mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19efb52521 | ||
|
|
81ef3ea3c5 | ||
|
|
6f07b7a372 | ||
|
|
b0f74b101a | ||
|
|
06a064538e | ||
|
|
166a433353 | ||
|
|
735fefd203 | ||
|
|
ed5cda4f44 | ||
|
|
b208af83f6 | ||
|
|
bad821ac4b | ||
|
|
8839012153 | ||
|
|
0958ef079e | ||
|
|
0bf2826110 | ||
|
|
c2840a43aa | ||
|
|
e8a8be521a | ||
|
|
a627fbe0e9 | ||
|
|
17f8233402 | ||
|
|
1c9e9079f0 | ||
|
|
69dc62fa78 | ||
|
|
f118a0949f | ||
|
|
f78824cdc4 | ||
|
|
f81de07830 | ||
|
|
3ae988c0bb | ||
|
|
5bed26cb72 | ||
|
|
c28d22e6cc | ||
|
|
8e1f2a3a87 | ||
|
|
6d1447a45c | ||
|
|
77c92b3567 | ||
|
|
6db7b672ca | ||
|
|
046cce6f43 | ||
|
|
c2c2d8cf21 | ||
|
|
148f8cea4f | ||
|
|
cd69ea546f | ||
|
|
7780a0d76e | ||
|
|
33a3352692 | ||
|
|
4004acf15d | ||
|
|
0b9402b530 | ||
|
|
e55799f8a5 | ||
|
|
0549efa627 | ||
|
|
a52cf166cb | ||
|
|
facffe9f07 | ||
|
|
148fb7f001 |
169
README.md
169
README.md
@@ -2,24 +2,30 @@
|
||||
|
||||
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.
|
||||
|
||||
[Check out the Meck discussion channel on the MeshCore Discord](https://discord.com/channels/1343693475589263471/1460136499390447670)
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/b30ce6bd-79af-44d3-93c4-f5e7e21e5621" alt="IMG_1453" width="300" height="650">
|
||||
|
||||
### Contents
|
||||
- [Supported Devices](#supported-devices)
|
||||
- [SD Card Requirements](#sd-card-requirements)
|
||||
- [Flashing Firmware](#flashing-firmware)
|
||||
- [First-Time Flash (Merged Firmware)](#first-time-flash-merged-firmware)
|
||||
- [Upgrading Firmware](#upgrading-firmware)
|
||||
- [SD Card Launcher](#sd-card-launcher)
|
||||
- [Launcher](#launcher)
|
||||
- [OTA Firmware Update](#ota-firmware-update-v13)
|
||||
- [Path Hash Mode (v0.9.9+)](#path-hash-mode-v099)
|
||||
- [T-Deck Pro](#t-deck-pro)
|
||||
- [Build Variants](#t-deck-pro-build-variants)
|
||||
- [Keyboard Controls](#t-deck-pro-keyboard-controls)
|
||||
- [Navigation (Home Screen)](#navigation-home-screen)
|
||||
- [Bluetooth (BLE)](#bluetooth-ble)
|
||||
- [WiFi Companion](#wifi-companion)
|
||||
- [Clock & Timezone](#clock--timezone)
|
||||
- [Channel Message Screen](#channel-message-screen)
|
||||
- [Contacts Screen](#contacts-screen)
|
||||
- [Sending a Direct Message](#sending-a-direct-message)
|
||||
- [Roomservers](#roomservers)
|
||||
- [Repeater Admin Screen](#repeater-admin-screen)
|
||||
- [Settings Screen](#settings-screen)
|
||||
- [Compose Mode](#compose-mode)
|
||||
@@ -27,6 +33,7 @@ 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)
|
||||
- [Lock Screen (T-Deck Pro)](#lock-screen-t-deck-pro)
|
||||
- [T5S3 E-Paper Pro](#t5s3-e-paper-pro)
|
||||
- [Build Variants](#t5s3-build-variants)
|
||||
- [Touch Navigation](#touch-navigation)
|
||||
@@ -70,13 +77,21 @@ Both devices use the ESP32-S3 with 16 MB flash and 8 MB PSRAM.
|
||||
|
||||
---
|
||||
|
||||
## SD Card Requirements
|
||||
|
||||
**An SD card is essential for Meck to function properly.** Many features — including the e-book reader, notes, bookmarks, web reader cache, audiobook playback, firmware updates, contact import/export, and WiFi credential storage — rely on files stored on the SD card. Without an SD card inserted, the device will boot and handle mesh messaging, but most extended features will be unavailable or will fail silently.
|
||||
|
||||
**Recommended:** A **32 GB or larger** microSD card formatted as **FAT32**. MeshCore users have found that **SanDisk** microSD cards are the most reliable across both the T-Deck Pro and T5S3.
|
||||
|
||||
---
|
||||
|
||||
## Flashing Firmware
|
||||
|
||||
Download the latest firmware from the [Releases](https://github.com/pelgraine/Meck/releases) page. Each release includes two types of `.bin` files per build variant:
|
||||
|
||||
| File Type | When to Use |
|
||||
|-----------|-------------|
|
||||
| `*_merged.bin` | **First-time flash** — includes bootloader, partition table, and firmware in a single file. Flash at address `0x0`. |
|
||||
| `*-merged.bin` | **First-time flash** — includes bootloader, partition table, and firmware in a single file. Flash at address `0x0`. |
|
||||
| `*.bin` (non-merged) | **Upgrading existing firmware** — firmware image only. Also used when loading firmware from an SD card via the Launcher. |
|
||||
|
||||
### First-Time Flash (Merged Firmware)
|
||||
@@ -87,7 +102,7 @@ If the device has never had Meck firmware (or you want a clean start), use the *
|
||||
|
||||
```
|
||||
esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
|
||||
write_flash 0x0 meck_t5s3_standalone_merged.bin
|
||||
write_flash 0x0 meck_t5s3_standalone-merged.bin
|
||||
```
|
||||
|
||||
On macOS the port is typically `/dev/cu.usbmodem*`. On Windows it will be a COM port like `COM3`.
|
||||
@@ -99,7 +114,7 @@ On macOS the port is typically `/dev/cu.usbmodem*`. On Windows it will be a COM
|
||||
3. Select the **merged** `.bin` file you downloaded
|
||||
4. Click **Flash**, select your device in the popup, and click **Connect**
|
||||
|
||||
> **Note:** The MeshCore Flasher flashes at address `0x0` by default, so the merged file is the correct choice here for first-time flashes.
|
||||
> **Note:** The MeshCore Flasher detects merged firmware by the `-merged.bin` suffix in the filename and automatically flashes at address `0x0`. If the filename doesn't end with `-merged.bin`, the flasher writes at `0x10000` instead, which will fail on a clean device.
|
||||
|
||||
### Upgrading Firmware
|
||||
|
||||
@@ -114,10 +129,25 @@ esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
|
||||
|
||||
> **Tip:** If you're unsure whether the device already has a bootloader, it's always safe to use the merged file and flash at `0x0` — it will overwrite everything cleanly.
|
||||
|
||||
### SD Card Launcher
|
||||
### Launcher
|
||||
|
||||
If you're loading firmware from an SD card via the LilyGo Launcher firmware, use the **non-merged** `.bin` file. The Launcher provides its own bootloader and only needs the application image.
|
||||
|
||||
### OTA Firmware Update (v1.3+)
|
||||
|
||||
Once Meck is installed, you can update firmware directly from your phone — no computer or serial cable required. The device creates a temporary WiFi access point and you upload the new `.bin` via your phone's browser.
|
||||
|
||||
1. Download the new **non-merged** `.bin` to your phone (from GitHub Releases, Discord, etc.)
|
||||
2. On the device: **Settings → Firmware Update → Enter** (T-Deck Pro) or **tap** (T5S3)
|
||||
3. The device starts a WiFi network called `Meck-Update-XXXX` and displays connection details
|
||||
4. On your phone: connect to the `Meck-Update` WiFi network, open a browser, go to `192.168.4.1`
|
||||
5. Tap **Choose File**, select the `.bin`, tap **Upload**
|
||||
6. The device receives the file, saves to SD, verifies, flashes, and reboots
|
||||
|
||||
The partition layout supports dual OTA slots — the old firmware remains on the inactive partition as an automatic rollback target. If the new firmware fails to boot, the ESP32 bootloader reverts to the previous working version automatically.
|
||||
|
||||
> **Note:** Use the **non-merged** `.bin` for OTA updates. The merged binary is only needed for first-time USB flashing.
|
||||
|
||||
---
|
||||
|
||||
## Path Hash Mode (v0.9.9+)
|
||||
@@ -149,8 +179,10 @@ For a detailed explanation of what multibyte path hash means and why it matters,
|
||||
| Variant | Environment | BLE | WiFi | 4G Modem | Audio DAC | Web Reader | Max Contacts |
|
||||
|---------|------------|-----|------|----------|-----------|------------|-------------|
|
||||
| Audio + BLE | `meck_audio_ble` | Yes | Yes (via BLE stack) | — | PCM5102A | Yes | 500 |
|
||||
| Audio + WiFi | `meck_audio_wifi` | — | Yes (TCP:5000) | — | PCM5102A | Yes | 1,500 |
|
||||
| Audio + Standalone | `meck_audio_standalone` | — | — | — | PCM5102A | No | 1,500 |
|
||||
| 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 |
|
||||
|
||||
The audio DAC and 4G modem occupy the same hardware slot and are mutually exclusive.
|
||||
@@ -175,8 +207,10 @@ The T-Deck Pro firmware includes full keyboard support for standalone messaging
|
||||
| T | Open SMS & Phone app (4G variant only) |
|
||||
| P | Open audiobook player (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) |
|
||||
| Q | Back to home screen |
|
||||
| Double-click Boot | Lock / unlock screen |
|
||||
|
||||
### Bluetooth (BLE)
|
||||
|
||||
@@ -184,12 +218,29 @@ BLE is **disabled by default** at boot to support standalone-first operation. Th
|
||||
|
||||
To connect to the MeshCore companion app, navigate to the **Bluetooth** home page (use D to page through) and press **Enter** to toggle BLE on. The BLE PIN will be displayed on screen. Toggle it off again the same way when you're done.
|
||||
|
||||
### WiFi Companion
|
||||
|
||||
The WiFi companion variants (`meck_audio_wifi`, `meck_4g_wifi`) connect to the MeshCore web app, meshcore.js, or Python CLI over your local network via TCP on port 5000. WiFi credentials are stored on the SD card at `/web/wifi.cfg`.
|
||||
|
||||
**Connecting:**
|
||||
|
||||
1. Navigate to the **WiFi** home page (use D to page through)
|
||||
2. Press **Enter** to toggle WiFi on
|
||||
3. The device scans for networks — select yours and enter the password
|
||||
4. Once connected, the IP address is displayed on the WiFi home page
|
||||
|
||||
Connect the MeshCore web app or meshcore.js to `<device IP>:5000`.
|
||||
|
||||
WiFi is also used by the web reader and IRC client on WiFi variants. The web reader shares the same connection — no extra setup needed.
|
||||
|
||||
> **Tip:** WiFi variants support up to 1,500 contacts (vs 500 for BLE variants) because they are not constrained by the BLE protocol ceiling.
|
||||
|
||||
### Clock & Timezone
|
||||
|
||||
The T-Deck Pro does not include a dedicated RTC chip, so after each reboot the device clock starts unset. The clock will appear in the nav bar (between node name and battery) once the time has been synced by one of two methods:
|
||||
The T-Deck Pro does not include a dedicated RTC chip, so after each reboot the device clock starts unset. The clock will appear in the nav bar (between node name and battery) once the time has been synced by one of these methods:
|
||||
|
||||
1. **GPS fix** (standalone) — Once the GPS acquires a satellite fix, the time is automatically synced from the NMEA data. No phone or BLE connection required. Typical time to first fix is 30–90 seconds outdoors with clear sky.
|
||||
2. **BLE companion app** — If BLE is enabled and connected to the MeshCore companion app, the app will push the current time to the device.
|
||||
2. **BLE/WiFi companion app** — If connected to the MeshCore companion app (via BLE or WiFi), the app will push the current time to the device.
|
||||
|
||||
**Setting your timezone:**
|
||||
|
||||
@@ -211,7 +262,7 @@ The GPS page also shows the current time, satellite count, position, altitude, a
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / S | Scroll messages up/down |
|
||||
| A / D | Switch between channels |
|
||||
| A / D | Switch between channels (press D past the last channel to reach the DM inbox, A to return) |
|
||||
| Enter | Compose new message |
|
||||
| R | Reply to a message — enter reply select mode, scroll to a message with W/S, then press Enter to compose a reply with an @mention |
|
||||
| V | View relay path of the last received message (scrollable, up to 20 hops) |
|
||||
@@ -230,12 +281,24 @@ Press **C** from the home screen to open the contacts list. All known mesh conta
|
||||
| R | Import contacts from SD card (wait 5–10 seconds for confirmation popup) |
|
||||
| Q | Back to home screen |
|
||||
|
||||
**Contact limits:** Standalone variants support up to 1,500 contacts (stored in PSRAM). BLE variants (both Audio-BLE and 4G-BLE) are limited to 500 contacts due to BLE protocol constraints.
|
||||
**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.
|
||||
|
||||
### Sending a Direct Message
|
||||
|
||||
Select a **Chat** contact in the contacts list and press **Enter** to start composing a direct message. The compose screen will show `DM: ContactName` in the header. Type your message and press **Enter** to send. The DM is sent encrypted directly to that contact (or flooded if no direct path is known). After sending or cancelling, you're returned to the contacts list.
|
||||
|
||||
Contacts with unread direct messages show a `*` marker next to their name in the contacts list.
|
||||
|
||||
**Reading received DMs:** On the Channel Messages screen, press **D** past the last group channel to reach the **DM inbox**. This shows all received direct messages with sender name and timestamp. Entering the DM inbox marks all DM messages as read and clears the unread indicator. Press **A** to return to group channels.
|
||||
|
||||
### Roomservers
|
||||
|
||||
Room servers are MeshCore nodes that host persistent chat rooms. Messages sent to a room server are stored and relayed to anyone who logs in. In Meck, room server messages arrive as contact messages and appear in the DM inbox alongside regular direct messages.
|
||||
|
||||
To interact with a room server, navigate to the Contacts screen, filter to **Room** contacts, select the room, and press **Enter** to open the Repeater Admin screen. Log in with the room's admin password to access room administration. On successful login, all unread messages from that room are automatically marked as read.
|
||||
|
||||
Room server messages are also synced to the companion app when connected via BLE or WiFi — the companion app will pull and display them alongside other messages.
|
||||
|
||||
### Repeater Admin Screen
|
||||
|
||||
Select a **Repeater** contact in the contacts list and press **Enter** to open the repeater admin screen. You'll be prompted for the repeater's admin password. Characters briefly appear as you type them before being masked, making it easier to enter symbols and numbers on the T-Deck Pro keyboard.
|
||||
@@ -266,8 +329,8 @@ Press **S** from the home screen to open settings. On first boot (when the devic
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / S | Navigate up / down through settings |
|
||||
| Enter | Edit selected setting |
|
||||
| Q | Back to home screen |
|
||||
| Enter | Edit selected setting, or enter a sub-screen |
|
||||
| Q | Back one level (sub-screen → top level → home screen) |
|
||||
|
||||
**Available settings:**
|
||||
|
||||
@@ -275,17 +338,26 @@ Press **S** from the home screen to open settings. On first boot (when the devic
|
||||
|---------|-------------|
|
||||
| Device Name | Text entry — type a name, Enter to confirm |
|
||||
| Radio Preset | A / D to cycle presets (MeshCore Default, Long Range, Fast/Short, EU Default), Enter to apply |
|
||||
| Frequency | W / S to adjust, Enter to confirm |
|
||||
| Frequency | Text entry — type exact value (e.g. 916.575), Enter to confirm |
|
||||
| Bandwidth | W / S to cycle standard values (31.25 / 62.5 / 125 / 250 / 500 kHz), Enter to confirm |
|
||||
| Spreading Factor | W / S to adjust (5–12), Enter to confirm |
|
||||
| Coding Rate | W / S to adjust (5–8), Enter to confirm |
|
||||
| TX Power | W / S to adjust (1–20 dBm), Enter to confirm |
|
||||
| UTC Offset | W / S to adjust (-12 to +14), Enter to confirm |
|
||||
| Path Hash Mode | A / D to cycle (0 = 1-byte, 1 = 2-byte, 2 = 3-byte), Enter to confirm |
|
||||
| Channels | View existing channels, add hashtag channels, or delete non-primary channels (X) |
|
||||
| Msg Rcvd LED Pulse | Toggle keyboard backlight flash on new message (Enter to toggle) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| Device Info | Public key and firmware version (read-only) |
|
||||
|
||||
The bottom of the settings screen also displays your node ID and firmware version. On the 4G variant, IMEI, carrier name, and APN details are shown here as well.
|
||||
**Contacts sub-screen** — press Enter on the `Contacts >>` row to open. Contains the contact auto-add mode picker (Auto All / Custom / Manual) and, when set to Custom, per-type toggles for Chat, Repeater, Room Server, Sensor, and an Overwrite Oldest option. Press Q to return to the top-level settings list.
|
||||
|
||||
**Channels sub-screen** — press Enter on the `Channels >>` row to open. Lists all current channels, with an option to add hashtag channels or delete non-primary channels (X). Press Q to return to the top-level settings list.
|
||||
|
||||
The top-level settings screen also displays your node ID and firmware version. On the 4G variant, IMEI, carrier name, and APN details are shown here as well.
|
||||
|
||||
When adding a hashtag channel, type the channel name and press Enter. The channel secret is automatically derived from the name via SHA-256, matching the standard MeshCore hashtag convention.
|
||||
|
||||
@@ -346,7 +418,7 @@ For full documentation including key mappings, dialpad usage, contacts managemen
|
||||
|
||||
### Web Browser & IRC
|
||||
|
||||
Press **B** from the home screen to open the web reader. This is available on the BLE and 4G variants (not the standalone audio variant, which excludes WiFi to preserve lowest-battery-usage design).
|
||||
Press **B** from the home screen to open the web reader. This is available on the BLE, WiFi, and 4G variants (not the standalone audio variant, which excludes WiFi to preserve lowest-battery-usage design).
|
||||
|
||||
The web reader home screen provides access to the **IRC client**, the **URL bar**, and your **bookmarks** and **history**. Select IRC Chat and press Enter to configure and connect to an IRC server. Select the URL bar to enter a web address, or scroll down to open a bookmark or history entry.
|
||||
|
||||
@@ -354,6 +426,14 @@ 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).
|
||||
|
||||
### 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.
|
||||
|
||||
Double-click the Boot button again to unlock and return to whatever screen you were on.
|
||||
|
||||
An auto-lock timer can be configured in **Settings → Auto Lock** (None / 2 / 5 / 10 / 15 / 30 minutes of idle time).
|
||||
|
||||
---
|
||||
|
||||
## T5S3 E-Paper Pro
|
||||
@@ -423,6 +503,8 @@ Long press the Boot button to lock the device. The lock screen shows:
|
||||
|
||||
Touch input is completely disabled while locked. Long press the Boot button again to unlock and return to whatever screen you were on.
|
||||
|
||||
An auto-lock timer can be configured in **Settings → Auto Lock** (None / 2 / 5 / 10 / 15 / 30 minutes of idle time). The CPU drops to 40 MHz while locked to reduce power consumption.
|
||||
|
||||
### Virtual Keyboard
|
||||
|
||||
Since the T5S3 has no physical keyboard, a full-screen QWERTY virtual keyboard appears automatically when text input is needed (composing messages, entering WiFi passwords, editing settings, etc.).
|
||||
@@ -435,14 +517,20 @@ The virtual keyboard supports:
|
||||
|
||||
Tap keys to type. Tap **Enter** to submit, or press the **Boot button** to cancel and close the keyboard.
|
||||
|
||||
### External Keyboard (CardKB)
|
||||
|
||||
The T5S3 supports the M5Stack CardKB (or compatible I2C keyboard) connected via the QWIIC port. When detected at boot, the CardKB can be used for all text input — composing messages, entering URLs, editing notes, and navigating menus — without the on-screen virtual keyboard.
|
||||
|
||||
The CardKB is auto-detected on the I2C bus at address `0x5F`. No configuration is needed — just plug it in.
|
||||
|
||||
### Display Settings
|
||||
|
||||
The T5S3 Settings screen includes two additional options not available on the T-Deck Pro:
|
||||
The T5S3 Settings screen includes one additional display option not available on the T-Deck Pro:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Dark Mode** | Inverts the display — white text on black background. Tap to toggle on/off. |
|
||||
| **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. |
|
||||
| **Dark Mode** | Inverts the display — white text on black background. 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.
|
||||
|
||||
@@ -481,7 +569,7 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Swipe up / down | Scroll messages |
|
||||
| Swipe left / right | Switch between channels |
|
||||
| Swipe left / right | Switch between channels (swipe left past the last channel to reach the DM inbox) |
|
||||
| Tap footer area | View relay path of last received message |
|
||||
| Tap path overlay | Dismiss overlay |
|
||||
| Long press (touch) | Open virtual keyboard to compose message to current channel |
|
||||
@@ -493,7 +581,7 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
|
||||
| Swipe up / down | Scroll through contacts |
|
||||
| Swipe left / right | Cycle contact filter (All → Chat → Repeater → Room → Sensor → Favourites) |
|
||||
| Tap | Select contact |
|
||||
| Long press on Chat contact | Open virtual keyboard to compose DM |
|
||||
| Long press on Chat contact | View unread DMs (if any), then compose DM |
|
||||
| Long press on Repeater contact | Open repeater admin login |
|
||||
|
||||
#### Text Reader (File List)
|
||||
@@ -508,6 +596,7 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Tap anywhere | Next page |
|
||||
| Tap footer bar | Go to page number (via virtual keyboard) |
|
||||
| Swipe left | Next page |
|
||||
| Swipe right | Previous page |
|
||||
| Swipe up / down | Next / previous page |
|
||||
@@ -548,8 +637,16 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Swipe up / down | Scroll node list |
|
||||
| Tap | Add selected node to contacts |
|
||||
| Long press | Rescan for nodes |
|
||||
|
||||
#### Last Heard
|
||||
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Swipe up / down | Scroll advert list |
|
||||
| Tap | Add to or delete from contacts |
|
||||
|
||||
#### Repeater Admin
|
||||
|
||||
| Gesture | Action |
|
||||
@@ -610,9 +707,9 @@ For developers:
|
||||
|
||||
**Companion Firmware**
|
||||
|
||||
The companion firmware can be connected to via BLE (T-Deck Pro and T5S3 BLE variants) or WiFi (T5S3 WiFi variant, TCP port 5000).
|
||||
The companion firmware can be connected to via BLE (T-Deck Pro and T5S3 BLE variants) or WiFi (T-Deck Pro WiFi variants and T5S3 WiFi variant, TCP port 5000).
|
||||
|
||||
> **Note:** On both the T-Deck Pro and T5S3, BLE is disabled by default at boot. On the T-Deck Pro, navigate to the Bluetooth home page and press Enter to enable BLE. On the T5S3, navigate to the Bluetooth home page and long-press the screen to toggle BLE on.
|
||||
> **Note:** On both the T-Deck Pro and T5S3, BLE and WiFi are disabled by default at boot. On the T-Deck Pro, navigate to the Bluetooth or WiFi home page and press Enter to enable. On the T5S3, navigate to the Bluetooth home page and long-press the screen to toggle BLE on.
|
||||
|
||||
- Web: https://app.meshcore.nz
|
||||
- Android: https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android
|
||||
@@ -649,17 +746,26 @@ There are a number of fairly major features in the pipeline, with no particular
|
||||
- [X] Settings screen with radio presets, channel management, and first-boot onboarding
|
||||
- [X] Expand SMS app to enable phone calls
|
||||
- [X] Basic web reader app with IRC client
|
||||
- [X] Lock screen with auto-lock timer and low-power standby
|
||||
- [X] Last heard passive advert list
|
||||
- [X] Touch-to-select on contacts, discovery, settings, text reader, notes screens
|
||||
- [X] Map screen with GPS tile rendering
|
||||
- [X] WiFi companion environment
|
||||
- [X] OTA firmware update via phone
|
||||
- [X] DM inbox with per-contact unread indicators
|
||||
- [X] Roomserver message handling and mark-read on login
|
||||
- [ ] Fix M4B rendering to enable chaptered audiobook playback
|
||||
- [ ] Better JPEG and PNG decoding
|
||||
- [ ] Improve EPUB rendering and EPUB format handling
|
||||
- [ ] Map support with GPS
|
||||
- [ ] WiFi companion environment
|
||||
- [ ] 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
|
||||
- [X] Touch-navigable home screen with tappable tile grid
|
||||
- [X] Full virtual keyboard for text entry
|
||||
- [X] Lock screen with clock, battery, and unread count
|
||||
- [X] Lock screen with clock, battery, unread count, and auto-lock timer
|
||||
- [X] Backlight control (double/triple-click Boot button)
|
||||
- [X] Dark mode and portrait mode display settings
|
||||
- [X] Channel messages with swipe navigation and touch compose
|
||||
@@ -668,9 +774,14 @@ There are a number of fairly major features in the pipeline, with no particular
|
||||
- [X] Web reader with virtual keyboard URL/search entry (WiFi variant)
|
||||
- [X] Settings screen with touch editing
|
||||
- [X] Serial clock sync for hardware RTC
|
||||
- [ ] Emoji sprites on home tiles
|
||||
- [ ] Portrait mode toggle via quadruple-click Boot button
|
||||
- [ ] Hibernate should auto-off backlight
|
||||
- [X] CardKB external keyboard support (via QWIIC)
|
||||
- [X] Last heard passive advert list
|
||||
- [X] Tap-to-select on contacts, discovery, settings, text reader, notes screens
|
||||
- [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
|
||||
- [ ] Improve EPUB rendering and EPUB format handling
|
||||
- [ ] Customised user option for larger-font mode
|
||||
|
||||
## 📞 Get Support
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This adds a text reader accessible via the **R** key from the home screen.
|
||||
This adds a text reader accessible via the **E** key from the home screen.
|
||||
|
||||
**Features:**
|
||||
- Browse `.txt` and `.epub` files from `/books/` folder on SD card
|
||||
@@ -13,17 +13,27 @@ This adds a text reader accessible via the **R** key from the home screen.
|
||||
- Index files cached to SD for instant re-opens
|
||||
- Bookmark indicator (`*`) on files with saved positions
|
||||
|
||||
**Key Mapping:**
|
||||
**Key Mapping (T-Deck Pro):**
|
||||
| Context | Key | Action |
|
||||
|---------|-----|--------|
|
||||
| Home screen | E | Open text reader |
|
||||
| File list | W/S | Navigate up/down |
|
||||
| File list | Enter | Open selected file |
|
||||
| File list | Tap / Enter | Open selected file |
|
||||
| File list | Q | Back to home screen |
|
||||
| Reading | W/A | Previous page |
|
||||
| Reading | S/D/Space/Enter | Next page |
|
||||
| Reading | S/D/Space | Next page |
|
||||
| Reading | Enter | Go to page number (type digits, Enter to confirm, Q to cancel) |
|
||||
| Reading | Q | Close book → file list |
|
||||
| Reading | C | Enter compose mode |
|
||||
|
||||
**Touch Gestures (T5S3):**
|
||||
| Context | Gesture | Action |
|
||||
|---------|---------|--------|
|
||||
| File list | Swipe up/down | Scroll file list |
|
||||
| File list | Tap | Open selected book |
|
||||
| Reading | Tap | Next page |
|
||||
| Reading | Swipe left/right | Next / previous page |
|
||||
| Reading | Tap footer | Go to page number (via virtual keyboard) |
|
||||
| Reading | Long press | Close book → file list |
|
||||
|
||||
---
|
||||
|
||||
@@ -113,4 +123,4 @@ The conversion is handled by three components:
|
||||
- Page content is pre-read from SD into a memory buffer during `handleInput()`, then rendered from buffer during `render()` — this avoids SPI bus conflicts during display refresh
|
||||
- Layout metrics (chars per line, lines per page) are calculated dynamically from the display driver's font metrics on first entry
|
||||
- EPUB conversion runs synchronously in `openBook()` — the e-ink splash screen keeps the user informed while the ESP32 processes the archive
|
||||
- ZIP extraction uses the ESP32-S3's hardware-optimised ROM `tinfl` inflate, avoiding external compression library dependencies and the linker conflicts they cause
|
||||
- ZIP extraction uses the ESP32-S3's hardware-optimised ROM `tinfl` inflate, avoiding external compression library dependencies and the linker conflicts they cause
|
||||
@@ -252,6 +252,34 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
|
||||
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
|
||||
|
||||
// v1.1+ Meck fields — may not exist in older prefs files
|
||||
if (file.read((uint8_t *)&_prefs.gps_baudrate, sizeof(_prefs.gps_baudrate)) != sizeof(_prefs.gps_baudrate)) {
|
||||
_prefs.gps_baudrate = 0; // default: use compile-time GPS_BAUDRATE
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold)) != sizeof(_prefs.interference_threshold)) {
|
||||
_prefs.interference_threshold = 0; // default: disabled
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)) != sizeof(_prefs.dark_mode)) {
|
||||
_prefs.dark_mode = 0; // default: light mode
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)) != sizeof(_prefs.portrait_mode)) {
|
||||
_prefs.portrait_mode = 0; // default: landscape
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)) != sizeof(_prefs.auto_lock_minutes)) {
|
||||
_prefs.auto_lock_minutes = 0; // default: disabled
|
||||
}
|
||||
|
||||
// Clamp to valid ranges
|
||||
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
|
||||
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
|
||||
// auto_lock_minutes: only accept known options (0, 2, 5, 10, 15, 30)
|
||||
{
|
||||
uint8_t alm = _prefs.auto_lock_minutes;
|
||||
if (alm != 0 && alm != 2 && alm != 5 && alm != 10 && alm != 15 && alm != 30) {
|
||||
_prefs.auto_lock_minutes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
@@ -291,6 +319,11 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)); // 90
|
||||
file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 91
|
||||
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 92
|
||||
file.write((uint8_t *)&_prefs.gps_baudrate, sizeof(_prefs.gps_baudrate)); // 93
|
||||
file.write((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold)); // 97
|
||||
file.write((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)); // 98
|
||||
file.write((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)); // 99
|
||||
file.write((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)); // 100
|
||||
|
||||
file.close();
|
||||
}
|
||||
@@ -443,6 +476,101 @@ void DataStore::saveContacts(DataStoreHost* host) {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Chunked contact save — non-blocking across multiple loop iterations
|
||||
// =========================================================================
|
||||
|
||||
bool DataStore::beginSaveContacts(DataStoreHost* host) {
|
||||
if (_saveInProgress) return false; // Already saving
|
||||
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
_saveFile = openWrite(fs, "/contacts3.tmp");
|
||||
if (!_saveFile) {
|
||||
Serial.println("DataStore: chunked save FAILED — cannot open tmp file");
|
||||
return false;
|
||||
}
|
||||
|
||||
_saveHost = host;
|
||||
_saveIdx = 0;
|
||||
_saveRecordsWritten = 0;
|
||||
_saveWriteOk = true;
|
||||
_saveInProgress = true;
|
||||
Serial.println("DataStore: chunked save started");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DataStore::saveContactsChunk(int batchSize) {
|
||||
if (!_saveInProgress || !_saveWriteOk) return false;
|
||||
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
int written = 0;
|
||||
|
||||
while (written < batchSize && _saveHost->getContactForSave(_saveIdx, c)) {
|
||||
bool success = (_saveFile.write(c.id.pub_key, 32) == 32);
|
||||
success = success && (_saveFile.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (_saveFile.write(&c.type, 1) == 1);
|
||||
success = success && (_saveFile.write(&c.flags, 1) == 1);
|
||||
success = success && (_saveFile.write(&unused, 1) == 1);
|
||||
success = success && (_saveFile.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (_saveFile.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (_saveFile.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (_saveFile.write(c.out_path, 64) == 64);
|
||||
success = success && (_saveFile.write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (_saveFile.write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (_saveFile.write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
|
||||
if (!success) {
|
||||
_saveWriteOk = false;
|
||||
Serial.printf("DataStore: chunked save write error at record %d\n", _saveIdx);
|
||||
return false; // Error — finishSaveContacts will clean up
|
||||
}
|
||||
|
||||
_saveRecordsWritten++;
|
||||
_saveIdx++;
|
||||
written++;
|
||||
}
|
||||
|
||||
// Check if there are more contacts to write
|
||||
ContactInfo peek;
|
||||
if (_saveHost->getContactForSave(_saveIdx, peek)) {
|
||||
return true; // More to write
|
||||
}
|
||||
return false; // Done
|
||||
}
|
||||
|
||||
void DataStore::finishSaveContacts() {
|
||||
if (!_saveInProgress) return;
|
||||
|
||||
_saveFile.close();
|
||||
_saveInProgress = false;
|
||||
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
const char* finalPath = "/contacts3";
|
||||
const char* tmpPath = "/contacts3.tmp";
|
||||
|
||||
// Verify
|
||||
size_t expectedBytes = _saveRecordsWritten * 152;
|
||||
File verify = openRead(fs, tmpPath);
|
||||
size_t bytesWritten = verify ? verify.size() : 0;
|
||||
if (verify) verify.close();
|
||||
|
||||
if (!_saveWriteOk || bytesWritten != expectedBytes) {
|
||||
Serial.printf("DataStore: chunked save ABORTED — wrote %d bytes, expected %d (%d records)\n",
|
||||
(int)bytesWritten, (int)expectedBytes, _saveRecordsWritten);
|
||||
fs->remove(tmpPath);
|
||||
return;
|
||||
}
|
||||
|
||||
fs->remove(finalPath);
|
||||
if (fs->rename(tmpPath, finalPath)) {
|
||||
Serial.printf("DataStore: saved %d contacts (%d bytes, chunked)\n",
|
||||
_saveRecordsWritten, (int)bytesWritten);
|
||||
} else {
|
||||
Serial.println("DataStore: rename failed, tmp file preserved");
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::loadChannels(DataStoreHost* host) {
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ class DataStore {
|
||||
void checkAdvBlobFile();
|
||||
#endif
|
||||
|
||||
// Chunked save state
|
||||
File _saveFile;
|
||||
DataStoreHost* _saveHost = nullptr;
|
||||
uint32_t _saveIdx = 0;
|
||||
uint32_t _saveRecordsWritten = 0;
|
||||
bool _saveInProgress = false;
|
||||
bool _saveWriteOk = true;
|
||||
|
||||
public:
|
||||
DataStore(FILESYSTEM& fs, mesh::RTCClock& clock);
|
||||
DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock);
|
||||
@@ -37,6 +45,14 @@ public:
|
||||
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
|
||||
void loadContacts(DataStoreHost* host);
|
||||
void saveContacts(DataStoreHost* host);
|
||||
// Chunked save — splits contact write across multiple loop iterations
|
||||
// to prevent blocking the main loop for 500ms+ on large contact lists.
|
||||
// Call beginSaveContacts(), then saveContactsChunk() each loop until it
|
||||
// returns false (done), then finishSaveContacts() to verify and commit.
|
||||
bool beginSaveContacts(DataStoreHost* host);
|
||||
bool saveContactsChunk(int batchSize = 20); // returns true if more to write
|
||||
void finishSaveContacts();
|
||||
bool isSaveInProgress() const { return _saveInProgress; }
|
||||
void loadChannels(DataStoreHost* host);
|
||||
void saveChannels(DataStoreHost* host);
|
||||
void migrateToSecondaryFS();
|
||||
@@ -51,4 +67,4 @@ public:
|
||||
|
||||
private:
|
||||
FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;};
|
||||
};
|
||||
};
|
||||
@@ -380,6 +380,7 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path
|
||||
|
||||
memcpy(p->pubkey_prefix, contact.id.pub_key, sizeof(p->pubkey_prefix));
|
||||
strcpy(p->name, contact.name);
|
||||
p->type = contact.type;
|
||||
p->recv_timestamp = getRTCClock()->getCurrentTime();
|
||||
p->path_len = mesh::Packet::copyPath(p->path, path, path_len);
|
||||
}
|
||||
@@ -427,6 +428,10 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) {
|
||||
return max_num;
|
||||
}
|
||||
|
||||
void MyMesh::scheduleLazyContactSave() {
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
}
|
||||
|
||||
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
|
||||
out_frame[0] = PUSH_CODE_PATH_UPDATED;
|
||||
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
|
||||
@@ -493,7 +498,24 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
||||
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
|
||||
if (should_display && _ui) {
|
||||
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
|
||||
// For signed messages (room server posts): the extra bytes contain the
|
||||
// original poster's pub_key prefix. Look up their name and format as
|
||||
// "PosterName: message" so the UI shows who actually wrote it.
|
||||
if (txt_type == TXT_TYPE_SIGNED_PLAIN && extra && extra_len >= 4) {
|
||||
ContactInfo* poster = lookupContactByPubKey(extra, extra_len);
|
||||
if (poster) {
|
||||
char formatted[MAX_PACKET_PAYLOAD];
|
||||
snprintf(formatted, sizeof(formatted), "%s: %s", poster->name, text);
|
||||
_ui->newMsg(path_len, from.name, formatted, offline_queue_len, msg_path, pkt->_snr);
|
||||
} else {
|
||||
// Poster not in contacts — show raw text (no name prefix)
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
}
|
||||
} else {
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
}
|
||||
|
||||
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
|
||||
}
|
||||
#endif
|
||||
@@ -732,6 +754,13 @@ bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password, uint3
|
||||
uint8_t save_path_len = recipient->out_path_len;
|
||||
recipient->out_path_len = OUT_PATH_UNKNOWN;
|
||||
|
||||
// For room servers: reset sync_since to zero so the server pushes ALL posts.
|
||||
// The device has no persistent DM storage, so every session needs full history.
|
||||
// sync_since naturally updates as messages arrive (BaseChatMesh::onPeerDataRecv).
|
||||
if (recipient->type == ADV_TYPE_ROOM) {
|
||||
recipient->sync_since = 0;
|
||||
}
|
||||
|
||||
Serial.printf("[uiLogin] Sending login to '%s' (idx=%d, path was 0x%02X, now 0x%02X, hash_mode=%d)\n",
|
||||
recipient->name, contact_idx, save_path_len, recipient->out_path_len, _prefs.path_hash_mode);
|
||||
|
||||
@@ -1193,12 +1222,25 @@ void MyMesh::begin(bool has_display) {
|
||||
_prefs.gps_baudrate != 9600 && _prefs.gps_baudrate != 19200 &&
|
||||
_prefs.gps_baudrate != 38400 && _prefs.gps_baudrate != 57600 &&
|
||||
_prefs.gps_baudrate != 115200) {
|
||||
Serial.printf("PREFS: invalid gps_baudrate=%lu — reset to 0 (default)\n",
|
||||
(unsigned long)_prefs.gps_baudrate);
|
||||
_prefs.gps_baudrate = 0; // reset to default if invalid
|
||||
}
|
||||
// interference_threshold: 0 = disabled, minimum functional value is 14
|
||||
// interference_threshold: 0 = disabled, minimum functional value is 14, max sane ~30
|
||||
if (_prefs.interference_threshold > 0 && _prefs.interference_threshold < 14) {
|
||||
_prefs.interference_threshold = 0;
|
||||
}
|
||||
if (_prefs.interference_threshold > 50) {
|
||||
Serial.printf("PREFS: invalid interference_threshold=%d — reset to 0 (disabled)\n",
|
||||
_prefs.interference_threshold);
|
||||
_prefs.interference_threshold = 0; // garbage from prefs upgrade — disable
|
||||
}
|
||||
// Clamp remaining v1.0 fields that may contain garbage after upgrade from older firmware
|
||||
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
|
||||
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
|
||||
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
|
||||
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
|
||||
if (_prefs.hint_shown > 1) _prefs.hint_shown = 0;
|
||||
|
||||
#ifdef BLE_PIN_CODE // 123456 by default
|
||||
if (_prefs.ble_pin == 0) {
|
||||
@@ -1571,6 +1613,13 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
uint8_t ch_idx = is_v3_ch ? out_frame[4] : out_frame[1];
|
||||
_ui->markChannelReadFromBLE(ch_idx);
|
||||
}
|
||||
|
||||
// Mark DM slot read when companion app syncs a contact (DM/room) message
|
||||
bool is_v3_dm = (out_frame[0] == RESP_CODE_CONTACT_MSG_RECV_V3);
|
||||
bool is_old_dm = (out_frame[0] == RESP_CODE_CONTACT_MSG_RECV);
|
||||
if (is_v3_dm || is_old_dm) {
|
||||
_ui->markChannelReadFromBLE(0xFF);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
@@ -2982,10 +3031,19 @@ void MyMesh::loop() {
|
||||
|
||||
// is there are pending dirty contacts write needed?
|
||||
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
|
||||
saveContacts();
|
||||
if (!_store->isSaveInProgress()) {
|
||||
_store->beginSaveContacts(this);
|
||||
}
|
||||
dirty_contacts_expiry = 0;
|
||||
}
|
||||
|
||||
// Drive chunked contact save — write a batch each loop iteration
|
||||
if (_store->isSaveInProgress()) {
|
||||
if (!_store->saveContactsChunk(20)) { // 20 contacts per chunk (~3KB, ~30ms)
|
||||
_store->finishSaveContacts(); // Done or error — verify and commit
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery scan timeout
|
||||
if (_discoveryActive && millisHasNowPassed(_discoveryTimeout)) {
|
||||
_discoveryActive = false;
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 10
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "13 March 2026"
|
||||
#define FIRMWARE_BUILD_DATE "23 March 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v1.0"
|
||||
#define FIRMWARE_VERSION "Meck v1.3"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
@@ -79,6 +79,7 @@
|
||||
struct AdvertPath {
|
||||
uint8_t pubkey_prefix[7];
|
||||
uint8_t path_len;
|
||||
uint8_t type; // ADV_TYPE_* (Chat/Repeater/Room/Sensor)
|
||||
char name[32];
|
||||
uint32_t recv_timestamp;
|
||||
uint8_t path[MAX_PATH_SIZE];
|
||||
@@ -119,6 +120,12 @@ public:
|
||||
int getDiscoveredCount() const { return _discoveredCount; }
|
||||
const DiscoveredNode& getDiscovered(int idx) const { return _discovered[idx]; }
|
||||
bool addDiscoveredToContacts(int idx); // promote a discovered node into contacts
|
||||
|
||||
// Last Heard — public wrappers for contact add/remove from UI
|
||||
void scheduleLazyContactSave();
|
||||
int getContactBlob(const uint8_t key[], int key_len, uint8_t dest_buf[]) {
|
||||
return getBlobByKey(key, key_len, dest_buf);
|
||||
}
|
||||
|
||||
// Queue a sent channel message for BLE app sync
|
||||
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
|
||||
@@ -262,7 +269,7 @@ private:
|
||||
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
|
||||
int next_ack_idx;
|
||||
|
||||
#define ADVERT_PATH_TABLE_SIZE 16
|
||||
#define ADVERT_PATH_TABLE_SIZE 40
|
||||
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
|
||||
|
||||
// Sent message repeat tracking
|
||||
|
||||
@@ -37,4 +37,6 @@ struct NodePrefs { // persisted to file
|
||||
uint8_t interference_threshold; // Interference threshold in dB (0=disabled, 14+=enabled)
|
||||
uint8_t dark_mode; // 0=off (white bg), 1=on (black bg)
|
||||
uint8_t portrait_mode; // 0=landscape, 1=portrait — T5S3 only
|
||||
uint8_t auto_lock_minutes; // 0=disabled, 2/5/10/15/30=auto-lock after idle
|
||||
uint8_t hint_shown; // 0=show nav hint on boot, 1=already shown (dismiss permanently)
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
122
examples/companion_radio/ui-new/CardKBKeyboard.h
Normal file
122
examples/companion_radio/ui-new/CardKBKeyboard.h
Normal file
@@ -0,0 +1,122 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// CardKBKeyboard — M5Stack CardKB (or compatible) I2C keyboard driver
|
||||
//
|
||||
// Polls 0x5F on the shared I2C bus via QWIIC connector.
|
||||
// Maps CardKB special key codes to Meck key constants.
|
||||
//
|
||||
// Usage:
|
||||
// CardKBKeyboard cardkb;
|
||||
// if (cardkb.begin()) { /* detected */ }
|
||||
// char key = cardkb.readKey(); // returns 0 if no key
|
||||
// =============================================================================
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && defined(MECK_CARDKB)
|
||||
#ifndef CARDKB_KEYBOARD_H
|
||||
#define CARDKB_KEYBOARD_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include "variant.h" // For I2C_SDA, I2C_SCL (bus recovery)
|
||||
|
||||
// I2C address (defined in variant.h, fallback here)
|
||||
#ifndef CARDKB_I2C_ADDR
|
||||
#define CARDKB_I2C_ADDR 0x5F
|
||||
#endif
|
||||
|
||||
// CardKB special key codes (from M5Stack documentation)
|
||||
#define CARDKB_KEY_UP 0xB5
|
||||
#define CARDKB_KEY_DOWN 0xB6
|
||||
#define CARDKB_KEY_LEFT 0xB4
|
||||
#define CARDKB_KEY_RIGHT 0xB7
|
||||
#define CARDKB_KEY_TAB 0x09
|
||||
#define CARDKB_KEY_ESC 0x1B
|
||||
#define CARDKB_KEY_BS 0x08
|
||||
#define CARDKB_KEY_ENTER 0x0D
|
||||
#define CARDKB_KEY_DEL 0x7F
|
||||
#define CARDKB_KEY_FN 0x00 // Fn modifier (swallowed by CardKB internally)
|
||||
|
||||
class CardKBKeyboard {
|
||||
public:
|
||||
CardKBKeyboard() : _detected(false) {}
|
||||
|
||||
// Probe for CardKB on the I2C bus. Call after Wire.begin().
|
||||
bool begin() {
|
||||
Wire.beginTransmission(CARDKB_I2C_ADDR);
|
||||
_detected = (Wire.endTransmission() == 0);
|
||||
if (_detected) {
|
||||
Serial.println("[CardKB] Detected at 0x5F");
|
||||
}
|
||||
return _detected;
|
||||
}
|
||||
|
||||
// Re-probe (e.g. for hot-plug detection every few seconds)
|
||||
bool probe() {
|
||||
Wire.beginTransmission(CARDKB_I2C_ADDR);
|
||||
_detected = (Wire.endTransmission() == 0);
|
||||
return _detected;
|
||||
}
|
||||
|
||||
bool isDetected() const { return _detected; }
|
||||
|
||||
// Poll for a keypress. Returns 0 if no key available.
|
||||
// Returns raw ASCII for printable chars, or Meck KEY_* constants for nav keys.
|
||||
// Throttled to avoid flooding I2C bus — polls at most every 50ms.
|
||||
// On read failure, backs off 500ms and re-inits Wire to recover bus state.
|
||||
char readKey() {
|
||||
if (!_detected) return 0;
|
||||
|
||||
unsigned long now = millis();
|
||||
if (now - _lastPoll < _pollInterval) return 0;
|
||||
_lastPoll = now;
|
||||
|
||||
Wire.requestFrom((uint8_t)CARDKB_I2C_ADDR, (uint8_t)1);
|
||||
if (!Wire.available()) {
|
||||
_errorCount++;
|
||||
if (_errorCount >= 3) {
|
||||
// I2C bus may be stuck — re-init to recover
|
||||
Wire.begin(I2C_SDA, I2C_SCL);
|
||||
Wire.setClock(100000);
|
||||
_pollInterval = 500; // Back off for 500ms
|
||||
_errorCount = 0;
|
||||
Serial.println("[CardKB] I2C error recovery — bus re-init");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
_errorCount = 0;
|
||||
_pollInterval = 50; // Normal polling rate
|
||||
|
||||
uint8_t raw = Wire.read();
|
||||
if (raw == 0) return 0;
|
||||
|
||||
// Map CardKB special keys to Meck constants
|
||||
switch (raw) {
|
||||
case CARDKB_KEY_UP: return 0xF2; // KEY_PREV
|
||||
case CARDKB_KEY_DOWN: return 0xF1; // KEY_NEXT
|
||||
case CARDKB_KEY_LEFT: return 0xF3; // KEY_LEFT
|
||||
case CARDKB_KEY_RIGHT: return 0xF4; // KEY_RIGHT
|
||||
case CARDKB_KEY_ENTER: return '\r';
|
||||
case CARDKB_KEY_BS: return '\b';
|
||||
case CARDKB_KEY_DEL: return '\b'; // Treat delete same as backspace
|
||||
case CARDKB_KEY_ESC: return 0x1B; // ESC — handled by caller
|
||||
case CARDKB_KEY_TAB: return 0x09; // Tab — available for future use
|
||||
default:
|
||||
// Printable ASCII — pass through unchanged
|
||||
if (raw >= 0x20 && raw <= 0x7E) {
|
||||
return (char)raw;
|
||||
}
|
||||
// Unknown code — ignore
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
bool _detected;
|
||||
unsigned long _lastPoll = 0;
|
||||
unsigned long _pollInterval = 50; // ms between polls (increases on error)
|
||||
uint8_t _errorCount = 0;
|
||||
};
|
||||
|
||||
#endif // CARDKB_KEYBOARD_H
|
||||
#endif // LilyGo_T5S3_EPaper_Pro && MECK_CARDKB
|
||||
@@ -59,11 +59,19 @@ public:
|
||||
uint8_t path_len;
|
||||
uint8_t channel_idx; // Which channel this message belongs to
|
||||
int8_t snr; // Receive SNR × 4 (0 if locally sent or unknown)
|
||||
uint32_t dm_peer_hash; // DM peer name hash (for conversation filtering)
|
||||
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes
|
||||
char text[CHANNEL_MSG_TEXT_LEN];
|
||||
bool valid;
|
||||
};
|
||||
|
||||
// Simple hash for DM peer matching
|
||||
static uint32_t peerHash(const char* s) {
|
||||
uint32_t h = 5381;
|
||||
while (*s) { h = ((h << 5) + h) ^ (uint8_t)*s++; }
|
||||
return h;
|
||||
}
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
@@ -84,6 +92,31 @@ private:
|
||||
int _replySelectPos; // Index into chronological channelMsgs[] (0=oldest)
|
||||
int _replyChannelMsgCount; // Cached count from last render (for input bounds)
|
||||
|
||||
// DM tab (channel_idx == 0xFF) two-level view:
|
||||
// Inbox mode: list of contacts you have DMs from
|
||||
// Conversation mode: messages filtered to one contact
|
||||
bool _dmInboxMode; // true = showing inbox list, false = conversation
|
||||
int _dmInboxScroll; // Scroll position in inbox list
|
||||
char _dmFilterName[32]; // Selected contact name for conversation view
|
||||
int _dmContactIdx; // Contact index for conversation (-1 if unknown)
|
||||
uint8_t _dmContactPerms; // Last login permissions for this contact (0=none/guest)
|
||||
const uint8_t* _dmUnreadPtr; // Pointer to per-contact DM unread array (from UITask)
|
||||
|
||||
// Helper: does a message belong to the current view?
|
||||
bool msgMatchesView(const ChannelMessage& msg) const {
|
||||
if (!msg.valid) return false;
|
||||
if (_viewChannelIdx != 0xFF) {
|
||||
return msg.channel_idx == _viewChannelIdx;
|
||||
}
|
||||
// DM tab in conversation mode: filter by peer hash
|
||||
if (!_dmInboxMode && _dmFilterName[0] != '\0') {
|
||||
if (msg.channel_idx != 0xFF) return false;
|
||||
return msg.dm_peer_hash == peerHash(_dmFilterName);
|
||||
}
|
||||
// Inbox mode or no filter — match all DMs
|
||||
return msg.channel_idx == 0xFF;
|
||||
}
|
||||
|
||||
// Per-channel unread message counts (standalone mode)
|
||||
// Index 0..MAX_GROUP_CHANNELS-1 for channel messages
|
||||
// Index MAX_GROUP_CHANNELS for DMs (channel_idx == 0xFF)
|
||||
@@ -93,10 +126,13 @@ public:
|
||||
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
|
||||
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathScrollPos(0), _pathHopsVisible(20),
|
||||
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0) {
|
||||
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0),
|
||||
_dmInboxMode(true), _dmInboxScroll(0), _dmContactIdx(-1), _dmContactPerms(0), _dmUnreadPtr(nullptr) {
|
||||
_dmFilterName[0] = '\0';
|
||||
// Initialize all messages as invalid
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
_messages[i].valid = false;
|
||||
_messages[i].dm_peer_hash = 0;
|
||||
memset(_messages[i].path, 0, MSG_PATH_MAX);
|
||||
}
|
||||
// Initialize unread counts
|
||||
@@ -106,8 +142,9 @@ public:
|
||||
void setSDReady(bool ready) { _sdReady = ready; }
|
||||
|
||||
// Add a new message to the history
|
||||
// peer_name: for DMs, the contact this message belongs to (sender for received, recipient for sent)
|
||||
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
|
||||
const uint8_t* path_bytes = nullptr, int8_t snr = 0) {
|
||||
const uint8_t* path_bytes = nullptr, int8_t snr = 0, const char* peer_name = nullptr) {
|
||||
// Move to next slot in circular buffer
|
||||
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
|
||||
|
||||
@@ -118,6 +155,13 @@ public:
|
||||
msg->snr = snr;
|
||||
msg->valid = true;
|
||||
|
||||
// Set DM peer hash for conversation filtering
|
||||
if (channel_idx == 0xFF) {
|
||||
msg->dm_peer_hash = peerHash(peer_name ? peer_name : sender);
|
||||
} else {
|
||||
msg->dm_peer_hash = 0;
|
||||
}
|
||||
|
||||
// Store path hop hashes
|
||||
memset(msg->path, 0, MSG_PATH_MAX);
|
||||
if (path_bytes && path_len > 0 && path_len != 0xFF) {
|
||||
@@ -158,7 +202,7 @@ public:
|
||||
int getMessageCountForChannel() const {
|
||||
int count = 0;
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
if (_messages[i].valid && _messages[i].channel_idx == _viewChannelIdx) {
|
||||
if (msgMatchesView(_messages[i])) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
@@ -173,11 +217,47 @@ public:
|
||||
_scrollPos = 0;
|
||||
_showPathOverlay = false;
|
||||
_pathScrollPos = 0;
|
||||
// Reset DM inbox state when entering DM tab
|
||||
if (idx == 0xFF) {
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
_dmContactIdx = -1;
|
||||
_dmContactPerms = 0;
|
||||
}
|
||||
markChannelRead(idx);
|
||||
}
|
||||
bool isDMTab() const { return _viewChannelIdx == 0xFF; }
|
||||
bool isDMInboxMode() const { return _viewChannelIdx == 0xFF && _dmInboxMode; }
|
||||
bool isDMConversation() const { return _viewChannelIdx == 0xFF && !_dmInboxMode; }
|
||||
const char* getDMFilterName() const { return _dmFilterName; }
|
||||
|
||||
// Open a specific contact's DM conversation directly (skipping inbox)
|
||||
void openConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0) {
|
||||
strncpy(_dmFilterName, contactName, sizeof(_dmFilterName) - 1);
|
||||
_dmFilterName[sizeof(_dmFilterName) - 1] = '\0';
|
||||
_dmInboxMode = false;
|
||||
_dmContactIdx = contactIdx;
|
||||
_dmContactPerms = perms;
|
||||
_scrollPos = 0;
|
||||
}
|
||||
|
||||
int getDMContactIdx() const { return _dmContactIdx; }
|
||||
uint8_t getDMContactPerms() const { return _dmContactPerms; }
|
||||
void setDMContactPerms(uint8_t p) { _dmContactPerms = p; }
|
||||
bool isShowingPathOverlay() const { return _showPathOverlay; }
|
||||
void dismissPathOverlay() { _showPathOverlay = false; _pathScrollPos = 0; }
|
||||
|
||||
// Set pointer to per-contact DM unread array (called by UITask after allocation)
|
||||
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnreadPtr = ptr; }
|
||||
|
||||
// Subtract a specific amount from the DM unread slot (used by per-contact clearing)
|
||||
void subtractDMUnread(int count) {
|
||||
int slot = MAX_GROUP_CHANNELS; // DM slot
|
||||
_unread[slot] -= count;
|
||||
if (_unread[slot] < 0) _unread[slot] = 0;
|
||||
}
|
||||
|
||||
// --- Reply select mode (R key → pick a message → Enter to @mention reply) ---
|
||||
bool isReplySelectMode() const { return _replySelectMode; }
|
||||
void exitReplySelect() { _replySelectMode = false; _replySelectPos = -1; }
|
||||
@@ -206,7 +286,7 @@ public:
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
|
||||
if (_messages[idx].valid && msgMatchesView(_messages[idx])) {
|
||||
rsMsgs[count++] = idx;
|
||||
}
|
||||
}
|
||||
@@ -230,7 +310,7 @@ public:
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
|
||||
if (_messages[idx].valid && msgMatchesView(_messages[idx])) {
|
||||
rsMsgs[count++] = idx;
|
||||
}
|
||||
}
|
||||
@@ -277,7 +357,7 @@ public:
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx
|
||||
if (msgMatchesView(_messages[idx])
|
||||
&& _messages[idx].path_len != 0) {
|
||||
return &_messages[idx];
|
||||
}
|
||||
@@ -449,7 +529,15 @@ public:
|
||||
|
||||
// Get channel name
|
||||
ChannelDetails channel;
|
||||
if (the_mesh.getChannel(_viewChannelIdx, channel)) {
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
if (_dmInboxMode) {
|
||||
display.print("Direct Messages");
|
||||
} else {
|
||||
char hdr[40];
|
||||
snprintf(hdr, sizeof(hdr), "DM: %s", _dmFilterName);
|
||||
display.print(hdr);
|
||||
}
|
||||
} else if (the_mesh.getChannel(_viewChannelIdx, channel)) {
|
||||
display.print(channel.name);
|
||||
} else {
|
||||
sprintf(tmp, "Channel %d", _viewChannelIdx);
|
||||
@@ -464,6 +552,196 @@ public:
|
||||
|
||||
// Divider line
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === DM Inbox mode: show list of contacts with DMs ===
|
||||
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
|
||||
#define DM_INBOX_MAX 20
|
||||
struct DMInboxEntry {
|
||||
uint32_t hash;
|
||||
char name[32];
|
||||
int msgCount;
|
||||
int unreadCount;
|
||||
uint32_t newestTs;
|
||||
};
|
||||
DMInboxEntry inbox[DM_INBOX_MAX];
|
||||
int inboxCount = 0;
|
||||
|
||||
// Scan all DMs and group by peer hash
|
||||
for (int i = 0; i < _msgCount && i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
|
||||
if (_messages[idx].dm_peer_hash == 0) continue;
|
||||
|
||||
uint32_t h = _messages[idx].dm_peer_hash;
|
||||
|
||||
// Find existing entry by hash
|
||||
int found = -1;
|
||||
for (int j = 0; j < inboxCount; j++) {
|
||||
if (inbox[j].hash == h) { found = j; break; }
|
||||
}
|
||||
if (found < 0 && inboxCount < DM_INBOX_MAX) {
|
||||
found = inboxCount++;
|
||||
inbox[found].hash = h;
|
||||
inbox[found].name[0] = '\0';
|
||||
inbox[found].msgCount = 0;
|
||||
inbox[found].unreadCount = 0;
|
||||
inbox[found].newestTs = 0;
|
||||
|
||||
// Look up name from contacts by matching peer hash
|
||||
uint32_t numC = the_mesh.getNumContacts();
|
||||
ContactInfo ci;
|
||||
for (uint32_t c = 0; c < numC; c++) {
|
||||
if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == h) {
|
||||
strncpy(inbox[found].name, ci.name, 31);
|
||||
inbox[found].name[31] = '\0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback: extract from text if contact not found
|
||||
if (inbox[found].name[0] == '\0') {
|
||||
extractSenderName(_messages[idx].text, inbox[found].name, sizeof(inbox[found].name));
|
||||
}
|
||||
}
|
||||
if (found >= 0) {
|
||||
inbox[found].msgCount++;
|
||||
if (_messages[idx].timestamp > inbox[found].newestTs)
|
||||
inbox[found].newestTs = _messages[idx].timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Look up unread counts from per-contact array
|
||||
if (_dmUnreadPtr) {
|
||||
for (int e = 0; e < inboxCount; e++) {
|
||||
uint32_t numC = the_mesh.getNumContacts();
|
||||
ContactInfo ci;
|
||||
for (uint32_t c = 0; c < numC; c++) {
|
||||
if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == inbox[e].hash) {
|
||||
inbox[e].unreadCount = _dmUnreadPtr[c];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by newest timestamp descending (insertion sort)
|
||||
for (int i = 1; i < inboxCount; i++) {
|
||||
DMInboxEntry tmp2 = inbox[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && inbox[j].newestTs < tmp2.newestTs) {
|
||||
inbox[j + 1] = inbox[j];
|
||||
j--;
|
||||
}
|
||||
inbox[j + 1] = tmp2;
|
||||
}
|
||||
|
||||
// Render inbox list
|
||||
display.setTextSize(0);
|
||||
int lineH = 9;
|
||||
int headerH = 14;
|
||||
int footerH = 14;
|
||||
int maxY = display.height() - footerH;
|
||||
int y = headerH;
|
||||
int maxVisible = (maxY - headerH) / lineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
|
||||
// Clamp scroll
|
||||
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount > 0 ? inboxCount - 1 : 0;
|
||||
|
||||
if (inboxCount == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
display.print("No direct messages");
|
||||
display.setCursor(0, y + lineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("DMs from contacts appear here");
|
||||
#else
|
||||
display.print("A/D: Switch channel");
|
||||
#endif
|
||||
} else {
|
||||
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
|
||||
inboxCount - maxVisible));
|
||||
int endIdx = min(inboxCount, startIdx + maxVisible);
|
||||
|
||||
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
|
||||
bool selected = (i == _dmInboxScroll);
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Prefix: > for selected, unread indicator
|
||||
char prefix[6];
|
||||
if (inbox[i].unreadCount > 0) {
|
||||
snprintf(prefix, sizeof(prefix), "%s*%d", selected ? ">" : " ", inbox[i].unreadCount);
|
||||
} else {
|
||||
snprintf(prefix, sizeof(prefix), "%s ", selected ? ">" : " ");
|
||||
}
|
||||
display.print(prefix);
|
||||
|
||||
// Name (truncated)
|
||||
char filteredName[32];
|
||||
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
|
||||
|
||||
// Right side: message count + age
|
||||
char ageStr[8];
|
||||
uint32_t age = _rtc->getCurrentTime() - inbox[i].newestTs;
|
||||
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
|
||||
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
|
||||
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
|
||||
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
|
||||
|
||||
char rightStr[16];
|
||||
snprintf(rightStr, sizeof(rightStr), "(%d) %s", inbox[i].msgCount, ageStr);
|
||||
int rightW = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
int nameX = display.getTextWidth(prefix) + 2;
|
||||
int nameMaxW = display.width() - nameX - rightW - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
display.setCursor(display.width() - rightW, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineH;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Swipe:Nav");
|
||||
const char* rtInbox = "Hold:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
|
||||
display.print(rtInbox);
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bck A/D:Ch");
|
||||
const char* rtInbox = "Ent:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
|
||||
display.print(rtInbox);
|
||||
#endif
|
||||
|
||||
#ifdef USE_EINK
|
||||
return 5000;
|
||||
#else
|
||||
return 1000;
|
||||
#endif
|
||||
}
|
||||
|
||||
// --- Path detail overlay ---
|
||||
if (_showPathOverlay) {
|
||||
@@ -667,18 +945,154 @@ public:
|
||||
display.setTextSize(0); // Tiny font for body text
|
||||
display.setCursor(0, 20);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("No messages yet");
|
||||
display.setCursor(0, 30);
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
char noMsg[48];
|
||||
snprintf(noMsg, sizeof(noMsg), "No messages from %s", _dmFilterName);
|
||||
display.print(noMsg);
|
||||
display.setCursor(0, 30);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("Long press: Compose");
|
||||
display.print("Hold: Compose reply");
|
||||
#else
|
||||
display.print("A/D: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("C: Compose message");
|
||||
display.print("Q: Back to inbox");
|
||||
display.setCursor(0, 40);
|
||||
display.print("Ent: Compose reply");
|
||||
#endif
|
||||
} else {
|
||||
display.print("No messages yet");
|
||||
display.setCursor(0, 30);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("Long press: Compose");
|
||||
#else
|
||||
display.print("A/D: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("C: Compose message");
|
||||
#endif
|
||||
}
|
||||
display.setTextSize(1); // Restore for footer
|
||||
} else if (_viewChannelIdx == 0xFF && _dmInboxMode) {
|
||||
// =================================================================
|
||||
// DM Inbox: list of contacts/rooms you have DM history with
|
||||
// =================================================================
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
int y = headerHeight;
|
||||
|
||||
// Scan all DM messages and collect unique senders
|
||||
#define DM_INBOX_MAX 16
|
||||
struct InboxEntry {
|
||||
char name[24];
|
||||
int count;
|
||||
uint32_t newest_ts;
|
||||
};
|
||||
static InboxEntry inbox[DM_INBOX_MAX];
|
||||
int inboxCount = 0;
|
||||
|
||||
for (int i = 0; i < _msgCount; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
|
||||
|
||||
char sender[24];
|
||||
if (!extractSenderName(_messages[idx].text, sender, sizeof(sender))) continue;
|
||||
|
||||
// Find or add sender in inbox
|
||||
bool found = false;
|
||||
for (int j = 0; j < inboxCount; j++) {
|
||||
if (strcmp(inbox[j].name, sender) == 0) {
|
||||
inbox[j].count++;
|
||||
if (_messages[idx].timestamp > inbox[j].newest_ts)
|
||||
inbox[j].newest_ts = _messages[idx].timestamp;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found && inboxCount < DM_INBOX_MAX) {
|
||||
strncpy(inbox[inboxCount].name, sender, 23);
|
||||
inbox[inboxCount].name[23] = '\0';
|
||||
inbox[inboxCount].count = 1;
|
||||
inbox[inboxCount].newest_ts = _messages[idx].timestamp;
|
||||
inboxCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by newest timestamp descending (most recent first)
|
||||
for (int i = 1; i < inboxCount; i++) {
|
||||
InboxEntry tmp2 = inbox[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && inbox[j].newest_ts < tmp2.newest_ts) {
|
||||
inbox[j + 1] = inbox[j];
|
||||
j--;
|
||||
}
|
||||
inbox[j + 1] = tmp2;
|
||||
}
|
||||
|
||||
if (inboxCount == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
display.print("No conversations");
|
||||
} else {
|
||||
// Clamp scroll
|
||||
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount - 1;
|
||||
if (_dmInboxScroll < 0) _dmInboxScroll = 0;
|
||||
|
||||
int maxVisible = (maxY - headerHeight) / lineHeight;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
|
||||
inboxCount - maxVisible));
|
||||
int endIdx = min(inboxCount, startIdx + maxVisible);
|
||||
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
|
||||
bool selected = (i == _dmInboxScroll);
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
display.print(selected ? ">" : " ");
|
||||
|
||||
// Name (ellipsized)
|
||||
char filteredName[24];
|
||||
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
|
||||
|
||||
// Right side: message count + age
|
||||
char ageStr[8];
|
||||
uint32_t age = now - inbox[i].newest_ts;
|
||||
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
|
||||
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
|
||||
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
|
||||
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
|
||||
|
||||
char rightStr[16];
|
||||
snprintf(rightStr, sizeof(rightStr), "[%d] %s", inbox[i].count, ageStr);
|
||||
int rightW = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
int nameX = display.getTextWidth(">") + 2;
|
||||
int nameMaxW = display.width() - nameX - rightW - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
display.setCursor(display.width() - rightW, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0); // Tiny font for message body
|
||||
int lineHeight = 9; // 8px font + 1px spacing
|
||||
@@ -701,7 +1115,7 @@ public:
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
|
||||
if (msgMatchesView(_messages[idx])) {
|
||||
channelMsgs[numChannelMsgs++] = idx;
|
||||
}
|
||||
}
|
||||
@@ -968,13 +1382,20 @@ public:
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Swipe:Ch/Scroll");
|
||||
const char* midCh = "Tap:Path";
|
||||
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
|
||||
display.print(midCh);
|
||||
const char* rtCh = "Hold:Compose";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
|
||||
display.print(rtCh);
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
display.print("Swipe:Scroll");
|
||||
const char* rtCh = "Hold:Reply";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
|
||||
display.print(rtCh);
|
||||
} else {
|
||||
display.print("Swipe:Ch/Scroll");
|
||||
const char* midCh = "Tap:Path";
|
||||
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
|
||||
display.print(midCh);
|
||||
const char* rtCh = "Hold:Compose";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
|
||||
display.print(rtCh);
|
||||
}
|
||||
#else
|
||||
// Left side: abbreviated controls
|
||||
if (_replySelectMode) {
|
||||
@@ -982,6 +1403,15 @@ public:
|
||||
const char* rightText = "Ent:Reply";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
} else if (_viewChannelIdx == 0xFF) {
|
||||
if (_dmContactPerms > 0) {
|
||||
display.print("Q:Exit L:Admin");
|
||||
} else {
|
||||
display.print("Q:Exit");
|
||||
}
|
||||
const char* rightText = "Ent:Reply";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
} else {
|
||||
display.print("Q:Bck A/D:Ch R:Rply");
|
||||
const char* rightText = "Ent:New";
|
||||
@@ -1080,10 +1510,92 @@ public:
|
||||
return true; // Consume all other keys in reply select
|
||||
}
|
||||
|
||||
// --- DM Inbox mode (two-level DM view) ---
|
||||
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
|
||||
// W - scroll up in inbox
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_dmInboxScroll > 0) { _dmInboxScroll--; return true; }
|
||||
return false;
|
||||
}
|
||||
// S - scroll down in inbox
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
_dmInboxScroll++; // Clamped during render
|
||||
return true;
|
||||
}
|
||||
// Enter - open conversation for selected entry
|
||||
if (c == '\r' || c == 13) {
|
||||
// Rebuild inbox by hash to find the selected entry
|
||||
uint32_t seenHash[DM_INBOX_MAX];
|
||||
int cur = 0;
|
||||
for (int i = 0; i < _msgCount; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
|
||||
if (_messages[idx].dm_peer_hash == 0) continue;
|
||||
|
||||
uint32_t h = _messages[idx].dm_peer_hash;
|
||||
bool dup = false;
|
||||
for (int k = 0; k < cur; k++) {
|
||||
if (seenHash[k] == h) { dup = true; break; }
|
||||
}
|
||||
if (dup) continue;
|
||||
if (cur < DM_INBOX_MAX) seenHash[cur] = h;
|
||||
|
||||
if (cur == _dmInboxScroll) {
|
||||
// Found the selected entry — look up name from contacts
|
||||
_dmFilterName[0] = '\0';
|
||||
_dmContactIdx = -1;
|
||||
_dmContactPerms = 0;
|
||||
uint32_t numC = the_mesh.getNumContacts();
|
||||
ContactInfo ci;
|
||||
for (uint32_t c2 = 0; c2 < numC; c2++) {
|
||||
if (the_mesh.getContactByIdx(c2, ci) && peerHash(ci.name) == h) {
|
||||
strncpy(_dmFilterName, ci.name, sizeof(_dmFilterName) - 1);
|
||||
_dmFilterName[sizeof(_dmFilterName) - 1] = '\0';
|
||||
_dmContactIdx = (int)c2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback to text extraction if contact not found
|
||||
if (_dmFilterName[0] == '\0') {
|
||||
extractSenderName(_messages[idx].text, _dmFilterName, sizeof(_dmFilterName));
|
||||
}
|
||||
_dmInboxMode = false;
|
||||
_scrollPos = 0;
|
||||
return true;
|
||||
}
|
||||
cur++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Q - let main.cpp handle (back to home)
|
||||
if (c == 'q' || c == 'Q' || c == '\b') {
|
||||
return false;
|
||||
}
|
||||
// A/D pass through to channel switching below
|
||||
if (c == 'a' || c == 'A' || c == 'd' || c == 'D') {
|
||||
// Fall through to channel switching
|
||||
} else {
|
||||
return true; // Consume other keys
|
||||
}
|
||||
}
|
||||
|
||||
// --- DM Conversation mode: Q goes back to inbox ---
|
||||
if (_viewChannelIdx == 0xFF && !_dmInboxMode) {
|
||||
if (c == 'q' || c == 'Q' || c == '\b') {
|
||||
_dmInboxMode = true;
|
||||
_dmFilterName[0] = '\0';
|
||||
_scrollPos = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
int channelMsgCount = getMessageCountForChannel();
|
||||
|
||||
// R - enter reply select mode
|
||||
// R - enter reply select mode (group channels only — DM tab uses Enter to reply)
|
||||
if (c == 'r' || c == 'R') {
|
||||
if (_viewChannelIdx == 0xFF) return false; // Not applicable on DM tab
|
||||
if (channelMsgCount > 0) {
|
||||
_replySelectMode = true;
|
||||
// Start with newest message selected
|
||||
@@ -1120,14 +1632,12 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// A - previous channel
|
||||
// A - previous channel (includes DM tab at 0xFF)
|
||||
if (c == 'a' || c == 'A') {
|
||||
_replySelectMode = false;
|
||||
_replySelectPos = -1;
|
||||
if (_viewChannelIdx > 0) {
|
||||
_viewChannelIdx--;
|
||||
} else {
|
||||
// Wrap to last valid channel
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
// DM tab → go to last valid group channel
|
||||
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
@@ -1135,22 +1645,39 @@ public:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (_viewChannelIdx > 0) {
|
||||
_viewChannelIdx--;
|
||||
} else {
|
||||
// Channel 0 → wrap to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
}
|
||||
_scrollPos = 0;
|
||||
markChannelRead(_viewChannelIdx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// D - next channel
|
||||
// D - next channel (includes DM tab at 0xFF)
|
||||
if (c == 'd' || c == 'D') {
|
||||
_replySelectMode = false;
|
||||
_replySelectPos = -1;
|
||||
ChannelDetails ch;
|
||||
uint8_t nextIdx = _viewChannelIdx + 1;
|
||||
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = nextIdx;
|
||||
} else {
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
// DM tab → wrap to channel 0
|
||||
_viewChannelIdx = 0;
|
||||
} else {
|
||||
ChannelDetails ch;
|
||||
uint8_t nextIdx = _viewChannelIdx + 1;
|
||||
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = nextIdx;
|
||||
} else {
|
||||
// Past last channel → go to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
}
|
||||
}
|
||||
_scrollPos = 0;
|
||||
markChannelRead(_viewChannelIdx);
|
||||
|
||||
@@ -40,6 +40,9 @@ private:
|
||||
// How many rows fit on screen (computed during render)
|
||||
int _rowsPerPage;
|
||||
|
||||
// Pointer to per-contact DM unread array (owned by UITask, set via setter)
|
||||
const uint8_t* _dmUnread = nullptr;
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
static const char* filterLabel(FilterMode f) {
|
||||
@@ -145,6 +148,9 @@ public:
|
||||
|
||||
void invalidateCache() { _cacheValid = false; }
|
||||
|
||||
// Set pointer to per-contact DM unread array (called by UITask after allocation)
|
||||
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnread = ptr; }
|
||||
|
||||
void resetScroll() {
|
||||
_scrollPos = 0;
|
||||
_cacheValid = false;
|
||||
@@ -152,6 +158,31 @@ public:
|
||||
|
||||
FilterMode getFilter() const { return _filter; }
|
||||
|
||||
// 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;
|
||||
#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(_scrollPos - maxVisible / 2,
|
||||
_filteredCount - maxVisible));
|
||||
|
||||
int tappedRow = startIdx + (vy - bodyTop) / lineH;
|
||||
if (tappedRow < 0 || tappedRow >= _filteredCount) return 0;
|
||||
|
||||
if (tappedRow == _scrollPos) return 2;
|
||||
_scrollPos = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the raw contact table index for the currently highlighted item
|
||||
// Returns -1 if no valid selection
|
||||
int getSelectedContactIdx() const {
|
||||
@@ -278,9 +309,14 @@ public:
|
||||
char ageStr[6];
|
||||
formatAge(ageStr, sizeof(ageStr), now, contact.last_advert_timestamp);
|
||||
|
||||
// Build right-side string: "hops age"
|
||||
char rightStr[14];
|
||||
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
|
||||
// Build right-side string: "*N hops age" if unread, else "hops age"
|
||||
int dmCount = (_dmUnread && _filteredIdx[i] < MAX_CONTACTS) ? _dmUnread[_filteredIdx[i]] : 0;
|
||||
char rightStr[20];
|
||||
if (dmCount > 0) {
|
||||
snprintf(rightStr, sizeof(rightStr), "*%d %sh %s", dmCount, hopStr, ageStr);
|
||||
} else {
|
||||
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
|
||||
}
|
||||
int rightWidth = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
// Name region: after prefix + small gap, before right info
|
||||
@@ -314,15 +350,10 @@ public:
|
||||
#else
|
||||
// Left: Q:Bk
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bk");
|
||||
display.print("Q:Bk A/D:Filter");
|
||||
|
||||
// Center: A/D:Filter
|
||||
const char* mid = "A/D:Filtr";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
// Right: F:Dscvr
|
||||
const char* right = "F:Dscvr";
|
||||
// Right: Tap/Ent:Select
|
||||
const char* right = "Tap/Ent:Select";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
@@ -44,6 +44,32 @@ public:
|
||||
|
||||
int getSelectedIdx() const { return _scrollPos; }
|
||||
|
||||
// Tap-to-select: given virtual Y, select discovered node row.
|
||||
// Returns: 0=miss, 1=moved, 2=tapped current row.
|
||||
int selectRowAtVY(int vy) {
|
||||
int count = the_mesh.getDiscoveredCount();
|
||||
if (count == 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(_scrollPos - maxVisible / 2,
|
||||
count - maxVisible));
|
||||
|
||||
int tappedRow = startIdx + (vy - bodyTop) / lineH;
|
||||
if (tappedRow < 0 || tappedRow >= count) return 0;
|
||||
|
||||
if (tappedRow == _scrollPos) return 2;
|
||||
_scrollPos = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
int count = the_mesh.getDiscoveredCount();
|
||||
bool active = the_mesh.isDiscoveryActive();
|
||||
@@ -177,13 +203,9 @@ public:
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.print("Q:Back");
|
||||
display.print("Q:Bk F:Rescan");
|
||||
|
||||
const char* mid = "Ent:Add";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
const char* right = "F:Rescan";
|
||||
const char* right = "Tap/Ent:Add";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
237
examples/companion_radio/ui-new/Lastheardscreen.h
Normal file
237
examples/companion_radio/ui-new/Lastheardscreen.h
Normal file
@@ -0,0 +1,237 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/AdvertDataHelpers.h>
|
||||
#include <MeshCore.h>
|
||||
|
||||
extern MyMesh the_mesh;
|
||||
|
||||
// ==========================================================================
|
||||
// Last Heard Screen — passive advert list
|
||||
// Shows all recently heard nodes from the advert path table, sorted by
|
||||
// recency. Unlike Discovery (active zero-hop scan), this is purely passive
|
||||
// — it shows nodes whose adverts have been received over time.
|
||||
// ==========================================================================
|
||||
class LastHeardScreen : public UIScreen {
|
||||
mesh::RTCClock* _rtc;
|
||||
int _scrollPos;
|
||||
|
||||
// Local sorted copy of advert paths (refreshed each render)
|
||||
AdvertPath _entries[ADVERT_PATH_TABLE_SIZE];
|
||||
int _count;
|
||||
|
||||
static char typeChar(uint8_t adv_type) {
|
||||
switch (adv_type) {
|
||||
case ADV_TYPE_CHAT: return 'C';
|
||||
case ADV_TYPE_REPEATER: return 'R';
|
||||
case ADV_TYPE_ROOM: return 'S';
|
||||
case ADV_TYPE_SENSOR: return 'N';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
|
||||
// Format age as human-readable string (e.g. "2m", "1h", "3d")
|
||||
static void formatAge(uint32_t now, uint32_t timestamp, char* buf, int bufLen) {
|
||||
if (timestamp == 0 || now < timestamp) {
|
||||
snprintf(buf, bufLen, "---");
|
||||
return;
|
||||
}
|
||||
uint32_t age = now - timestamp;
|
||||
if (age < 60) snprintf(buf, bufLen, "%ds", age);
|
||||
else if (age < 3600) snprintf(buf, bufLen, "%dm", age / 60);
|
||||
else if (age < 86400) snprintf(buf, bufLen, "%dh", age / 3600);
|
||||
else snprintf(buf, bufLen, "%dd", age / 86400);
|
||||
}
|
||||
|
||||
public:
|
||||
LastHeardScreen(mesh::RTCClock* rtc)
|
||||
: _rtc(rtc), _scrollPos(0), _count(0) {}
|
||||
|
||||
void resetScroll() { _scrollPos = 0; }
|
||||
|
||||
int getSelectedIdx() const { return _scrollPos; }
|
||||
|
||||
// Check if selected node is already in contacts
|
||||
bool isSelectedInContacts() const {
|
||||
if (_scrollPos < 0 || _scrollPos >= _count) return false;
|
||||
return the_mesh.lookupContactByPubKey(_entries[_scrollPos].pubkey_prefix, 8) != nullptr;
|
||||
}
|
||||
|
||||
// Get selected entry (for add/delete operations)
|
||||
const AdvertPath* getSelectedEntry() const {
|
||||
if (_scrollPos < 0 || _scrollPos >= _count) return nullptr;
|
||||
return &_entries[_scrollPos];
|
||||
}
|
||||
|
||||
// Tap-to-select: given virtual Y, select row.
|
||||
// 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;
|
||||
#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(_scrollPos - maxVisible / 2,
|
||||
_count - maxVisible));
|
||||
|
||||
int tappedRow = startIdx + (vy - bodyTop) / lineH;
|
||||
if (tappedRow < 0 || tappedRow >= _count) return 0;
|
||||
|
||||
if (tappedRow == _scrollPos) return 2;
|
||||
_scrollPos = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
// Refresh sorted list from mesh
|
||||
_count = the_mesh.getRecentlyHeard(_entries, ADVERT_PATH_TABLE_SIZE);
|
||||
|
||||
// Filter out empty entries (recv_timestamp == 0)
|
||||
int validCount = 0;
|
||||
for (int i = 0; i < _count; i++) {
|
||||
if (_entries[i].recv_timestamp > 0) validCount++;
|
||||
else break; // sorted by recency, so first zero means rest are empty
|
||||
}
|
||||
_count = validCount;
|
||||
|
||||
if (_scrollPos >= _count) _scrollPos = max(0, _count - 1);
|
||||
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
|
||||
// === Header ===
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
|
||||
char hdr[32];
|
||||
snprintf(hdr, sizeof(hdr), "Last Heard: %d nodes", _count);
|
||||
display.print(hdr);
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body — node rows ===
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
int y = headerHeight;
|
||||
|
||||
if (_count == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 28);
|
||||
display.print("No adverts received yet");
|
||||
display.setCursor(4, 38);
|
||||
display.print("Nodes appear as adverts arrive");
|
||||
} else {
|
||||
int maxVisible = (maxY - headerHeight) / lineHeight;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
|
||||
_count - maxVisible));
|
||||
int endIdx = min(_count, startIdx + maxVisible);
|
||||
|
||||
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
|
||||
const AdvertPath& entry = _entries[i];
|
||||
bool selected = (i == _scrollPos);
|
||||
|
||||
// Highlight selected row
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Prefix: cursor + type char
|
||||
char prefix[4];
|
||||
snprintf(prefix, sizeof(prefix), "%c%c",
|
||||
selected ? '>' : ' ', typeChar(entry.type));
|
||||
display.print(prefix);
|
||||
|
||||
// Right side: age + hops + [★] for favourites, [+] for other contacts
|
||||
char rightStr[20];
|
||||
char ageBuf[8];
|
||||
formatAge(now, entry.recv_timestamp, ageBuf, sizeof(ageBuf));
|
||||
|
||||
ContactInfo* ci = the_mesh.lookupContactByPubKey(entry.pubkey_prefix, 8);
|
||||
bool inContacts = (ci != nullptr);
|
||||
bool isFav = inContacts && (ci->flags & 0x01);
|
||||
if (isFav) {
|
||||
snprintf(rightStr, sizeof(rightStr), "%s %dh [*]", ageBuf, entry.path_len & 63);
|
||||
} else if (inContacts) {
|
||||
snprintf(rightStr, sizeof(rightStr), "%s %dh [+]", ageBuf, entry.path_len & 63);
|
||||
} else {
|
||||
snprintf(rightStr, sizeof(rightStr), "%s %dh", ageBuf, entry.path_len & 63);
|
||||
}
|
||||
int rightWidth = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
// Name (truncated with ellipsis)
|
||||
char filteredName[32];
|
||||
display.translateUTF8ToBlocks(filteredName, entry.name, sizeof(filteredName));
|
||||
int nameX = display.getTextWidth(prefix) + 2;
|
||||
int nameMaxW = display.width() - nameX - rightWidth - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
// Right-aligned info
|
||||
display.setCursor(display.width() - rightWidth, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
|
||||
// === Footer ===
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe:Scroll");
|
||||
const char* right = "Tap:Add/Del";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.print("Q:Bk");
|
||||
const char* right = "Tap/Ent:Add/Del";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
return 5000; // refresh every 5s to update ages
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// Scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_scrollPos > 0) { _scrollPos--; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_scrollPos < _count - 1) { _scrollPos++; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter — handled by main.cpp (needs access to private MyMesh methods)
|
||||
// Q — handled by main.cpp (navigation)
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -137,22 +137,18 @@ public:
|
||||
_zoomMin(MAP_MIN_ZOOM),
|
||||
_zoomMax(MAP_MAX_ZOOM),
|
||||
_pngBuf(nullptr),
|
||||
_lineBuf(nullptr),
|
||||
_tileFound(false)
|
||||
{
|
||||
// Allocate marker array in PSRAM at construction (~20KB)
|
||||
// so addMarker() works before enter() is called
|
||||
_markers = (MapMarker*)ps_calloc(MAP_MAX_MARKERS, sizeof(MapMarker));
|
||||
if (_markers) {
|
||||
Serial.printf("MapScreen: markers allocated (%d × %d = %d bytes PSRAM)\n",
|
||||
MAP_MAX_MARKERS, (int)sizeof(MapMarker),
|
||||
MAP_MAX_MARKERS * (int)sizeof(MapMarker));
|
||||
} else {
|
||||
Serial.println("MapScreen: marker PSRAM alloc FAILED");
|
||||
}
|
||||
// Marker array and PNG buffers are deferred to enter() to avoid
|
||||
// consuming 20KB+ PSRAM at boot when the map may never be opened.
|
||||
_markers = nullptr;
|
||||
_numMarkers = 0;
|
||||
}
|
||||
|
||||
~MapScreen() {
|
||||
if (_pngBuf) { free(_pngBuf); _pngBuf = nullptr; }
|
||||
if (_lineBuf) { free(_lineBuf); _lineBuf = nullptr; }
|
||||
if (_markers) { free(_markers); _markers = nullptr; }
|
||||
}
|
||||
|
||||
@@ -184,7 +180,12 @@ public:
|
||||
// Add a location marker (call once per contact before entering map)
|
||||
void clearMarkers() { _numMarkers = 0; }
|
||||
void addMarker(double lat, double lon, const char* name = "", uint8_t type = 0) {
|
||||
if (!_markers || _numMarkers >= MAP_MAX_MARKERS) return;
|
||||
// Lazy-allocate markers on first use (deferred from constructor)
|
||||
if (!_markers) {
|
||||
_markers = (MapMarker*)ps_calloc(MAP_MAX_MARKERS, sizeof(MapMarker));
|
||||
if (!_markers) return; // Alloc failed — skip silently
|
||||
}
|
||||
if (_numMarkers >= MAP_MAX_MARKERS) return;
|
||||
if (lat == 0.0 && lon == 0.0) return; // Skip no-location contacts
|
||||
_markers[_numMarkers].lat = lat;
|
||||
_markers[_numMarkers].lon = lon;
|
||||
@@ -203,6 +204,18 @@ public:
|
||||
_einkDisplay = static_cast<GxEPDDisplay*>(&display);
|
||||
_needsRedraw = true;
|
||||
|
||||
// Allocate marker array in PSRAM on first use (~20KB)
|
||||
if (!_markers) {
|
||||
_markers = (MapMarker*)ps_calloc(MAP_MAX_MARKERS, sizeof(MapMarker));
|
||||
if (_markers) {
|
||||
Serial.printf("MapScreen: markers allocated (%d × %d = %d bytes PSRAM)\n",
|
||||
MAP_MAX_MARKERS, (int)sizeof(MapMarker),
|
||||
MAP_MAX_MARKERS * (int)sizeof(MapMarker));
|
||||
} else {
|
||||
Serial.println("MapScreen: marker PSRAM alloc FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate PNG read buffer in PSRAM on first use
|
||||
if (!_pngBuf) {
|
||||
_pngBuf = (uint8_t*)ps_malloc(MAP_PNG_BUF_SIZE);
|
||||
@@ -217,6 +230,20 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate scanline decode buffer in PSRAM (512 bytes — avoids stack
|
||||
// allocation inside the PNGdec callback which is called 256× per tile)
|
||||
if (!_lineBuf) {
|
||||
_lineBuf = (uint16_t*)ps_malloc(MAP_TILE_SIZE * sizeof(uint16_t));
|
||||
if (!_lineBuf) {
|
||||
_lineBuf = (uint16_t*)malloc(MAP_TILE_SIZE * sizeof(uint16_t));
|
||||
}
|
||||
if (_lineBuf) {
|
||||
Serial.println("MapScreen: lineBuf allocated");
|
||||
} else {
|
||||
Serial.println("MapScreen: lineBuf alloc FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
// Detect available zoom levels from SD card directories
|
||||
detectZoomRange();
|
||||
}
|
||||
@@ -356,6 +383,7 @@ private:
|
||||
|
||||
// PNG decode buffer (PSRAM)
|
||||
uint8_t* _pngBuf;
|
||||
uint16_t* _lineBuf; // Scanline RGB565 buffer for PNG decode (PSRAM)
|
||||
bool _tileFound; // Did last tile load succeed?
|
||||
|
||||
// PNGdec instance
|
||||
@@ -381,6 +409,7 @@ private:
|
||||
int offsetY; // Screen Y offset for this tile
|
||||
int viewportY; // Top of viewport (MAP_VIEWPORT_Y)
|
||||
int viewportH; // Height of viewport (MAP_VIEWPORT_H)
|
||||
uint16_t* lineBuf; // Scanline decode buffer (PSRAM-allocated, avoids 512B stack usage per callback)
|
||||
};
|
||||
DrawContext _drawCtx;
|
||||
|
||||
@@ -487,7 +516,7 @@ private:
|
||||
// Load a PNG tile from SD and decode it directly to the display
|
||||
// screenX, screenY = top-left corner on display where this tile goes
|
||||
bool loadAndRenderTile(int tileX, int tileY, int screenX, int screenY) {
|
||||
if (!_pngBuf || !_einkDisplay) return false;
|
||||
if (!_pngBuf || !_lineBuf || !_einkDisplay) return false;
|
||||
|
||||
char path[64];
|
||||
buildTilePath(path, sizeof(path), _zoom, tileX, tileY);
|
||||
@@ -521,6 +550,7 @@ private:
|
||||
_drawCtx.offsetY = screenY;
|
||||
_drawCtx.viewportY = MAP_VIEWPORT_Y;
|
||||
_drawCtx.viewportH = MAP_VIEWPORT_H;
|
||||
_drawCtx.lineBuf = _lineBuf;
|
||||
|
||||
// Open PNG from memory buffer
|
||||
int rc = _png.openRAM(_pngBuf, fileSize, pngDrawCallback);
|
||||
@@ -547,7 +577,7 @@ private:
|
||||
// Uses getLineAsRGB565 with correct (little) endianness for ESP32.
|
||||
static int pngDrawCallback(PNGDRAW* pDraw) {
|
||||
DrawContext* ctx = (DrawContext*)pDraw->pUser;
|
||||
if (!ctx || !ctx->display || !ctx->png) return 0;
|
||||
if (!ctx || !ctx->display || !ctx->png || !ctx->lineBuf) return 0;
|
||||
|
||||
int screenY = ctx->offsetY + pDraw->y;
|
||||
|
||||
@@ -564,9 +594,8 @@ private:
|
||||
}
|
||||
|
||||
uint16_t lineWidth = pDraw->iWidth;
|
||||
uint16_t lineBuf[MAP_TILE_SIZE];
|
||||
if (lineWidth > MAP_TILE_SIZE) lineWidth = MAP_TILE_SIZE;
|
||||
ctx->png->getLineAsRGB565(pDraw, lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF);
|
||||
ctx->png->getLineAsRGB565(pDraw, ctx->lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF);
|
||||
|
||||
for (int x = 0; x < lineWidth; x++) {
|
||||
int screenX = ctx->offsetX + x;
|
||||
@@ -574,7 +603,7 @@ private:
|
||||
|
||||
// RGB565 little-endian on ESP32: standard bit layout
|
||||
// R[15:11] G[10:5] B[4:0]
|
||||
uint16_t pixel = lineBuf[x];
|
||||
uint16_t pixel = ctx->lineBuf[x];
|
||||
|
||||
// For B&W tiles this is 0x0000 (black) or 0xFFFF (white)
|
||||
// Simple threshold on full 16-bit value handles both cleanly
|
||||
@@ -639,6 +668,7 @@ private:
|
||||
} else {
|
||||
missing++;
|
||||
}
|
||||
yield(); // Feed WDT between tiles — each tile can take 1-2s at 80MHz
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ private:
|
||||
uint32_t _rtcTime; // Unix timestamp (0 = unavailable)
|
||||
int8_t _utcOffset; // UTC offset in hours
|
||||
|
||||
// Callback to get fresh RTC time (set by UITask at init)
|
||||
typedef uint32_t (*TimeGetterFn)();
|
||||
TimeGetterFn _getTimeFn = nullptr;
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
String getFullPath(const String& filename) {
|
||||
@@ -570,8 +574,8 @@ private:
|
||||
display.print("Swipe:Nav");
|
||||
const char* right = "Tap:Open";
|
||||
#else
|
||||
display.print("Q:Back W/S:Nav");
|
||||
const char* right = "Ent:Open";
|
||||
display.print("Q:Bk");
|
||||
const char* right = "Tap/Ent:Open";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
@@ -592,8 +596,8 @@ private:
|
||||
display.print("Tap:Edit");
|
||||
const char* right = "Hold:Delete";
|
||||
#else
|
||||
display.print("Q:Bck Ent:Edit");
|
||||
const char* right = "Sh+Del:Del";
|
||||
display.print("Q:Bk Ent:Edit");
|
||||
const char* right = "X:Delete";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
@@ -684,9 +688,9 @@ private:
|
||||
|
||||
const char* right = "Tap:Edit";
|
||||
#else
|
||||
display.print("Q:Bck Ent:Edit");
|
||||
display.print("Q:Bk Ent:Edit");
|
||||
|
||||
const char* right = "Sh+Del:Del";
|
||||
const char* right = "X:Delete";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
@@ -1077,6 +1081,10 @@ private:
|
||||
// ---- Note Creation ----
|
||||
|
||||
void createNewNote() {
|
||||
// Refresh timestamp at creation time for accurate filenames
|
||||
if (_getTimeFn) {
|
||||
_rtcTime = _getTimeFn();
|
||||
}
|
||||
_currentFile = generateFilename();
|
||||
_buf[0] = '\0';
|
||||
_bufLen = 0;
|
||||
@@ -1176,6 +1184,8 @@ public:
|
||||
_utcOffset = utcOffset;
|
||||
}
|
||||
|
||||
void setTimeGetter(TimeGetterFn fn) { _getTimeFn = fn; }
|
||||
|
||||
void enter(DisplayDriver& display) {
|
||||
initLayout(display);
|
||||
scanFiles();
|
||||
|
||||
@@ -475,6 +475,7 @@ public:
|
||||
|
||||
int getContactIdx() const { return _contactIdx; }
|
||||
AdminState getState() const { return _state; }
|
||||
uint8_t getPermissions() const { return _permissions; }
|
||||
|
||||
void onLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
_waitingForLogin = false;
|
||||
@@ -561,7 +562,9 @@ public:
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
snprintf(tmp, sizeof(tmp), "Admin: %.16s", _repeaterName);
|
||||
const char* hdrPrefix = (_state == STATE_PASSWORD_ENTRY || _state == STATE_LOGGING_IN)
|
||||
? "Login" : "Admin";
|
||||
snprintf(tmp, sizeof(tmp), "%s: %.16s", hdrPrefix, _repeaterName);
|
||||
display.print(tmp);
|
||||
|
||||
if (_state >= STATE_CATEGORY_MENU && _state <= STATE_RESPONSE_VIEW) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ class UITask;
|
||||
// ============================================================================
|
||||
#define BOOKS_FOLDER "/books"
|
||||
#define INDEX_FOLDER "/.indexes"
|
||||
#define INDEX_VERSION 9 // v9: indexer buffer matches page buffer (fixes chunk boundary gaps)
|
||||
#define INDEX_VERSION 12 // v12: indexer breaks page BEFORE overflowing line (matches renderer pre-check)
|
||||
#define PREINDEX_PAGES 100
|
||||
#define READER_MAX_FILES 50
|
||||
#define READER_BUF_SIZE 4096
|
||||
@@ -115,10 +115,16 @@ inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineSta
|
||||
result.nextStart = lineStart;
|
||||
if (lineStart >= bufLen || !display) return result;
|
||||
|
||||
int displayW = display->width() - 3; // 3-unit right margin (rounding safety for proportional fonts)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
int rightMargin = 5; // Wider margin for T5S3 (portrait mode especially tight)
|
||||
#else
|
||||
int rightMargin = 3;
|
||||
#endif
|
||||
int displayW = display->width() - rightMargin;
|
||||
char measBuf[300]; // temp buffer for pixel measurement
|
||||
int measLen = 0;
|
||||
int lastBreakPoint = -1;
|
||||
int lastBreakMeasLen = 0; // measLen at lastBreakPoint (for mid-word fallback)
|
||||
bool inWord = false;
|
||||
int charCount = 0;
|
||||
|
||||
@@ -145,6 +151,7 @@ inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineSta
|
||||
// UTF-8 handling: decode multi-byte sequences to CP437 for accurate
|
||||
// width measurement. The renderer (renderPage) does this same conversion,
|
||||
// so the measurement must match or it underestimates line width.
|
||||
int charStartIdx = i; // buffer index where this character started
|
||||
if ((uint8_t)c >= 0xC0) {
|
||||
// UTF-8 lead byte — decode full sequence to CP437
|
||||
int decPos = i;
|
||||
@@ -156,65 +163,54 @@ inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineSta
|
||||
}
|
||||
i = decPos - 1; // -1 because the for loop will i++
|
||||
inWord = true;
|
||||
continue;
|
||||
}
|
||||
if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) {
|
||||
} else if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) {
|
||||
// Orphan continuation byte — treat as CP437 pass-through (same as renderer)
|
||||
if (measLen < 298) measBuf[measLen++] = c;
|
||||
charCount++;
|
||||
inWord = true;
|
||||
continue;
|
||||
} else {
|
||||
// Plain ASCII
|
||||
charCount++;
|
||||
if (measLen < 298) measBuf[measLen++] = c;
|
||||
|
||||
if (c == ' ' || c == '\t') {
|
||||
if (inWord) {
|
||||
lastBreakPoint = i;
|
||||
lastBreakMeasLen = measLen;
|
||||
inWord = false;
|
||||
}
|
||||
} else if (c == '-') {
|
||||
if (inWord) {
|
||||
lastBreakPoint = i + 1;
|
||||
lastBreakMeasLen = measLen;
|
||||
}
|
||||
inWord = true;
|
||||
} else {
|
||||
inWord = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Plain ASCII
|
||||
charCount++;
|
||||
if (measLen < 298) measBuf[measLen++] = c;
|
||||
|
||||
if (c == ' ' || c == '\t') {
|
||||
if (inWord) {
|
||||
// Measure pixel width at this word boundary
|
||||
measBuf[measLen] = '\0';
|
||||
int pw = display->getTextWidth(measBuf);
|
||||
if (pw >= displayW) {
|
||||
// Current word pushes past edge — break at previous word boundary
|
||||
if (lastBreakPoint > lineStart) {
|
||||
result.lineEnd = lastBreakPoint;
|
||||
result.nextStart = lastBreakPoint;
|
||||
while (result.nextStart < bufLen &&
|
||||
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
|
||||
result.nextStart++;
|
||||
} else {
|
||||
result.lineEnd = i;
|
||||
result.nextStart = i;
|
||||
}
|
||||
return result;
|
||||
// Per-character pixel width check — catches long words that exceed
|
||||
// displayW without ever hitting a space/hyphen break point.
|
||||
// Only measure every 3 chars to avoid excessive getTextWidth() calls.
|
||||
if ((charCount & 3) == 0 || c == ' ' || c == '-') {
|
||||
measBuf[measLen] = '\0';
|
||||
int pw = display->getTextWidth(measBuf);
|
||||
if (pw >= displayW) {
|
||||
if (lastBreakPoint > lineStart) {
|
||||
// Break at last word boundary
|
||||
result.lineEnd = lastBreakPoint;
|
||||
result.nextStart = lastBreakPoint;
|
||||
while (result.nextStart < bufLen &&
|
||||
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
|
||||
result.nextStart++;
|
||||
} else {
|
||||
// No word boundary found — break mid-word before this character
|
||||
result.lineEnd = charStartIdx;
|
||||
result.nextStart = charStartIdx;
|
||||
}
|
||||
lastBreakPoint = i;
|
||||
inWord = false;
|
||||
return result;
|
||||
}
|
||||
} else if (c == '-') {
|
||||
if (inWord) {
|
||||
// Measure at hyphen break point
|
||||
measBuf[measLen] = '\0';
|
||||
int pw = display->getTextWidth(measBuf);
|
||||
if (pw >= displayW) {
|
||||
if (lastBreakPoint > lineStart) {
|
||||
result.lineEnd = lastBreakPoint;
|
||||
result.nextStart = lastBreakPoint;
|
||||
while (result.nextStart < bufLen &&
|
||||
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
|
||||
result.nextStart++;
|
||||
} else {
|
||||
result.lineEnd = i;
|
||||
result.nextStart = i;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
lastBreakPoint = i + 1;
|
||||
}
|
||||
inWord = true;
|
||||
} else {
|
||||
inWord = true;
|
||||
}
|
||||
|
||||
// Safety: hard char limit (handles spaceless lines, URLs, etc.)
|
||||
@@ -242,17 +238,25 @@ inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineSta
|
||||
|
||||
// ============================================================================
|
||||
// Page Indexer (word-wrap aware, matches display rendering)
|
||||
// When textAreaHeight and lineHeight are provided (both > 0), uses height-based
|
||||
// pagination that accounts for blank lines getting 40% height (matching renderer).
|
||||
// Otherwise falls back to simple line counting.
|
||||
// ============================================================================
|
||||
inline int indexPagesWordWrap(File& file, long startPos,
|
||||
std::vector<long>& pagePositions,
|
||||
int linesPerPage, int charsPerLine,
|
||||
int maxPages) {
|
||||
int maxPages,
|
||||
int textAreaHeight = 0, int lineHeight = 0) {
|
||||
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
|
||||
char buffer[BUF_SIZE];
|
||||
|
||||
bool heightAware = (textAreaHeight > 0 && lineHeight > 0);
|
||||
int blankLineH = heightAware ? max(2, lineHeight * 2 / 5) : 0;
|
||||
|
||||
file.seek(startPos);
|
||||
int pagesAdded = 0;
|
||||
int lineCount = 0;
|
||||
int accHeight = 0;
|
||||
int leftover = 0;
|
||||
long chunkFileStart = startPos;
|
||||
|
||||
@@ -263,17 +267,42 @@ inline int indexPagesWordWrap(File& file, long startPos,
|
||||
|
||||
int pos = 0;
|
||||
while (pos < bufLen) {
|
||||
int lineStart = pos;
|
||||
WrapResult wrap = findLineBreak(buffer, bufLen, pos, charsPerLine);
|
||||
if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;
|
||||
|
||||
lineCount++;
|
||||
// Blank line = newline at line start (no printable content before it)
|
||||
bool isBlankLine = (wrap.lineEnd == lineStart);
|
||||
|
||||
bool pageBreak = false;
|
||||
if (heightAware) {
|
||||
int thisH = isBlankLine ? blankLineH : lineHeight;
|
||||
// Check BEFORE adding: does this line fit on the current page?
|
||||
// The renderer checks y <= maxY before rendering each line,
|
||||
// so we must break the page BEFORE a line that won't fit.
|
||||
if (accHeight > 0 && accHeight + thisH > textAreaHeight) {
|
||||
// This line doesn't fit — start new page at this line's position
|
||||
long pageFilePos = chunkFileStart + lineStart;
|
||||
pagePositions.push_back(pageFilePos);
|
||||
pagesAdded++;
|
||||
accHeight = 0;
|
||||
if (maxPages > 0 && pagesAdded >= maxPages) break;
|
||||
}
|
||||
accHeight += thisH;
|
||||
} else {
|
||||
lineCount++;
|
||||
if (lineCount >= linesPerPage) {
|
||||
pageBreak = true;
|
||||
lineCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pos = wrap.nextStart;
|
||||
|
||||
if (lineCount >= linesPerPage) {
|
||||
if (pageBreak) {
|
||||
long pageFilePos = chunkFileStart + pos;
|
||||
pagePositions.push_back(pageFilePos);
|
||||
pagesAdded++;
|
||||
lineCount = 0;
|
||||
if (maxPages > 0 && pagesAdded >= maxPages) break;
|
||||
}
|
||||
if (pos >= bufLen) break;
|
||||
@@ -377,6 +406,7 @@ private:
|
||||
int _charsPerLine;
|
||||
int _linesPerPage;
|
||||
int _lineHeight; // virtual coord units per text line
|
||||
int _textAreaHeight; // usable height for text (excluding header/footer)
|
||||
int _headerHeight;
|
||||
int _footerHeight;
|
||||
|
||||
@@ -400,6 +430,11 @@ private:
|
||||
int _pageBufLen;
|
||||
bool _contentDirty; // Need to re-read from SD
|
||||
|
||||
// Go-to-page input mode (Enter in reading view)
|
||||
bool _gotoMode = false;
|
||||
char _gotoBuf[6]; // Up to 5 digits + null
|
||||
int _gotoBufLen = 0;
|
||||
|
||||
// ---- Splash Screen Drawing ----
|
||||
// Draw directly to display outside the normal render cycle.
|
||||
// Matches the style of the standalone text reader firmware splash.
|
||||
@@ -899,22 +934,14 @@ private:
|
||||
if (_pagePositions.empty()) {
|
||||
// Cache had no pages (e.g. dummy entry) — full index from scratch
|
||||
_pagePositions.push_back(0);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
indexPagesWordWrapPixel(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, _display, 0);
|
||||
#else
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
#endif
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
} else {
|
||||
long lastPos = cache->pagePositions.back();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
indexPagesWordWrapPixel(_file, lastPos, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, _display, 0);
|
||||
#else
|
||||
indexPagesWordWrap(_file, lastPos, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
#endif
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
}
|
||||
} else {
|
||||
// No cache — full index from scratch
|
||||
@@ -932,13 +959,9 @@ private:
|
||||
drawSplash("Indexing...", "Please wait", shortName);
|
||||
|
||||
_pagePositions.push_back(0);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
indexPagesWordWrapPixel(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, _display, 0);
|
||||
#else
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
#endif
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
}
|
||||
|
||||
// Save complete index
|
||||
@@ -1144,9 +1167,9 @@ private:
|
||||
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home");
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Back W/S:Nav");
|
||||
display.print("Q:Bk");
|
||||
|
||||
const char* right = "Ent:Open";
|
||||
const char* right = "Tap/Ent:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
@@ -1165,13 +1188,9 @@ private:
|
||||
// Render all lines in the page buffer using word wrap.
|
||||
// The buffer contains exactly the bytes for this page (from indexed positions),
|
||||
// so we render everything in it.
|
||||
while (pos < _pageBufLen && lineCount < _linesPerPage && y <= maxY) {
|
||||
while (pos < _pageBufLen && y <= maxY) {
|
||||
int oldPos = pos;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
WrapResult wrap = findLineBreakPixel(_pageBuf, _pageBufLen, pos, &display, _charsPerLine);
|
||||
#else
|
||||
WrapResult wrap = findLineBreak(_pageBuf, _pageBufLen, pos, _charsPerLine);
|
||||
#endif
|
||||
|
||||
// Safety: stop if findLineBreak made no progress (stuck at end of buffer)
|
||||
if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break;
|
||||
@@ -1242,20 +1261,26 @@ private:
|
||||
|
||||
char status[30];
|
||||
int pct = _totalPages > 1 ? (_currentPage * 100) / (_totalPages - 1) : 100;
|
||||
sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct);
|
||||
|
||||
if (_gotoMode) {
|
||||
// Go-to-page input mode — show typed digits in footer
|
||||
snprintf(status, sizeof(status), "Go to: %.*s_", _gotoBufLen, _gotoBuf);
|
||||
} else {
|
||||
sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct);
|
||||
}
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(0);
|
||||
display.setCursor(0, footerY);
|
||||
display.print(status);
|
||||
const char* right = "Swipe: Page Tap: Next Hold: Close";
|
||||
const char* right = "Swipe:Page Tap:GoTo Hold:Close";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print(status);
|
||||
|
||||
const char* right = "W/S:Nav Q:Back";
|
||||
const char* right = _gotoMode ? "Ent:Go Q:Cancel" : "Entr:Pg# Q:Bk";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
@@ -1266,7 +1291,7 @@ public:
|
||||
: _task(task), _mode(FILE_LIST), _sdReady(false), _initialized(false),
|
||||
_bootIndexed(false), _display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
|
||||
_headerHeight(14), _footerHeight(14),
|
||||
_textAreaHeight(100), _headerHeight(14), _footerHeight(14),
|
||||
_selectedFile(0), _currentPath(BOOKS_FOLDER),
|
||||
_fileOpen(false), _currentPage(0), _totalPages(0),
|
||||
_pageBufLen(0), _contentDirty(true) {
|
||||
@@ -1296,16 +1321,27 @@ public:
|
||||
// Measure tiny font metrics using the display driver
|
||||
display.setTextSize(0);
|
||||
|
||||
// Measure character width: use 10 M's to get accurate average
|
||||
// Measure character width: use 10 M's for monospace (T-Deck Pro).
|
||||
// T5S3 overrides this below with average-width measurement.
|
||||
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
|
||||
if (tenCharsW > 0) {
|
||||
_charsPerLine = (display.width() * 10) / tenCharsW;
|
||||
}
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3 uses pixel-based line breaking (findLineBreakPixel) which measures
|
||||
// actual text width via getTextWidth(). _charsPerLine serves only as a
|
||||
// safety upper bound for lines without word breaks (URLs, etc.).
|
||||
_charsPerLine = 120;
|
||||
// T5S3 uses proportional font (FreeSans12pt) — measure average character
|
||||
// width from a representative English sample. M-based measurement is far
|
||||
// too conservative (M is the widest glyph), leaving half the line empty.
|
||||
{
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
// 95% factor as small safety margin for slightly-wider-than-average text
|
||||
_charsPerLine = (display.width() * sampleLen * 95) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 80) _charsPerLine = 80;
|
||||
#else
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 60) _charsPerLine = 60;
|
||||
@@ -1337,16 +1373,16 @@ public:
|
||||
|
||||
_headerHeight = 0; // No header in reading mode (maximize text area)
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _headerHeight - _footerHeight;
|
||||
_linesPerPage = textAreaHeight / _lineHeight;
|
||||
_textAreaHeight = display.height() - _headerHeight - _footerHeight;
|
||||
_linesPerPage = _textAreaHeight / _lineHeight;
|
||||
if (_linesPerPage < 5) _linesPerPage = 5;
|
||||
if (_linesPerPage > 40) _linesPerPage = 40;
|
||||
|
||||
display.setTextSize(1); // Restore
|
||||
_initialized = true;
|
||||
|
||||
Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d (display %dx%d)\n",
|
||||
_charsPerLine, _linesPerPage, _lineHeight, display.width(), display.height());
|
||||
Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d, textH=%d (display %dx%d)\n",
|
||||
_charsPerLine, _linesPerPage, _lineHeight, _textAreaHeight, display.width(), display.height());
|
||||
}
|
||||
|
||||
// ---- Boot-time Indexing ----
|
||||
@@ -1457,15 +1493,10 @@ public:
|
||||
cache.pagePositions.clear();
|
||||
cache.pagePositions.push_back(0);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
int added = indexPagesWordWrapPixel(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
_display, PREINDEX_PAGES - 1);
|
||||
#else
|
||||
int added = indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1);
|
||||
#endif
|
||||
PREINDEX_PAGES - 1,
|
||||
_textAreaHeight, _lineHeight);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
|
||||
@@ -1508,13 +1539,9 @@ public:
|
||||
// Layout was invalidated (orientation change) — reindex the open book
|
||||
Serial.println("TextReader: Reindexing after layout change");
|
||||
_pagePositions.push_back(0);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
indexPagesWordWrapPixel(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, _display, 0);
|
||||
#else
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
#endif
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
_totalPages = _pagePositions.size();
|
||||
if (_currentPage >= _totalPages) _currentPage = 0;
|
||||
_mode = READING;
|
||||
@@ -1529,6 +1556,48 @@ public:
|
||||
bool isReading() const { return _mode == READING; }
|
||||
bool isInFileList() const { return _mode == FILE_LIST; }
|
||||
|
||||
// Jump to a specific page number (1-based for user-facing, converted to 0-based)
|
||||
void gotoPage(int pageNum) {
|
||||
if (!_fileOpen || _totalPages == 0) return;
|
||||
int target = pageNum - 1; // Convert 1-based input to 0-based
|
||||
if (target < 0) target = 0;
|
||||
if (target >= _totalPages) target = _totalPages - 1;
|
||||
_currentPage = target;
|
||||
loadPageContent();
|
||||
Serial.printf("TextReader: Go to page %d/%d\n", _currentPage + 1, _totalPages);
|
||||
}
|
||||
|
||||
int getTotalPages() const { return _totalPages; }
|
||||
int getCurrentPage() const { return _currentPage; }
|
||||
|
||||
// Tap-to-select: given virtual Y, select file list row.
|
||||
// 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;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = startY;
|
||||
#else
|
||||
const int bodyTop = startY + 5; // GxEPD baseline offset
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
int totalItems = totalListItems();
|
||||
if (totalItems == 0) return 0;
|
||||
int maxVisible = (128 - startY - footerH) / listLineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
if (maxVisible > 15) maxVisible = 15;
|
||||
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
|
||||
totalItems - maxVisible));
|
||||
|
||||
int tappedRow = startIdx + (vy - bodyTop) / listLineH;
|
||||
if (tappedRow < 0 || tappedRow >= totalItems) return 0;
|
||||
|
||||
if (tappedRow == _selectedFile) return 2;
|
||||
_selectedFile = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
if (!_sdReady) {
|
||||
display.setCursor(0, 20);
|
||||
@@ -1553,6 +1622,7 @@ public:
|
||||
if (_mode == FILE_LIST) {
|
||||
return handleFileListInput(c);
|
||||
} else if (_mode == READING) {
|
||||
if (_gotoMode) return handleGotoInput(c);
|
||||
return handleReadingInput(c);
|
||||
}
|
||||
return false;
|
||||
@@ -1639,15 +1709,10 @@ public:
|
||||
cache.lastReadPage = 0;
|
||||
cache.pagePositions.clear();
|
||||
cache.pagePositions.push_back(0);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
indexPagesWordWrapPixel(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
_display, PREINDEX_PAGES - 1);
|
||||
#else
|
||||
indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1);
|
||||
#endif
|
||||
PREINDEX_PAGES - 1,
|
||||
_textAreaHeight, _lineHeight);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
|
||||
@@ -1673,9 +1738,9 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// S/D/Space/Enter - next page
|
||||
// S/D/Space - next page
|
||||
if (c == 's' || c == 'S' || c == 'd' || c == 'D' ||
|
||||
c == ' ' || c == '\r' || c == 13 || c == 0xF1) {
|
||||
c == ' ' || c == 0xF1) {
|
||||
if (_currentPage < _totalPages - 1) {
|
||||
_currentPage++;
|
||||
loadPageContent();
|
||||
@@ -1684,6 +1749,14 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter - go-to-page input mode
|
||||
if (c == '\r' || c == 13) {
|
||||
_gotoMode = true;
|
||||
_gotoBufLen = 0;
|
||||
_gotoBuf[0] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Q - close book, back to file list
|
||||
if (c == 'q' || c == 'Q') {
|
||||
closeBook();
|
||||
@@ -1694,6 +1767,42 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
bool handleGotoInput(char c) {
|
||||
// Enter — commit page number
|
||||
if (c == '\r' || c == 13) {
|
||||
if (_gotoBufLen > 0) {
|
||||
int pageNum = atoi(_gotoBuf);
|
||||
gotoPage(pageNum);
|
||||
}
|
||||
_gotoMode = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Q or Escape — cancel
|
||||
if (c == 'q' || c == 'Q' || c == 0x1B) {
|
||||
_gotoMode = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backspace — delete last digit
|
||||
if (c == '\b' || c == 0x7F) {
|
||||
if (_gotoBufLen > 0) {
|
||||
_gotoBufLen--;
|
||||
_gotoBuf[_gotoBufLen] = '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Digit — append (max 5 digits)
|
||||
if (c >= '0' && c <= '9' && _gotoBufLen < 5) {
|
||||
_gotoBuf[_gotoBufLen++] = c;
|
||||
_gotoBuf[_gotoBufLen] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
return true; // Consume all other keys while in goto mode
|
||||
}
|
||||
|
||||
// External close (called when leaving reader screen entirely)
|
||||
void exitReader() {
|
||||
if (_fileOpen) closeBook();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "NotesScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "DiscoveryScreen.h"
|
||||
#include "LastHeardScreen.h"
|
||||
#ifdef MECK_WEB_READER
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
@@ -138,6 +139,7 @@ class HomeScreen : public UIScreen {
|
||||
unsigned long _shutdown_at; // earliest time to proceed with shutdown (after e-ink refresh)
|
||||
bool _editing_utc;
|
||||
int8_t _saved_utc_offset; // for cancel/undo
|
||||
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
|
||||
|
||||
@@ -254,7 +256,8 @@ public:
|
||||
_shutdown_init(false), _shutdown_at(0), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
|
||||
|
||||
bool isEditingUTC() const { return _editing_utc; }
|
||||
void cancelEditUTC() {
|
||||
bool isOnRecentPage() const { return _page == HomePage::RECENT; }
|
||||
void cancelEditing() {
|
||||
if (_editing_utc) {
|
||||
_node_prefs->utc_offset_hours = _saved_utc_offset;
|
||||
_editing_utc = false;
|
||||
@@ -454,15 +457,15 @@ public:
|
||||
#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 ");
|
||||
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 ");
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [F] Discover ");
|
||||
#elif defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||
#else
|
||||
y -= 10; // reclaim the row for standalone
|
||||
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
|
||||
#endif
|
||||
y += 14;
|
||||
|
||||
@@ -496,6 +499,16 @@ public:
|
||||
display.setCursor(display.width() - timestamp_width - 1, y);
|
||||
display.print(tmp);
|
||||
}
|
||||
// Hint for full Last Heard screen
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(display.width() / 2, display.height() - 24,
|
||||
"Tap here for full Last Heard list");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, display.height() - 24,
|
||||
"H: Full Last Heard list");
|
||||
#endif
|
||||
} else if (_page == HomePage::RADIO) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(1);
|
||||
@@ -666,7 +679,9 @@ public:
|
||||
display.drawTextLeftAlign(0, y, "time(U)");
|
||||
display.drawTextRightAlign(display.width()-1, y, "no sync");
|
||||
}
|
||||
y = y + 12;
|
||||
}
|
||||
|
||||
// UTC offset editor overlay
|
||||
if (_editing_utc) {
|
||||
// Draw background box
|
||||
@@ -686,6 +701,7 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, by + bh - 10, "W/S:adj Enter:ok Q:cancel");
|
||||
display.setTextSize(1);
|
||||
}
|
||||
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
} else if (_page == HomePage::SENSORS) {
|
||||
@@ -842,7 +858,7 @@ public:
|
||||
#endif
|
||||
}
|
||||
}
|
||||
return _editing_utc ? 700 : 5000; // match e-ink refresh cycle while editing UTC
|
||||
return _editing_utc ? 700 : 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
@@ -1031,11 +1047,12 @@ public:
|
||||
};
|
||||
|
||||
// ==========================================================================
|
||||
// Lock Screen — T5S3 only
|
||||
// Big clock, battery %, unread message count. Touch disabled while shown.
|
||||
// Long press boot button to lock/unlock. Touch disabled while locked.
|
||||
// Lock Screen — T5S3 and T-Deck Pro
|
||||
// Big clock, battery %, unread message count.
|
||||
// T5S3: Long press boot button to lock/unlock. Touch disabled while locked.
|
||||
// T-Deck Pro: Double-press boot button to lock/unlock. Touch+keyboard disabled.
|
||||
// ==========================================================================
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
class LockScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
@@ -1059,7 +1076,11 @@ public:
|
||||
}
|
||||
|
||||
// ---- Huge clock: HH:MM on one line ----
|
||||
display.setTextSize(5); // Clock face size (FreeSansBold24pt × 5)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(5); // T5S3: FreeSansBold24pt × 5
|
||||
#else
|
||||
display.setTextSize(5); // T-Deck Pro: FreeSansBold12pt at GxEPD 2× scale
|
||||
#endif
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, 55, timeBuf);
|
||||
|
||||
@@ -1088,7 +1109,11 @@ public:
|
||||
// ---- Unlock hint ----
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(display.width() / 2, 120, "Hold button to unlock");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, 120, "Dbl-press to unlock");
|
||||
#endif
|
||||
|
||||
return 30000;
|
||||
}
|
||||
@@ -1097,7 +1122,7 @@ public:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
#endif // LilyGo_T5S3_EPaper_Pro
|
||||
#endif
|
||||
|
||||
void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
|
||||
_display = display;
|
||||
@@ -1113,6 +1138,17 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
|
||||
_node_prefs = node_prefs;
|
||||
|
||||
// Initialize message dedup ring buffer
|
||||
memset(_dedup, 0, sizeof(_dedup));
|
||||
_dedupIdx = 0;
|
||||
|
||||
// Allocate per-contact DM unread tracking (PSRAM if available)
|
||||
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
|
||||
_dmUnread = (uint8_t*)ps_calloc(MAX_CONTACTS, sizeof(uint8_t));
|
||||
#else
|
||||
_dmUnread = new uint8_t[MAX_CONTACTS]();
|
||||
#endif
|
||||
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
// Apply GPS preferences from stored prefs
|
||||
if (_sensors != NULL && _node_prefs != NULL) {
|
||||
@@ -1151,18 +1187,24 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
|
||||
ui_started_at = millis();
|
||||
_alert_expiry = 0;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
_lastInputMillis = millis();
|
||||
#endif
|
||||
|
||||
splash = new SplashScreen(this);
|
||||
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
|
||||
msg_preview = new MsgPreviewScreen(this, &rtc_clock);
|
||||
channel_screen = new ChannelScreen(this, &rtc_clock);
|
||||
((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);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||||
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
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
|
||||
@@ -1175,16 +1217,18 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
map_screen = nullptr;
|
||||
#endif
|
||||
|
||||
// Apply saved dark mode preference before first render
|
||||
if (_node_prefs->dark_mode) {
|
||||
::display.setDarkMode(true);
|
||||
}
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// Apply saved display preferences before first render
|
||||
if (_node_prefs->portrait_mode) {
|
||||
::display.setPortraitMode(true);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Apply saved dark mode preference (both T-Deck Pro and T5S3)
|
||||
if (_node_prefs->dark_mode) {
|
||||
::display.setDarkMode(true);
|
||||
}
|
||||
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -1194,6 +1238,34 @@ void UITask::showAlert(const char* text, int duration_millis) {
|
||||
_next_refresh = millis() + 100; // trigger re-render to show updated text
|
||||
}
|
||||
|
||||
void UITask::showBootHint(bool immediate) {
|
||||
if (immediate) {
|
||||
// Activate now — used when hint should overlay the current screen (e.g. onboarding)
|
||||
_hintActive = true;
|
||||
_hintExpiry = millis() + 8000; // 8 seconds auto-dismiss
|
||||
_pendingBootHint = false;
|
||||
_next_refresh = millis() + 100;
|
||||
Serial.println("[UI] Boot hint activated (immediate)");
|
||||
} else {
|
||||
// Defer until after splash screen — actual activation happens in gotoHomeScreen()
|
||||
_pendingBootHint = true;
|
||||
Serial.println("[UI] Boot hint pending (will show after splash)");
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::dismissBootHint() {
|
||||
if (!_hintActive) return;
|
||||
_hintActive = false;
|
||||
_hintExpiry = 0;
|
||||
// Persist so hint never shows again
|
||||
if (_node_prefs) {
|
||||
_node_prefs->hint_shown = 1;
|
||||
the_mesh.savePrefs();
|
||||
}
|
||||
_next_refresh = millis() + 100;
|
||||
Serial.println("[UI] Boot hint dismissed");
|
||||
}
|
||||
|
||||
void UITask::notify(UIEventType t) {
|
||||
#if defined(PIN_BUZZER)
|
||||
switch(t){
|
||||
@@ -1235,6 +1307,24 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
const uint8_t* path, int8_t snr) {
|
||||
_msgcount = msgcount;
|
||||
|
||||
// --- Dedup: suppress retry spam (same sender + text within 60s) ---
|
||||
uint32_t nameH = simpleHash(from_name);
|
||||
uint32_t textH = simpleHash(text);
|
||||
unsigned long now = millis();
|
||||
for (int i = 0; i < MSG_DEDUP_SIZE; i++) {
|
||||
if (_dedup[i].name_hash == nameH && _dedup[i].text_hash == textH &&
|
||||
(now - _dedup[i].millis) < MSG_DEDUP_WINDOW_MS) {
|
||||
// Duplicate — suppress UI notification but still queued for BLE sync
|
||||
Serial.println("[Dedup] Suppressed duplicate");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Record this message in the dedup ring
|
||||
_dedup[_dedupIdx].name_hash = nameH;
|
||||
_dedup[_dedupIdx].text_hash = textH;
|
||||
_dedup[_dedupIdx].millis = now;
|
||||
_dedupIdx = (_dedupIdx + 1) % MSG_DEDUP_SIZE;
|
||||
|
||||
// Add to preview screen (for notifications on non-keyboard devices)
|
||||
((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
|
||||
|
||||
@@ -1251,7 +1341,35 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
}
|
||||
|
||||
// Add to channel history screen with channel index, path data, and SNR
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr);
|
||||
// For DMs (channel_idx == 0xFF):
|
||||
// - Regular DMs: prefix text with sender name ("NodeName: hello")
|
||||
// - Room server messages: text already contains "OriginalSender: message",
|
||||
// don't double-prefix. Tag with room server name for conversation filtering.
|
||||
bool isRoomMsg = false;
|
||||
if (channel_idx == 0xFF) {
|
||||
// Check if sender is a room server
|
||||
uint32_t numContacts = the_mesh.getNumContacts();
|
||||
ContactInfo senderContact;
|
||||
for (uint32_t ci = 0; ci < numContacts; ci++) {
|
||||
if (the_mesh.getContactByIdx(ci, senderContact) && strcmp(senderContact.name, from_name) == 0) {
|
||||
if (senderContact.type == ADV_TYPE_ROOM) isRoomMsg = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRoomMsg) {
|
||||
// Room server: text already has "Poster: message" format — store as-is
|
||||
// Tag with room server name for conversation filtering
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, from_name);
|
||||
} else {
|
||||
// Regular DM: prefix with sender name
|
||||
char dmFormatted[CHANNEL_MSG_TEXT_LEN];
|
||||
snprintf(dmFormatted, sizeof(dmFormatted), "%s: %s", from_name, text);
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, dmFormatted, path, snr);
|
||||
}
|
||||
} else {
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr);
|
||||
}
|
||||
|
||||
// If user is currently viewing this channel, mark it as read immediately
|
||||
// (they can see the message arrive in real-time)
|
||||
@@ -1259,18 +1377,31 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) {
|
||||
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
|
||||
}
|
||||
|
||||
// Per-contact DM unread tracking: find contact index by name
|
||||
if (channel_idx == 0xFF && _dmUnread) {
|
||||
uint32_t numContacts = the_mesh.getNumContacts();
|
||||
ContactInfo contact;
|
||||
for (uint32_t ci = 0; ci < numContacts; ci++) {
|
||||
if (the_mesh.getContactByIdx(ci, contact) && strcmp(contact.name, from_name) == 0) {
|
||||
if (_dmUnread[ci] < 255) _dmUnread[ci]++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(LilyGo_TDeck_Pro) || defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// Don't interrupt user with popup - just show brief notification
|
||||
// Messages are stored in channel history, accessible via tile/key
|
||||
if (!isOnRepeaterAdmin()) {
|
||||
// Suppress toasts for room server messages (bulk sync would spam toasts)
|
||||
if (!isOnRepeaterAdmin() && !isRoomMsg) {
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
}
|
||||
#else
|
||||
// Other devices: Show full preview screen (legacy behavior)
|
||||
setCurrScreen(msg_preview);
|
||||
// Other devices: Show full preview screen (legacy behavior, skip room sync)
|
||||
if (!isRoomMsg) setCurrScreen(msg_preview);
|
||||
#endif
|
||||
|
||||
if (_display != NULL) {
|
||||
@@ -1279,13 +1410,19 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
}
|
||||
if (_display->isOn()) {
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer
|
||||
_next_refresh = 100; // trigger refresh
|
||||
// Throttle refresh during room sync — batch messages instead of 648ms render per msg
|
||||
if (isRoomMsg) {
|
||||
unsigned long earliest = millis() + 3000; // At most one refresh per 3s during sync
|
||||
if (_next_refresh < earliest) _next_refresh = earliest;
|
||||
} else {
|
||||
_next_refresh = 100; // trigger refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard flash notification
|
||||
// Keyboard flash notification (suppress for room sync)
|
||||
#ifdef KB_BL_PIN
|
||||
if (_node_prefs->kb_flash_notify) {
|
||||
if (_node_prefs->kb_flash_notify && !isRoomMsg) {
|
||||
digitalWrite(KB_BL_PIN, HIGH);
|
||||
_kb_flash_off_at = millis() + 200; // 200ms flash
|
||||
}
|
||||
@@ -1315,6 +1452,9 @@ void UITask::userLedHandler() {
|
||||
|
||||
void UITask::setCurrScreen(UIScreen* c) {
|
||||
curr = c;
|
||||
_alert_expiry = 0; // Dismiss any active toast — prevents stale overlay from
|
||||
// triggering extra 644ms e-ink refreshes on the new screen
|
||||
if (_hintActive) dismissBootHint(); // Dismiss hint when navigating away
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
@@ -1345,11 +1485,16 @@ void UITask::shutdown(bool restart){
|
||||
}
|
||||
|
||||
// Disable WiFi if active
|
||||
#ifdef WIFI_SSID
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
#endif
|
||||
|
||||
// Disable 4G modem if active
|
||||
#ifdef HAS_4G_MODEM
|
||||
modemManager.shutdown();
|
||||
#endif
|
||||
|
||||
// Disable GPS if active
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
{
|
||||
@@ -1425,11 +1570,27 @@ void UITask::loop() {
|
||||
gotoHomeScreen(); // file list: go home
|
||||
c = 0;
|
||||
}
|
||||
} else if (isOnNotesScreen()) {
|
||||
NotesScreen* notes = (NotesScreen*)notes_screen;
|
||||
if (notes && notes->isEditing()) {
|
||||
notes->triggerSaveAndExit(); // save and return to file list
|
||||
} else {
|
||||
notes->exitNotes();
|
||||
gotoHomeScreen();
|
||||
}
|
||||
c = 0;
|
||||
} else {
|
||||
gotoHomeScreen();
|
||||
c = 0; // consumed
|
||||
}
|
||||
}
|
||||
#elif defined(LilyGo_TDeck_Pro)
|
||||
// T-Deck Pro: single click ignored while locked — double-press to unlock
|
||||
if (_locked) {
|
||||
c = 0;
|
||||
} else {
|
||||
c = checkDisplayOn(KEY_NEXT);
|
||||
}
|
||||
#else
|
||||
c = checkDisplayOn(KEY_NEXT);
|
||||
#endif
|
||||
@@ -1468,10 +1629,21 @@ void UITask::loop() {
|
||||
}
|
||||
#endif
|
||||
|
||||
if (c != 0 && curr) {
|
||||
// Dismiss boot hint on any button input (boot button on T5S3)
|
||||
if (_hintActive) {
|
||||
dismissBootHint();
|
||||
c = 0; // Consume the press
|
||||
}
|
||||
}
|
||||
|
||||
if (c != 0 && curr) {
|
||||
curr->handleInput(c);
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
_next_refresh = 100; // trigger refresh
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
_lastInputMillis = millis(); // Reset auto-lock idle timer
|
||||
#endif
|
||||
}
|
||||
|
||||
userLedHandler();
|
||||
@@ -1546,6 +1718,7 @@ if (curr) curr->poll();
|
||||
display.setDarkMode(_node_prefs->dark_mode != 0);
|
||||
}
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// Sync portrait mode with prefs (T5S3 only)
|
||||
if (_node_prefs && display.isPortraitMode() != (_node_prefs->portrait_mode != 0)) {
|
||||
display.setPortraitMode(_node_prefs->portrait_mode != 0);
|
||||
// Text reader layout depends on orientation — force recalculation
|
||||
@@ -1567,6 +1740,11 @@ if (curr) curr->poll();
|
||||
onVKBCancel();
|
||||
}
|
||||
} else {
|
||||
// Default: allow full refresh. Override for notes editing (no flash while typing).
|
||||
display.setForcePartial(false);
|
||||
if (isOnNotesScreen() && ((NotesScreen*)notes_screen)->isEditing()) {
|
||||
display.setForcePartial(true);
|
||||
}
|
||||
int delay_millis = curr->render(*_display);
|
||||
|
||||
// Check if settings screen needs VKB for WiFi password entry
|
||||
@@ -1580,7 +1758,41 @@ if (curr) curr->poll();
|
||||
}
|
||||
#endif
|
||||
|
||||
if (millis() < _alert_expiry) {
|
||||
if (_hintActive && millis() < _hintExpiry) {
|
||||
// Boot navigation hint overlay — multi-line, larger box
|
||||
_display->setTextSize(1);
|
||||
int w = _display->width();
|
||||
int h = _display->height();
|
||||
int boxX = w / 8;
|
||||
int boxY = h / 5;
|
||||
int boxW = w - boxX * 2;
|
||||
int boxH = h * 3 / 5;
|
||||
_display->setColor(DisplayDriver::DARK);
|
||||
_display->fillRect(boxX, boxY, boxW, boxH);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->drawRect(boxX, boxY, boxW, boxH);
|
||||
int cx = w / 2;
|
||||
int lineH = 11;
|
||||
int startY = boxY + 6;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
_display->drawTextCentered(cx, startY, "Swipe: Navigate");
|
||||
_display->drawTextCentered(cx, startY + lineH, "Tap: Select");
|
||||
_display->drawTextCentered(cx, startY + lineH * 2, "Long Press: Action");
|
||||
_display->drawTextCentered(cx, startY + lineH * 3, "Boot Btn: Home");
|
||||
_display->drawTextCentered(cx, startY + lineH * 4 + 4, "[Tap to dismiss hint]");
|
||||
#else
|
||||
_display->drawTextCentered(cx, startY, "M:Msgs C:Contacts");
|
||||
_display->drawTextCentered(cx, startY + lineH, "S:Settings E:Reader");
|
||||
_display->drawTextCentered(cx, startY + lineH * 2, "N:Notes W/S:Scroll");
|
||||
_display->drawTextCentered(cx, startY + lineH * 3, "A/D:Cycle Left/Right");
|
||||
_display->drawTextCentered(cx, startY + lineH * 4 + 4, "[X to dismiss hint]");
|
||||
#endif
|
||||
_next_refresh = _hintExpiry;
|
||||
} else if (_hintActive) {
|
||||
// Hint expired — auto-dismiss
|
||||
dismissBootHint();
|
||||
_next_refresh = millis() + 200;
|
||||
} else if (millis() < _alert_expiry) {
|
||||
_display->setTextSize(1);
|
||||
int y = _display->height() / 3;
|
||||
int p = _display->height() / 32;
|
||||
@@ -1596,7 +1808,33 @@ if (curr) curr->poll();
|
||||
}
|
||||
#else
|
||||
int delay_millis = curr->render(*_display);
|
||||
if (millis() < _alert_expiry) { // render alert popup
|
||||
if (_hintActive && millis() < _hintExpiry) {
|
||||
// Boot navigation hint overlay — multi-line, larger box
|
||||
_display->setTextSize(1);
|
||||
int w = _display->width();
|
||||
int h = _display->height();
|
||||
int boxX = w / 8;
|
||||
int boxY = h / 5;
|
||||
int boxW = w - boxX * 2;
|
||||
int boxH = h * 3 / 5;
|
||||
_display->setColor(DisplayDriver::DARK);
|
||||
_display->fillRect(boxX, boxY, boxW, boxH);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->drawRect(boxX, boxY, boxW, boxH);
|
||||
int cx = w / 2;
|
||||
int lineH = 11;
|
||||
int startY = boxY + 6;
|
||||
_display->drawTextCentered(cx, startY, "M:Msgs C:Contacts");
|
||||
_display->drawTextCentered(cx, startY + lineH, "S:Settings E:Reader");
|
||||
_display->drawTextCentered(cx, startY + lineH * 2, "N:Notes W/S:Scroll");
|
||||
_display->drawTextCentered(cx, startY + lineH * 3, "A/D:Cycle Left/Right");
|
||||
_display->drawTextCentered(cx, startY + lineH * 4 + 4, "[X to dismiss]");
|
||||
_next_refresh = _hintExpiry;
|
||||
} else if (_hintActive) {
|
||||
// Hint expired — auto-dismiss
|
||||
dismissBootHint();
|
||||
_next_refresh = millis() + 200;
|
||||
} else if (millis() < _alert_expiry) { // render alert popup
|
||||
_display->setTextSize(1);
|
||||
int y = _display->height() / 3;
|
||||
int p = _display->height() / 32;
|
||||
@@ -1611,6 +1849,13 @@ if (curr) curr->poll();
|
||||
}
|
||||
#endif
|
||||
_display->endFrame();
|
||||
|
||||
// E-ink render throttle: enforce minimum 800ms between renders.
|
||||
// Each partial update blocks for ~644ms. Without this floor, incoming
|
||||
// mesh notifications can trigger back-to-back renders that starve the
|
||||
// keyboard polling loop, causing TCA8418 FIFO overflow and lost keys.
|
||||
unsigned long minNext = millis() + 800;
|
||||
if (_next_refresh < minNext) _next_refresh = minNext;
|
||||
}
|
||||
#if AUTO_OFF_MILLIS > 0
|
||||
if (millis() > _auto_off) {
|
||||
@@ -1619,6 +1864,35 @@ if (curr) curr->poll();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Auto-lock idle timer — runs regardless of display on/off state
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
if (_node_prefs && _node_prefs->auto_lock_minutes > 0 && !_locked) {
|
||||
uint8_t alm = _node_prefs->auto_lock_minutes;
|
||||
// Only act on valid option values (guards against garbage from uninitialised prefs)
|
||||
if (alm == 2 || alm == 5 || alm == 10 || alm == 15 || alm == 30) {
|
||||
unsigned long lock_timeout = (unsigned long)alm * 60000UL;
|
||||
if (millis() - _lastInputMillis >= lock_timeout) {
|
||||
lockScreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lock screen clock refresh — update time display every 15 minutes.
|
||||
// Runs outside the _display->isOn() gate so it works even after auto-off.
|
||||
// Wakes the display briefly to render, then lets auto-off turn it back off.
|
||||
if (_locked && _display != NULL) {
|
||||
const unsigned long LOCK_REFRESH_INTERVAL = 15UL * 60UL * 1000UL; // 15 minutes
|
||||
if (millis() - _lastLockRefresh >= LOCK_REFRESH_INTERVAL) {
|
||||
_lastLockRefresh = millis();
|
||||
if (!_display->isOn()) {
|
||||
_display->turnOn();
|
||||
_auto_off = millis() + 5000; // Stay on just long enough to render + settle
|
||||
}
|
||||
_next_refresh = 0; // Trigger immediate render
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PIN_VIBRATION
|
||||
vibration.loop();
|
||||
#endif
|
||||
@@ -1660,6 +1934,9 @@ char UITask::checkDisplayOn(char c) {
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
_next_refresh = 0; // trigger refresh
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
_lastInputMillis = millis(); // Reset auto-lock idle timer
|
||||
#endif
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -1695,6 +1972,14 @@ char UITask::handleDoubleClick(char c) {
|
||||
board.setBacklight(true);
|
||||
}
|
||||
c = 0; // consume event — don't pass through as navigation
|
||||
#elif defined(LilyGo_TDeck_Pro)
|
||||
// Double-click boot button → lock/unlock screen
|
||||
if (_locked) {
|
||||
unlockScreen();
|
||||
} else {
|
||||
lockScreen();
|
||||
}
|
||||
c = 0;
|
||||
#endif
|
||||
checkDisplayOn(c);
|
||||
return c;
|
||||
@@ -1718,16 +2003,23 @@ char UITask::handleTripleClick(char c) {
|
||||
return c;
|
||||
}
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
void UITask::lockScreen() {
|
||||
if (_locked) return;
|
||||
_locked = true;
|
||||
_screenBeforeLock = curr;
|
||||
setCurrScreen(lock_screen);
|
||||
board.setBacklight(false); // Save power
|
||||
// Ensure display is on so lock screen renders (auto-off may have turned it off)
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
board.setBacklight(false); // Save power (T5S3 backlight)
|
||||
#endif
|
||||
_next_refresh = 0; // Draw lock screen immediately
|
||||
_auto_off = millis() + 60000; // 60s before display off while locked
|
||||
Serial.println("[UI] Screen locked");
|
||||
_lastLockRefresh = millis(); // Start 15-min clock refresh cycle
|
||||
Serial.println("[UI] Screen locked — entering low-power mode");
|
||||
}
|
||||
|
||||
void UITask::unlockScreen() {
|
||||
@@ -1739,11 +2031,18 @@ void UITask::unlockScreen() {
|
||||
gotoHomeScreen();
|
||||
}
|
||||
_screenBeforeLock = nullptr;
|
||||
// Ensure display is on so unlocked screen renders
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_lastInputMillis = millis(); // Reset auto-lock idle timer
|
||||
_next_refresh = 0;
|
||||
Serial.println("[UI] Screen unlocked");
|
||||
Serial.println("[UI] Screen unlocked — exiting low-power mode");
|
||||
}
|
||||
#endif // LilyGo_T5S3_EPaper_Pro || LilyGo_TDeck_Pro
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
void UITask::showVirtualKeyboard(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx) {
|
||||
_vkb.open(purpose, label, initial, maxLen, contextIdx);
|
||||
_vkbActive = true;
|
||||
@@ -1787,12 +2086,26 @@ void UITask::onVKBSubmit() {
|
||||
case VKB_DM: {
|
||||
if (strlen(text) == 0) break;
|
||||
|
||||
bool dmSuccess = false;
|
||||
if (the_mesh.uiSendDirectMessage((uint32_t)idx, text)) {
|
||||
showAlert("DM sent!", 1500);
|
||||
} else {
|
||||
showAlert("DM failed!", 1500);
|
||||
// Add to channel screen so sent DM appears in conversation view
|
||||
ContactInfo dmRecipient;
|
||||
if (the_mesh.getContactByIdx(idx, dmRecipient)) {
|
||||
addSentDM(dmRecipient.name, the_mesh.getNodePrefs()->node_name, text);
|
||||
}
|
||||
dmSuccess = true;
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
// Return to DM conversation if we have contact info
|
||||
ContactInfo dmContact;
|
||||
if (the_mesh.getContactByIdx(idx, dmContact)) {
|
||||
ChannelScreen* cs = (ChannelScreen*)channel_screen;
|
||||
uint8_t savedPerms = (cs && cs->isDMConversation()) ? cs->getDMContactPerms() : 0;
|
||||
gotoDMConversation(dmContact.name, idx, savedPerms);
|
||||
} else if (_screenBeforeVKB) {
|
||||
setCurrScreen(_screenBeforeVKB);
|
||||
}
|
||||
// Show alert AFTER navigation (setCurrScreen clears prior alerts)
|
||||
showAlert(dmSuccess ? "DM sent!" : "DM failed!", 1500);
|
||||
break;
|
||||
}
|
||||
case VKB_ADMIN_PASSWORD: {
|
||||
@@ -1895,6 +2208,17 @@ void UITask::onVKBSubmit() {
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
case VKB_TEXT_PAGE: {
|
||||
if (strlen(text) > 0) {
|
||||
int pageNum = atoi(text);
|
||||
TextReaderScreen* reader = (TextReaderScreen*)getTextReaderScreen();
|
||||
if (reader && pageNum > 0) {
|
||||
reader->gotoPage(pageNum);
|
||||
}
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_screenBeforeVKB = nullptr;
|
||||
_next_refresh = 0;
|
||||
@@ -1911,6 +2235,30 @@ void UITask::onVKBCancel() {
|
||||
display.invalidateFrameCRC();
|
||||
Serial.println("[UI] VKB cancelled");
|
||||
}
|
||||
|
||||
#ifdef MECK_CARDKB
|
||||
void UITask::feedCardKBChar(char c) {
|
||||
if (_vkbActive) {
|
||||
// VKB is open — feed character into its text buffer
|
||||
if (_vkb.feedChar(c)) {
|
||||
_next_refresh = 0; // Redraw VKB immediately
|
||||
_auto_off = millis() + 120000; // Extend timeout while typing
|
||||
// Check if feedChar triggered submit or cancel
|
||||
if (_vkb.status() == VKB_SUBMITTED) {
|
||||
onVKBSubmit();
|
||||
} else if (_vkb.status() == VKB_CANCELLED) {
|
||||
onVKBCancel();
|
||||
}
|
||||
} else {
|
||||
// feedChar returned false — nav keys (arrows) while VKB is active
|
||||
// Not consumed; could be used for cursor movement in future
|
||||
}
|
||||
} else {
|
||||
// No VKB active — route as normal navigation key
|
||||
injectKey(c);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
bool UITask::getGPSState() {
|
||||
@@ -1972,6 +2320,9 @@ void UITask::injectKey(char c) {
|
||||
}
|
||||
curr->handleInput(c);
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
_lastInputMillis = millis(); // Reset auto-lock idle timer
|
||||
#endif
|
||||
// Debounce refresh when editing UTC offset - e-ink takes 644ms per refresh
|
||||
// so don't queue another render until the current one could have finished
|
||||
if (isEditingHomeScreen()) {
|
||||
@@ -1987,25 +2338,65 @@ void UITask::injectKey(char c) {
|
||||
|
||||
void UITask::gotoHomeScreen() {
|
||||
// Cancel any active editing state when navigating to home
|
||||
((HomeScreen *) home)->cancelEditUTC();
|
||||
((HomeScreen *) home)->cancelEditing();
|
||||
setCurrScreen(home);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
|
||||
// Activate deferred boot hint now that home screen is visible
|
||||
if (_pendingBootHint) {
|
||||
_pendingBootHint = false;
|
||||
_hintActive = true;
|
||||
_hintExpiry = millis() + 8000; // 8 seconds auto-dismiss
|
||||
_next_refresh = millis() + 100;
|
||||
Serial.println("[UI] Boot hint activated");
|
||||
}
|
||||
}
|
||||
|
||||
bool UITask::isEditingHomeScreen() const {
|
||||
return curr == home && ((HomeScreen *) home)->isEditingUTC();
|
||||
}
|
||||
|
||||
bool UITask::isHomeOnRecentPage() const {
|
||||
return curr == home && ((HomeScreen *) home)->isOnRecentPage();
|
||||
}
|
||||
|
||||
void UITask::gotoChannelScreen() {
|
||||
ChannelScreen* cs = (ChannelScreen*)channel_screen;
|
||||
// If currently showing DM view, reset to channel 0
|
||||
if (cs->getViewChannelIdx() == 0xFF) {
|
||||
cs->setViewChannelIdx(0);
|
||||
}
|
||||
cs->resetScroll();
|
||||
// Mark the currently viewed channel as read
|
||||
cs->markChannelRead(cs->getViewChannelIdx());
|
||||
setCurrScreen(channel_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
bool UITask::isEditingHomeScreen() const {
|
||||
return curr == home && ((HomeScreen *) home)->isEditingUTC();
|
||||
void UITask::gotoDMTab() {
|
||||
((ChannelScreen *) channel_screen)->setViewChannelIdx(0xFF); // switches + marks read
|
||||
((ChannelScreen *) channel_screen)->resetScroll();
|
||||
setCurrScreen(channel_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoChannelScreen() {
|
||||
((ChannelScreen *) channel_screen)->resetScroll();
|
||||
// Mark the currently viewed channel as read
|
||||
((ChannelScreen *) channel_screen)->markChannelRead(
|
||||
((ChannelScreen *) channel_screen)->getViewChannelIdx()
|
||||
);
|
||||
void UITask::gotoDMConversation(const char* contactName, int contactIdx, uint8_t perms) {
|
||||
ChannelScreen* cs = (ChannelScreen*)channel_screen;
|
||||
cs->setViewChannelIdx(0xFF); // enters inbox mode + marks read
|
||||
cs->openConversation(contactName, contactIdx, perms); // switches to conversation mode
|
||||
cs->resetScroll();
|
||||
setCurrScreen(channel_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
@@ -2042,6 +2433,10 @@ void UITask::gotoNotesScreen() {
|
||||
if (_display != NULL) {
|
||||
notes->enter(*_display);
|
||||
}
|
||||
// Set fresh timestamp and wire up time getter for note creation
|
||||
notes->setTimestamp(rtc_clock.getCurrentTime(),
|
||||
_node_prefs ? _node_prefs->utc_offset_hours : 0);
|
||||
notes->setTimeGetter([]() -> uint32_t { return rtc_clock.getCurrentTime(); });
|
||||
setCurrScreen(notes_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
@@ -2116,12 +2511,44 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
|
||||
}
|
||||
|
||||
void UITask::addSentDM(const char* recipientName, const char* sender, const char* text) {
|
||||
// Format as "Sender: message" and tag with recipient's peer hash
|
||||
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
|
||||
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
|
||||
((ChannelScreen *) channel_screen)->addMessage(0xFF, 0, sender, formattedMsg,
|
||||
nullptr, 0, recipientName);
|
||||
}
|
||||
|
||||
void UITask::markChannelReadFromBLE(uint8_t channel_idx) {
|
||||
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
|
||||
// If clearing DMs, also zero all per-contact DM counts
|
||||
if (channel_idx == 0xFF && _dmUnread) {
|
||||
memset(_dmUnread, 0, MAX_CONTACTS * sizeof(uint8_t));
|
||||
}
|
||||
// Trigger a refresh so the home screen unread count updates in real-time
|
||||
_next_refresh = millis() + 200;
|
||||
}
|
||||
|
||||
bool UITask::hasDMUnread(int contactIdx) const {
|
||||
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return false;
|
||||
return _dmUnread[contactIdx] > 0;
|
||||
}
|
||||
|
||||
int UITask::getDMUnreadCount(int contactIdx) const {
|
||||
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return 0;
|
||||
return _dmUnread[contactIdx];
|
||||
}
|
||||
|
||||
void UITask::clearDMUnread(int contactIdx) {
|
||||
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return;
|
||||
int count = _dmUnread[contactIdx];
|
||||
if (count > 0) {
|
||||
_dmUnread[contactIdx] = 0;
|
||||
((ChannelScreen *) channel_screen)->subtractDMUnread(count);
|
||||
_next_refresh = millis() + 200;
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::gotoRepeaterAdmin(int contactIdx) {
|
||||
// Lazy-initialize on first use (same pattern as audiobook player)
|
||||
if (repeater_admin == nullptr) {
|
||||
@@ -2147,6 +2574,17 @@ void UITask::gotoRepeaterAdmin(int contactIdx) {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoRepeaterAdminDirect(int contactIdx) {
|
||||
// Open admin and auto-submit cached password (skips password screen)
|
||||
_skipRoomRedirect = true; // Don't redirect back to conversation after login
|
||||
gotoRepeaterAdmin(contactIdx);
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
|
||||
if (admin && admin->getState() == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
|
||||
// If password was pre-filled from cache, simulate Enter to submit login
|
||||
admin->handleInput('\r');
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::gotoDiscoveryScreen() {
|
||||
((DiscoveryScreen*)discovery_screen)->resetScroll();
|
||||
setCurrScreen(discovery_screen);
|
||||
@@ -2157,6 +2595,16 @@ void UITask::gotoDiscoveryScreen() {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoLastHeardScreen() {
|
||||
((LastHeardScreen*)last_heard_screen)->resetScroll();
|
||||
setCurrScreen(last_heard_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
void UITask::gotoWebReader() {
|
||||
// Lazy-initialize on first use (same pattern as audiobook player)
|
||||
@@ -2201,6 +2649,26 @@ void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t serv
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
|
||||
if (success) {
|
||||
int cidx = ((RepeaterAdminScreen*)repeater_admin)->getContactIdx();
|
||||
if (cidx >= 0) {
|
||||
clearDMUnread(cidx);
|
||||
|
||||
// Room server login: redirect to conversation view with stored permissions.
|
||||
// Admin users see L:Admin footer to access the admin panel.
|
||||
// Skip redirect if user explicitly pressed L to get to admin.
|
||||
if (!_skipRoomRedirect) {
|
||||
ContactInfo contact;
|
||||
if (the_mesh.getContactByIdx(cidx, contact) && contact.type == ADV_TYPE_ROOM) {
|
||||
uint8_t maskedPerms = permissions & 0x03;
|
||||
gotoDMConversation(contact.name, cidx, maskedPerms);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_skipRoomRedirect = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ class UITask : public AbstractUITask {
|
||||
NodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
unsigned long _alert_expiry;
|
||||
bool _hintActive = false; // Boot navigation hint overlay
|
||||
unsigned long _hintExpiry = 0; // Auto-dismiss time for hint
|
||||
bool _pendingBootHint = false; // Deferred hint — show after splash screen
|
||||
int _msgcount;
|
||||
unsigned long ui_started_at, next_batt_chck;
|
||||
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
|
||||
@@ -84,6 +87,7 @@ class UITask : public AbstractUITask {
|
||||
#endif
|
||||
UIScreen* repeater_admin; // Repeater admin screen
|
||||
UIScreen* discovery_screen; // Node discovery scan screen
|
||||
UIScreen* last_heard_screen; // Last heard passive advert list
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
|
||||
#endif
|
||||
@@ -95,12 +99,43 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* lock_screen; // Lock screen (big clock + battery + unread)
|
||||
UIScreen* _screenBeforeLock = nullptr;
|
||||
bool _locked = false;
|
||||
unsigned long _lastInputMillis = 0; // Auto-lock idle tracking
|
||||
unsigned long _lastLockRefresh = 0; // Periodic lock screen clock update
|
||||
|
||||
VirtualKeyboard _vkb;
|
||||
bool _vkbActive = false;
|
||||
UIScreen* _screenBeforeVKB = nullptr;
|
||||
unsigned long _vkbOpenedAt = 0;
|
||||
#ifdef MECK_CARDKB
|
||||
bool _cardkbDetected = false;
|
||||
#endif
|
||||
#elif defined(LilyGo_TDeck_Pro)
|
||||
UIScreen* lock_screen; // Lock screen (big clock + battery + unread)
|
||||
UIScreen* _screenBeforeLock = nullptr;
|
||||
bool _locked = false;
|
||||
unsigned long _lastInputMillis = 0; // Auto-lock idle tracking
|
||||
unsigned long _lastLockRefresh = 0; // Periodic lock screen clock update
|
||||
#endif
|
||||
|
||||
// --- Message dedup ring buffer (suppress retry spam at UI level) ---
|
||||
#define MSG_DEDUP_SIZE 8
|
||||
#define MSG_DEDUP_WINDOW_MS 60000 // 60 seconds
|
||||
struct MsgDedup {
|
||||
uint32_t name_hash;
|
||||
uint32_t text_hash;
|
||||
unsigned long millis;
|
||||
};
|
||||
MsgDedup _dedup[MSG_DEDUP_SIZE];
|
||||
int _dedupIdx = 0;
|
||||
|
||||
// --- Per-contact DM unread tracking ---
|
||||
uint8_t* _dmUnread = nullptr; // PSRAM-allocated, MAX_CONTACTS entries
|
||||
|
||||
static uint32_t simpleHash(const char* s) {
|
||||
uint32_t h = 5381;
|
||||
while (*s) { h = ((h << 5) + h) ^ (uint8_t)*s++; }
|
||||
return h;
|
||||
}
|
||||
|
||||
void userLedHandler();
|
||||
|
||||
@@ -129,6 +164,8 @@ public:
|
||||
|
||||
void gotoHomeScreen();
|
||||
void gotoChannelScreen(); // Navigate to channel message screen
|
||||
void gotoDMTab(); // Navigate directly to DM tab on channel screen
|
||||
void gotoDMConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0);
|
||||
void gotoContactsScreen(); // Navigate to contacts list
|
||||
void gotoTextReader(); // *** NEW: Navigate to text reader ***
|
||||
void gotoNotesScreen(); // Navigate to notes editor
|
||||
@@ -136,7 +173,9 @@ public:
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
void gotoRepeaterAdminDirect(int contactIdx); // Auto-login admin (L key from conversation)
|
||||
void gotoDiscoveryScreen(); // Navigate to node discovery scan
|
||||
void gotoLastHeardScreen(); // Navigate to last heard passive list
|
||||
#if HAS_GPS
|
||||
void gotoMapScreen(); // Navigate to map tile screen
|
||||
#endif
|
||||
@@ -150,6 +189,9 @@ public:
|
||||
#endif
|
||||
void showAlert(const char* text, int duration_millis) override;
|
||||
void forceRefresh() override { _next_refresh = 100; }
|
||||
void showBootHint(bool immediate = false); // Show navigation hint overlay on first boot
|
||||
void dismissBootHint(); // Dismiss hint and save preference
|
||||
bool isHintActive() const { return _hintActive; }
|
||||
// Wake display and extend auto-off timer. Call this when handling keys
|
||||
// outside of injectKey() to prevent display auto-off during direct input.
|
||||
void keepAlive() {
|
||||
@@ -158,6 +200,14 @@ public:
|
||||
}
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
int getUnreadMsgCount() const; // Per-channel unread tracking (standalone)
|
||||
|
||||
// Per-contact DM unread tracking
|
||||
bool hasDMUnread(int contactIdx) const;
|
||||
int getDMUnreadCount(int contactIdx) const;
|
||||
void clearDMUnread(int contactIdx);
|
||||
|
||||
// Flag: suppress room→conversation redirect on next login (L key admin access)
|
||||
bool _skipRoomRedirect = false;
|
||||
bool hasDisplay() const { return _display != NULL; }
|
||||
bool isButtonPressed() const;
|
||||
bool isOnChannelScreen() const { return curr == channel_screen; }
|
||||
@@ -173,17 +223,25 @@ public:
|
||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
|
||||
bool isOnLastHeardScreen() const { return curr == last_heard_screen; }
|
||||
bool isOnMapScreen() const { return curr == map_screen; }
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
bool isLocked() const { return _locked; }
|
||||
void lockScreen();
|
||||
void unlockScreen();
|
||||
#endif
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
bool isVKBActive() const { return _vkbActive; }
|
||||
unsigned long vkbOpenedAt() const { return _vkbOpenedAt; }
|
||||
VirtualKeyboard& getVKB() { return _vkb; }
|
||||
void showVirtualKeyboard(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx = 0);
|
||||
void onVKBSubmit();
|
||||
void onVKBCancel();
|
||||
#ifdef MECK_CARDKB
|
||||
void setCardKBDetected(bool v) { _cardkbDetected = v; }
|
||||
bool hasCardKB() const { return _cardkbDetected; }
|
||||
void feedCardKBChar(char c);
|
||||
#endif
|
||||
#endif
|
||||
#ifdef MECK_WEB_READER
|
||||
bool isOnWebReader() const { return curr == web_reader; }
|
||||
@@ -202,12 +260,15 @@ public:
|
||||
|
||||
// Check if home screen is in an editing mode (e.g. UTC offset editor)
|
||||
bool isEditingHomeScreen() const;
|
||||
// Check if home screen is showing the Recent Adverts page
|
||||
bool isHomeOnRecentPage() const;
|
||||
|
||||
// Inject a key press from external source (e.g., keyboard)
|
||||
void injectKey(char c);
|
||||
|
||||
// Add a sent message to the channel screen history
|
||||
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
|
||||
void addSentDM(const char* recipientName, const char* sender, const char* text);
|
||||
|
||||
// Mark channel as read when BLE companion app syncs messages
|
||||
void markChannelReadFromBLE(uint8_t channel_idx) override;
|
||||
@@ -229,6 +290,7 @@ public:
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
|
||||
UIScreen* getLastHeardScreen() const { return last_heard_screen; }
|
||||
UIScreen* getMapScreen() const { return map_screen; }
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* getWebReaderScreen() const { return web_reader; }
|
||||
|
||||
@@ -29,6 +29,7 @@ enum VKBPurpose {
|
||||
VKB_ADMIN_CLI, // Repeater admin CLI command
|
||||
VKB_NOTES, // Insert text into notes
|
||||
VKB_SETTINGS_NAME, // Edit node name
|
||||
VKB_SETTINGS_TEXT, // Generic settings text edit (channel name, freq, APN)
|
||||
VKB_WIFI_PASSWORD, // WiFi password entry (settings screen)
|
||||
#ifdef MECK_WEB_READER
|
||||
VKB_WEB_URL, // Web reader URL entry
|
||||
@@ -36,6 +37,7 @@ enum VKBPurpose {
|
||||
VKB_WEB_WIFI_PASS, // Web reader WiFi password
|
||||
VKB_WEB_LINK, // Web reader link number entry
|
||||
#endif
|
||||
VKB_TEXT_PAGE, // Text reader: go to page number
|
||||
};
|
||||
|
||||
class VirtualKeyboard {
|
||||
@@ -215,6 +217,32 @@ public:
|
||||
// Swipe up on keyboard = cancel
|
||||
void cancel() { _status = VKB_CANCELLED; }
|
||||
|
||||
// --- Feed a raw ASCII character from an external physical keyboard ---
|
||||
// Maps standard ASCII control chars to internal VKB actions.
|
||||
// Returns true if the character was consumed.
|
||||
#ifdef MECK_CARDKB
|
||||
bool feedChar(char c) {
|
||||
if (_status != VKB_EDITING) return false;
|
||||
switch (c) {
|
||||
case '\r': processKey('>'); return true; // Enter → submit
|
||||
case '\b': processKey('<'); return true; // Backspace
|
||||
case 0x7F: processKey('<'); return true; // Delete → backspace
|
||||
case 0x1B: _status = VKB_CANCELLED; return true; // ESC → cancel
|
||||
case ' ': processKey('~'); return true; // Space
|
||||
default:
|
||||
// Printable ASCII → insert directly
|
||||
if (c >= 0x20 && c <= 0x7E) {
|
||||
if (_textLen < _maxLen) {
|
||||
_text[_textLen++] = c;
|
||||
_text[_textLen] = '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false; // Non-printable / nav keys — not consumed
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private:
|
||||
VKBStatus _status;
|
||||
VKBPurpose _purpose;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""
|
||||
PlatformIO post-build script: merge bootloader + partitions + firmware
|
||||
PlatformIO post-build script: merge bootloader + partitions + firmware + SPIFFS
|
||||
into a single flashable binary.
|
||||
|
||||
Includes a pre-formatted empty SPIFFS image so first-boot doesn't need to
|
||||
format the partition (which takes 1-2 minutes on 16MB flash).
|
||||
|
||||
Output: .pio/build/<env>/firmware_merged.bin
|
||||
Flash: esptool.py --chip esp32s3 write_flash 0x0 firmware_merged.bin
|
||||
|
||||
@@ -12,6 +15,87 @@ Add to each environment (or the base section):
|
||||
|
||||
Import("env")
|
||||
|
||||
def find_spiffs_partition(partitions_bin):
|
||||
"""Parse compiled partitions.bin to find SPIFFS partition offset and size.
|
||||
|
||||
ESP32 partition entry format (32 bytes each):
|
||||
0xAA50 magic, type, subtype, offset(u32le), size(u32le), label(16), flags(u32le)
|
||||
SPIFFS: type=0x01(data), subtype=0x82(spiffs)
|
||||
"""
|
||||
import struct
|
||||
|
||||
with open(partitions_bin, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
for i in range(0, len(data) - 32, 32):
|
||||
magic = struct.unpack_from("<H", data, i)[0]
|
||||
if magic != 0xAA50:
|
||||
continue
|
||||
ptype = data[i + 2]
|
||||
subtype = data[i + 3]
|
||||
offset = struct.unpack_from("<I", data, i + 4)[0]
|
||||
size = struct.unpack_from("<I", data, i + 8)[0]
|
||||
label = data[i + 12:i + 28].split(b'\x00')[0].decode("ascii", errors="ignore")
|
||||
if ptype == 0x01 and subtype == 0x82: # data/spiffs
|
||||
return offset, size, label
|
||||
return None, None, None
|
||||
|
||||
|
||||
def build_spiffs_image(env, size):
|
||||
"""Generate an empty formatted SPIFFS image using mkspiffs."""
|
||||
import subprocess, os, tempfile, glob
|
||||
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
spiffs_bin = os.path.join(build_dir, "spiffs_empty.bin")
|
||||
|
||||
# If already generated for this build, reuse it
|
||||
if os.path.isfile(spiffs_bin) and os.path.getsize(spiffs_bin) == size:
|
||||
return spiffs_bin
|
||||
|
||||
# Find mkspiffs in PlatformIO packages
|
||||
pio_home = os.path.expanduser("~/.platformio")
|
||||
mkspiffs_paths = glob.glob(os.path.join(pio_home, "packages", "tool-mkspiffs*", "mkspiffs*"))
|
||||
if not mkspiffs_paths:
|
||||
# Also check platform-specific tool paths
|
||||
mkspiffs_paths = glob.glob(os.path.join(pio_home, "packages", "tool-mklittlefs*", "mkspiffs*"))
|
||||
|
||||
mkspiffs = None
|
||||
for p in mkspiffs_paths:
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
mkspiffs = p
|
||||
break
|
||||
|
||||
if not mkspiffs:
|
||||
print("[merge] WARNING: mkspiffs not found, skipping SPIFFS image")
|
||||
return None
|
||||
|
||||
# Create empty data directory for mkspiffs
|
||||
data_dir = os.path.join(build_dir, "_empty_spiffs_data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
# SPIFFS block/page sizes — ESP32 Arduino defaults
|
||||
block_size = 4096
|
||||
page_size = 256
|
||||
|
||||
cmd = [
|
||||
mkspiffs,
|
||||
"-c", data_dir,
|
||||
"-b", str(block_size),
|
||||
"-p", str(page_size),
|
||||
"-s", str(size),
|
||||
spiffs_bin,
|
||||
]
|
||||
|
||||
print(f"[merge] Generating empty SPIFFS image ({size // 1024} KB)...")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0 and os.path.isfile(spiffs_bin):
|
||||
print(f"[merge] SPIFFS image OK: {spiffs_bin}")
|
||||
return spiffs_bin
|
||||
else:
|
||||
print(f"[merge] mkspiffs failed: {result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def merge_bin(source, target, env):
|
||||
import subprocess, os
|
||||
|
||||
@@ -21,7 +105,7 @@ def merge_bin(source, target, env):
|
||||
bootloader = os.path.join(build_dir, "bootloader.bin")
|
||||
partitions = os.path.join(build_dir, "partitions.bin")
|
||||
firmware = os.path.join(build_dir, "firmware.bin")
|
||||
output = os.path.join(build_dir, "firmware_merged.bin")
|
||||
output = os.path.join(build_dir, "firmware-merged.bin")
|
||||
|
||||
# Verify all inputs exist
|
||||
for f in [bootloader, partitions, firmware]:
|
||||
@@ -52,8 +136,18 @@ def merge_bin(source, target, env):
|
||||
"0x10000", firmware,
|
||||
]
|
||||
|
||||
# Try to include a pre-formatted SPIFFS image (eliminates 1-2 min first-boot format)
|
||||
spiffs_offset, spiffs_size, spiffs_label = find_spiffs_partition(partitions)
|
||||
if spiffs_offset and spiffs_size:
|
||||
spiffs_bin = build_spiffs_image(env, spiffs_size)
|
||||
if spiffs_bin:
|
||||
cmd.extend([f"0x{spiffs_offset:x}", spiffs_bin])
|
||||
print(f"[merge] Including SPIFFS image at 0x{spiffs_offset:x} ({spiffs_size // 1024} KB)")
|
||||
else:
|
||||
print("[merge] No SPIFFS partition found in partition table, skipping SPIFFS image")
|
||||
|
||||
print(f"\n[merge] Creating merged firmware for {env_name}...")
|
||||
print(f"[merge] {' '.join(cmd[-6:])}")
|
||||
print(f"[merge] {' '.join(cmd[-8:])}")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
|
||||
BIN
readback.bin
Normal file
BIN
readback.bin
Normal file
Binary file not shown.
@@ -52,7 +52,7 @@ bool GxEPDDisplay::begin() {
|
||||
|
||||
void GxEPDDisplay::turnOn() {
|
||||
if (!_init) begin();
|
||||
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN)
|
||||
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN) && !defined(LilyGo_TDeck_Pro)
|
||||
digitalWrite(DISP_BACKLIGHT, HIGH);
|
||||
#elif defined(EXP_PIN_BACKLIGHT) && !defined(BACKLIGHT_BTN)
|
||||
expander.digitalWrite(EXP_PIN_BACKLIGHT, HIGH);
|
||||
@@ -61,12 +61,17 @@ void GxEPDDisplay::turnOn() {
|
||||
}
|
||||
|
||||
void GxEPDDisplay::turnOff() {
|
||||
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN)
|
||||
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN) && !defined(LilyGo_TDeck_Pro)
|
||||
// Only toggle backlight on boards that actually have one.
|
||||
// T-Deck Pro defines DISP_BACKLIGHT (GPIO 45) but has no physical backlight —
|
||||
// setting _isOn=false would stop the render loop, making the device appear frozen.
|
||||
digitalWrite(DISP_BACKLIGHT, LOW);
|
||||
_isOn = false;
|
||||
#elif defined(EXP_PIN_BACKLIGHT) && !defined(BACKLIGHT_BTN)
|
||||
expander.digitalWrite(EXP_PIN_BACKLIGHT, LOW);
|
||||
#endif
|
||||
_isOn = false;
|
||||
#endif
|
||||
// T-Deck Pro: _isOn stays true — e-ink has no backlight, render loop must keep running
|
||||
}
|
||||
|
||||
void GxEPDDisplay::clear() {
|
||||
@@ -100,15 +105,23 @@ void GxEPDDisplay::setTextSize(int sz) {
|
||||
break;
|
||||
case 1: // Small - use 9pt (was 9pt)
|
||||
display.setFont(&FreeSans9pt7b);
|
||||
display.setTextSize(1);
|
||||
break;
|
||||
case 2: // Medium Bold - use 9pt bold instead of 12pt
|
||||
display.setFont(&FreeSans9pt7b);
|
||||
display.setTextSize(1);
|
||||
break;
|
||||
case 3: // Large - use 12pt instead of 18pt
|
||||
display.setFont(&FreeSansBold12pt7b);
|
||||
display.setTextSize(1);
|
||||
break;
|
||||
case 5: // Extra Large - lock screen clock face
|
||||
display.setFont(&FreeSansBold12pt7b);
|
||||
display.setTextSize(2); // GxEPD2 native 2× scaling on 12pt bold
|
||||
break;
|
||||
default:
|
||||
display.setFont(&FreeSans9pt7b);
|
||||
display.setTextSize(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
// 240 MHz ~70-80 mA
|
||||
// 160 MHz ~50-60 mA
|
||||
// 80 MHz ~30-40 mA
|
||||
// 40 MHz ~15-20 mA (low-power / lock screen mode)
|
||||
//
|
||||
// SPI peripherals and UART use their own clock dividers from the APB clock,
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz.
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz.
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
@@ -22,23 +23,36 @@
|
||||
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_LOW_POWER
|
||||
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
|
||||
#endif
|
||||
|
||||
#ifndef CPU_BOOST_TIMEOUT_MS
|
||||
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
|
||||
#endif
|
||||
|
||||
class CPUPowerManager {
|
||||
public:
|
||||
CPUPowerManager() : _boosted(false), _boost_started(0) {}
|
||||
CPUPowerManager() : _boosted(false), _lowPower(false), _boost_started(0) {}
|
||||
|
||||
void begin() {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
_lowPower = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
|
||||
setIdle();
|
||||
// Return to low-power if locked, otherwise normal idle
|
||||
if (_lowPower) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: boost expired, returning to low-power %d MHz", CPU_FREQ_LOW_POWER);
|
||||
} else {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
_boosted = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +71,42 @@ public:
|
||||
_boosted = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
if (_lowPower) {
|
||||
_lowPower = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Low-power mode — drops CPU to 40 MHz for lock screen standby.
|
||||
// If currently boosted, the boost timeout will return to 40 MHz
|
||||
// instead of 80 MHz.
|
||||
void setLowPower() {
|
||||
_lowPower = true;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: low-power at %d MHz", CPU_FREQ_LOW_POWER);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to low-power instead of idle
|
||||
}
|
||||
|
||||
// Exit low-power mode — returns to normal idle (80 MHz).
|
||||
// If currently boosted, the boost timeout will return to idle
|
||||
// instead of low-power.
|
||||
void clearLowPower() {
|
||||
_lowPower = false;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz (low-power cleared)", CPU_FREQ_IDLE);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to idle as normal
|
||||
}
|
||||
|
||||
bool isBoosted() const { return _boosted; }
|
||||
bool isLowPower() const { return _lowPower; }
|
||||
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
|
||||
|
||||
private:
|
||||
bool _boosted;
|
||||
bool _lowPower;
|
||||
unsigned long _boost_started;
|
||||
};
|
||||
|
||||
|
||||
@@ -63,10 +63,13 @@ build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/LilyGo_T5S3_EPaper_Pro>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
WebServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; T5S3 standalone — touch UI (stub), verify display rendering
|
||||
; Uses FastEPD for parallel e-ink, Adafruit GFX for drawing
|
||||
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_t5s3_standalone]
|
||||
extends = LilyGo_T5S3_EPaper_Pro
|
||||
@@ -79,7 +82,8 @@ build_flags =
|
||||
-D CHANNEL_MSG_HISTORY_SIZE=800
|
||||
-D DISPLAY_CLASS=FastEPDDisplay
|
||||
-D USE_EINK
|
||||
; Font family: comment/uncomment to toggle (delete .indexes on SD after switching)
|
||||
-D MECK_CARDKB
|
||||
-D MECK_OTA_UPDATE=1
|
||||
; -D MECK_SERIF_FONT ; FreeSerif (Times New Roman-like)
|
||||
; ; Default (no flag): FreeSans (Arial-like)
|
||||
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
|
||||
@@ -98,6 +102,7 @@ lib_deps =
|
||||
; ---------------------------------------------------------------------------
|
||||
; T5S3 BLE companion — touch UI, BLE phone bridging
|
||||
; Connect via MeshCore iOS/Android app over Bluetooth
|
||||
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
|
||||
; Flash: pio run -e meck_t5s3_ble -t upload
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_t5s3_ble]
|
||||
@@ -111,6 +116,8 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D DISPLAY_CLASS=FastEPDDisplay
|
||||
-D USE_EINK
|
||||
-D MECK_CARDKB
|
||||
-D MECK_OTA_UPDATE=1
|
||||
; -D MECK_SERIF_FONT
|
||||
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
@@ -140,10 +147,12 @@ build_flags =
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D MECK_WIFI_COMPANION=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D TCP_PORT=5000
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D DISPLAY_CLASS=FastEPDDisplay
|
||||
-D USE_EINK
|
||||
-D MECK_CARDKB
|
||||
; -D MECK_SERIF_FONT
|
||||
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
@@ -156,5 +165,4 @@ lib_deps =
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
https://github.com/mverch67/FastEPD/archive/0df1bff329b6fc782e062f611758880762340647.zip
|
||||
https://github.com/lewisxhe/SensorLib/archive/refs/tags/v0.3.4.zip
|
||||
|
||||
https://github.com/lewisxhe/SensorLib/archive/refs/tags/v0.3.4.zip
|
||||
@@ -29,6 +29,7 @@
|
||||
#define I2C_ADDR_BQ27220 0x55 // Fuel gauge
|
||||
#define I2C_ADDR_BQ25896 0x6B // Battery charger
|
||||
#define I2C_ADDR_TPS65185 0x68 // E-ink power driver
|
||||
#define CARDKB_I2C_ADDR 0x5F // M5Stack CardKB (external, via QWIIC)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SPI Bus — shared by LoRa and SD card
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "TDeckBoard.h"
|
||||
|
||||
uint32_t deviceOnline = 0x00;
|
||||
|
||||
void TDeckBoard::begin() {
|
||||
|
||||
ESP32Board::begin();
|
||||
|
||||
// Enable peripheral power
|
||||
pinMode(PIN_PERF_POWERON, OUTPUT);
|
||||
digitalWrite(PIN_PERF_POWERON, HIGH);
|
||||
|
||||
// Configure user button
|
||||
pinMode(PIN_USER_BTN, INPUT);
|
||||
|
||||
// Configure LoRa Pins
|
||||
pinMode(P_LORA_MISO, INPUT_PULLUP);
|
||||
// pinMode(P_LORA_DIO_1, INPUT_PULLUP);
|
||||
|
||||
#ifdef P_LORA_TX_LED
|
||||
digitalWrite(P_LORA_TX_LED, HIGH); // inverted pin for SX1276 - HIGH for off
|
||||
#endif
|
||||
|
||||
esp_reset_reason_t reason = esp_reset_reason();
|
||||
if (reason == ESP_RST_DEEPSLEEP) {
|
||||
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
|
||||
if (wakeup_source & (1 << P_LORA_DIO_1)) {
|
||||
startup_reason = BD_STARTUP_RX_PACKET; // received a LoRa packet (while in deep sleep)
|
||||
}
|
||||
|
||||
rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS);
|
||||
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Wire.h>
|
||||
#include <Arduino.h>
|
||||
#include "helpers/ESP32Board.h"
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
#define PIN_VBAT_READ 4
|
||||
#define BATTERY_SAMPLES 8
|
||||
#define ADC_MULTIPLIER (2.0f * 3.3f * 1000)
|
||||
|
||||
class TDeckBoard : public ESP32Board {
|
||||
public:
|
||||
void begin();
|
||||
|
||||
#ifdef P_LORA_TX_LED
|
||||
void onBeforeTransmit() override{
|
||||
digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on - invert pin for SX1276
|
||||
}
|
||||
|
||||
void onAfterTransmit() override{
|
||||
digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off - invert pin for SX1276
|
||||
}
|
||||
#endif
|
||||
|
||||
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
|
||||
// Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep
|
||||
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
|
||||
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
|
||||
|
||||
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
|
||||
|
||||
if (pin_wake_btn < 0) {
|
||||
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet
|
||||
} else {
|
||||
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn
|
||||
}
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000);
|
||||
}
|
||||
|
||||
// Finally set ESP32 into sleep
|
||||
esp_deep_sleep_start(); // CPU halts here and never returns!
|
||||
}
|
||||
|
||||
uint16_t getBattMilliVolts() {
|
||||
#if defined(PIN_VBAT_READ) && defined(ADC_MULTIPLIER)
|
||||
analogReadResolution(12);
|
||||
|
||||
uint32_t raw = 0;
|
||||
for (int i = 0; i < BATTERY_SAMPLES; i++) {
|
||||
raw += analogRead(PIN_VBAT_READ);
|
||||
}
|
||||
|
||||
raw = raw / BATTERY_SAMPLES;
|
||||
return (ADC_MULTIPLIER * raw) / 4096;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
const char* getManufacturerName() const{
|
||||
return "LilyGo T-Deck";
|
||||
}
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
[LilyGo_TDeck]
|
||||
extends = esp32_base
|
||||
board = t-deck
|
||||
build_flags =
|
||||
${esp32_base.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/lilygo_tdeck
|
||||
-D LILYGO_TDECK
|
||||
-D BOARD_HAS_PSRAM=1
|
||||
-D CORE_DEBUG_LEVEL=1
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
-D PIN_USER_BTN=0 ; Trackball button
|
||||
-D PIN_PERF_POWERON=10 ; Peripheral power pin
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=false
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=1.8f
|
||||
-D P_LORA_DIO_1=45 ; LORA IRQ pin
|
||||
-D ENV_INCLUDE_GPS=1
|
||||
-D ENV_INCLUDE_AHTX0=0
|
||||
-D ENV_INCLUDE_BME280=0
|
||||
-D ENV_INCLUDE_BMP280=0
|
||||
-D ENV_INCLUDE_SHTC3=0
|
||||
-D ENV_INCLUDE_SHT4X=0
|
||||
-D ENV_INCLUDE_LPS22HB=0
|
||||
-D ENV_INCLUDE_INA3221=0
|
||||
-D ENV_INCLUDE_INA219=0
|
||||
-D ENV_INCLUDE_INA226=0
|
||||
-D ENV_INCLUDE_INA260=0
|
||||
-D ENV_INCLUDE_MLX90614=0
|
||||
-D ENV_INCLUDE_VL53L0X=0
|
||||
-D ENV_INCLUDE_BME680=0
|
||||
-D ENV_INCLUDE_BMP085=0
|
||||
-D P_LORA_NSS=9 ; LORA SS pin
|
||||
-D P_LORA_RESET=17 ; LORA RST pin
|
||||
-D P_LORA_BUSY=13 ; LORA Busy pin
|
||||
-D P_LORA_SCLK=40 ; LORA SCLK pin
|
||||
-D P_LORA_MISO=38 ; LORA MISO pin
|
||||
-D P_LORA_MOSI=41 ; LORA MOSI pin
|
||||
-D DISPLAY_CLASS=ST7789LCDDisplay
|
||||
-D DISPLAY_SCALE_X=2.5
|
||||
-D DISPLAY_SCALE_Y=3.75
|
||||
-D PIN_TFT_RST=-1
|
||||
-D PIN_TFT_VDD_CTL=-1
|
||||
-D PIN_TFT_LEDA_CTL=42
|
||||
-D PIN_TFT_CS=12
|
||||
-D PIN_TFT_DC=11
|
||||
-D PIN_TFT_SCL=40
|
||||
-D PIN_TFT_SDA=41
|
||||
-D PIN_GPS_RX=43
|
||||
-D PIN_GPS_TX=44
|
||||
-D GPS_BAUD_RATE=38400
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/lilygo_tdeck>
|
||||
+<helpers/sensors/*.cpp>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0
|
||||
|
||||
[env:LilyGo_TDeck_companion_radio_usb]
|
||||
extends = LilyGo_TDeck
|
||||
build_flags =
|
||||
${LilyGo_TDeck.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
build_src_filter = ${LilyGo_TDeck.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/ST7789LCDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:LilyGo_TDeck_companion_radio_ble]
|
||||
extends = LilyGo_TDeck
|
||||
build_flags =
|
||||
${LilyGo_TDeck.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
build_src_filter = ${LilyGo_TDeck.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/ST7789LCDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:LilyGo_TDeck_repeater]
|
||||
extends = LilyGo_TDeck
|
||||
build_flags =
|
||||
${LilyGo_TDeck.build_flags}
|
||||
-D ADVERT_NAME='"TDeck Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
build_src_filter = ${LilyGo_TDeck.build_src_filter}
|
||||
+<../examples/simple_repeater>
|
||||
+<helpers/ui/ST7789LCDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
@@ -1,55 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "target.h"
|
||||
|
||||
TDeckBoard board;
|
||||
|
||||
#if defined(P_LORA_SCLK)
|
||||
static SPIClass spi;
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);
|
||||
#else
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY);
|
||||
#endif
|
||||
|
||||
WRAPPER_CLASS radio_driver(radio, board);
|
||||
|
||||
ESP32RTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
MicroNMEALocationProvider gps(Serial1, &rtc_clock);
|
||||
EnvironmentSensorManager sensors(gps);
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
DISPLAY_CLASS display;
|
||||
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
fallback_clock.begin();
|
||||
rtc_clock.begin(Wire);
|
||||
Wire.begin(18, 8);
|
||||
|
||||
#if defined(P_LORA_SCLK)
|
||||
return radio.std_init(&spi);
|
||||
#else
|
||||
return radio.std_init();
|
||||
#endif
|
||||
}
|
||||
|
||||
uint32_t radio_get_rng_seed() {
|
||||
return radio.random(0x7FFFFFFF);
|
||||
}
|
||||
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setFrequency(freq);
|
||||
radio.setSpreadingFactor(sf);
|
||||
radio.setBandwidth(bw);
|
||||
radio.setCodingRate(cr);
|
||||
}
|
||||
|
||||
void radio_set_tx_power(uint8_t dbm) {
|
||||
radio.setOutputPower(dbm);
|
||||
}
|
||||
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng); // create new random identity
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include <TDeckBoard.h>
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include <helpers/ui/ST7789LCDDisplay.h>
|
||||
#include <helpers/ui/MomentaryButton.h>
|
||||
#endif
|
||||
#include "helpers/sensors/EnvironmentSensorManager.h"
|
||||
#include "helpers/sensors/MicroNMEALocationProvider.h"
|
||||
|
||||
extern TDeckBoard board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
extern DISPLAY_CLASS display;
|
||||
extern MomentaryButton user_btn;
|
||||
#endif
|
||||
|
||||
bool radio_init();
|
||||
uint32_t radio_get_rng_seed();
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
|
||||
void radio_set_tx_power(uint8_t dbm);
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
@@ -8,9 +8,10 @@
|
||||
// 240 MHz ~70-80 mA
|
||||
// 160 MHz ~50-60 mA
|
||||
// 80 MHz ~30-40 mA
|
||||
// 40 MHz ~15-20 mA (low-power / lock screen mode)
|
||||
//
|
||||
// SPI peripherals and UART use their own clock dividers from the APB clock,
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz.
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz.
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
@@ -22,23 +23,36 @@
|
||||
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_LOW_POWER
|
||||
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
|
||||
#endif
|
||||
|
||||
#ifndef CPU_BOOST_TIMEOUT_MS
|
||||
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
|
||||
#endif
|
||||
|
||||
class CPUPowerManager {
|
||||
public:
|
||||
CPUPowerManager() : _boosted(false), _boost_started(0) {}
|
||||
CPUPowerManager() : _boosted(false), _lowPower(false), _boost_started(0) {}
|
||||
|
||||
void begin() {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
_lowPower = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
|
||||
setIdle();
|
||||
// Return to low-power if locked, otherwise normal idle
|
||||
if (_lowPower) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: boost expired, returning to low-power %d MHz", CPU_FREQ_LOW_POWER);
|
||||
} else {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
_boosted = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +71,42 @@ public:
|
||||
_boosted = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
if (_lowPower) {
|
||||
_lowPower = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Low-power mode — drops CPU to 40 MHz for lock screen standby.
|
||||
// If currently boosted, the boost timeout will return to 40 MHz
|
||||
// instead of 80 MHz.
|
||||
void setLowPower() {
|
||||
_lowPower = true;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: low-power at %d MHz", CPU_FREQ_LOW_POWER);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to low-power instead of idle
|
||||
}
|
||||
|
||||
// Exit low-power mode — returns to normal idle (80 MHz).
|
||||
// If currently boosted, the boost timeout will return to idle
|
||||
// instead of low-power.
|
||||
void clearLowPower() {
|
||||
_lowPower = false;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz (low-power cleared)", CPU_FREQ_IDLE);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to idle as normal
|
||||
}
|
||||
|
||||
bool isBoosted() const { return _boosted; }
|
||||
bool isLowPower() const { return _lowPower; }
|
||||
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
|
||||
|
||||
private:
|
||||
bool _boosted;
|
||||
bool _lowPower;
|
||||
unsigned long _boost_started;
|
||||
};
|
||||
|
||||
|
||||
@@ -161,24 +161,47 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Configure keyboard matrix (8 rows x 10 cols)
|
||||
// --- Warm-reboot safe init sequence ---
|
||||
// The TCA8418 stays powered across ESP32 resets (no dedicated RST pin),
|
||||
// so the scanner may still be active from the previous session.
|
||||
// We must disable it before reconfiguring the matrix.
|
||||
|
||||
// 1. Disable scanner — stop all scanning before touching config
|
||||
writeReg(TCA8418_REG_CFG, 0x00);
|
||||
|
||||
// 2. Drain any stale events from the previous session
|
||||
for (int i = 0; i < 16; i++) {
|
||||
if ((readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) == 0) break;
|
||||
readReg(TCA8418_REG_KEY_EVENT_A);
|
||||
}
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F); // Clear all interrupt flags
|
||||
|
||||
// 3. Explicitly clear GPI event masks (prevent phantom GPI events)
|
||||
writeReg(TCA8418_REG_GPI_EM1, 0x00);
|
||||
writeReg(TCA8418_REG_GPI_EM2, 0x00);
|
||||
writeReg(TCA8418_REG_GPI_EM3, 0x00);
|
||||
|
||||
// 4. Configure keyboard matrix (8 rows x 10 cols)
|
||||
writeReg(TCA8418_REG_KP_GPIO1, 0xFF); // Rows 0-7 as keypad
|
||||
writeReg(TCA8418_REG_KP_GPIO2, 0xFF); // Cols 0-7 as keypad
|
||||
writeReg(TCA8418_REG_KP_GPIO3, 0x03); // Cols 8-9 as keypad
|
||||
|
||||
// Enable keypad with FIFO overflow detection
|
||||
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
|
||||
|
||||
// Set debounce
|
||||
// 5. Set debounce
|
||||
writeReg(TCA8418_REG_DEBOUNCE, 0x03);
|
||||
|
||||
// Clear any pending interrupts
|
||||
// 6. Final pre-enable cleanup
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F);
|
||||
|
||||
// Flush the FIFO
|
||||
while (readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) {
|
||||
// 7. Enable scanner — matrix config is stable, safe to start scanning
|
||||
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
|
||||
|
||||
// 8. Let scanner stabilise, then flush any spurious first-scan events
|
||||
delay(5);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
if ((readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) == 0) break;
|
||||
readReg(TCA8418_REG_KEY_EVENT_A);
|
||||
}
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F);
|
||||
|
||||
_initialized = true;
|
||||
Serial.println("TCA8418: Keyboard initialized OK");
|
||||
|
||||
@@ -83,6 +83,8 @@ build_flags =
|
||||
-D PIN_DISPLAY_MOSI=33
|
||||
-D PIN_DISPLAY_BL=45
|
||||
-D PIN_USER_BTN=0
|
||||
-D HAS_TOUCHSCREEN=1
|
||||
-D CST328_PIN_INT=12
|
||||
-D CST328_PIN_RST=38
|
||||
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
@@ -94,6 +96,8 @@ lib_deps =
|
||||
zinggjm/GxEPD2@^1.5.9
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
bitbank2/PNGdec@^1.0.1
|
||||
WebServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Meck unified builds — one codebase, six variants via build flags
|
||||
@@ -112,6 +116,7 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -144,7 +149,8 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.0.WiFi"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -159,6 +165,7 @@ lib_deps =
|
||||
|
||||
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
|
||||
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
|
||||
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
|
||||
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
|
||||
[env:meck_audio_standalone]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
@@ -169,6 +176,7 @@ build_flags =
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D OFFLINE_QUEUE_SIZE=1
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_OTA_UPDATE=1
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -194,7 +202,8 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.0.4G"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.4G"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -224,7 +233,8 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.0.4G.WiFi"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.4G.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -250,7 +260,8 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=1
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.0.4G.SA"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.4G.SA"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
|
||||
Reference in New Issue
Block a user