mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
86 Commits
multi-byte
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce93cfa033 | ||
|
|
2be399f65a | ||
|
|
5679cda38e | ||
|
|
1ea883783c | ||
|
|
bf8cf32bc2 | ||
|
|
465a29bb23 | ||
|
|
81eca29b69 | ||
|
|
342cf4e745 | ||
|
|
c52a190ace | ||
|
|
a7bc7a4733 | ||
|
|
47a0d2cc95 | ||
|
|
5dda0b686e | ||
|
|
60dcd6a89e | ||
|
|
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 | ||
|
|
509411630b | ||
|
|
a1ce8ca4d4 | ||
|
|
b77059706b | ||
|
|
a6f0052b89 | ||
|
|
120c0a739b | ||
|
|
816e41d63a | ||
|
|
68d10f088f | ||
|
|
2f0c8909b9 | ||
|
|
c60255a44d | ||
|
|
9040873526 | ||
|
|
a564957a82 | ||
|
|
b55892431d | ||
|
|
dc5331702d | ||
|
|
88a887eba2 | ||
|
|
b1218223e6 | ||
|
|
0971cd6015 | ||
|
|
81eb558868 | ||
|
|
74b24f1222 | ||
|
|
182231deeb | ||
|
|
3372c4aa1d | ||
|
|
467773366b | ||
|
|
753d125384 | ||
|
|
8b78eac17f | ||
|
|
565c2a4c9b | ||
|
|
7ae9c47006 | ||
|
|
2a0497e5ba | ||
|
|
479673e90f | ||
|
|
9b15458927 | ||
|
|
85ccdf526e | ||
|
|
c0dd59834c | ||
|
|
90a4f5f881 |
64
Filter_clock_sync.py
Normal file
64
Filter_clock_sync.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# PlatformIO monitor filter: automatic clock sync for Meck devices
|
||||
#
|
||||
# When a Meck device boots with no valid RTC time, it prints "MECK_CLOCK_REQ"
|
||||
# over serial. This filter watches for that line and responds immediately
|
||||
# with "clock sync <epoch>\r\n", setting the device's real-time clock to
|
||||
# the host computer's current time.
|
||||
#
|
||||
# The sync is completely transparent — the user just sees it happen in the
|
||||
# boot log. If the RTC already has valid time, the device never sends the
|
||||
# request and this filter does nothing.
|
||||
#
|
||||
# Install: place this file in <project>/monitor/filter_clock_sync.py
|
||||
# Enable: add "clock_sync" to monitor_filters in platformio.ini
|
||||
#
|
||||
# Works with: PlatformIO Core >= 6.0
|
||||
|
||||
import time
|
||||
|
||||
from platformio.device.monitor.filters.base import DeviceFilter
|
||||
|
||||
|
||||
class ClockSync(DeviceFilter):
|
||||
NAME = "clock_sync"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._buf = bytearray()
|
||||
self._synced = False
|
||||
|
||||
def rx(self, text):
|
||||
"""Called with each chunk of data received from the device."""
|
||||
if self._synced:
|
||||
return text
|
||||
|
||||
# Accumulate into a line buffer to detect MECK_CLOCK_REQ
|
||||
if isinstance(text, str):
|
||||
self._buf.extend(text.encode("utf-8", errors="replace"))
|
||||
else:
|
||||
self._buf.extend(text)
|
||||
|
||||
if b"MECK_CLOCK_REQ" in self._buf:
|
||||
epoch = int(time.time())
|
||||
response = "clock sync {}\r\n".format(epoch)
|
||||
try:
|
||||
# Write directly to the serial port
|
||||
self.miniterm.serial.write(response.encode("utf-8"))
|
||||
except Exception as e:
|
||||
# Fallback: shouldn't happen, but don't crash the monitor
|
||||
import sys
|
||||
print(
|
||||
"\n[clock_sync] Failed to auto-sync: {}".format(e),
|
||||
file=sys.stderr,
|
||||
)
|
||||
self._synced = True
|
||||
self._buf = bytearray()
|
||||
elif len(self._buf) > 2048:
|
||||
# Prevent unbounded growth — keep tail only
|
||||
self._buf = self._buf[-256:]
|
||||
|
||||
return text
|
||||
|
||||
def tx(self, text):
|
||||
"""Called with each chunk of data sent from terminal to device."""
|
||||
return text
|
||||
545
README.md
545
README.md
@@ -1,16 +1,31 @@
|
||||
## Meshcore + Fork = Meck
|
||||
This fork was created specifically to focus on enabling BLE companion firmware for the LilyGo T-Deck Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
|
||||
## Meshcore + Fork = Meck
|
||||
|
||||
A fork created specifically to focus on enabling BLE & WiFi companion firmware for the LilyGo T-Deck Pro & LilyGo T5 E-Paper S3 Pro. Created wholly with Claude AI using Meshcore v1.11 code. 100% vibecoded.
|
||||
|
||||
[Check out the Meck discussion channel on the MeshCore Discord](https://discord.com/channels/1343693475589263471/1460136499390447670)
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/b30ce6bd-79af-44d3-93c4-f5e7e21e5621" alt="IMG_1453" width="300" height="650">
|
||||
|
||||
### Contents
|
||||
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
|
||||
- [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)
|
||||
- [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)
|
||||
@@ -18,12 +33,27 @@ This fork was created specifically to focus on enabling BLE 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)
|
||||
- [Home Screen](#t5s3-home-screen)
|
||||
- [Boot Button Controls](#boot-button-controls)
|
||||
- [Backlight](#backlight)
|
||||
- [Lock Screen](#lock-screen)
|
||||
- [Virtual Keyboard](#virtual-keyboard)
|
||||
- [Display Settings](#display-settings)
|
||||
- [Clock & RTC](#clock--rtc)
|
||||
- [Touch Gestures by Screen](#touch-gestures-by-screen)
|
||||
- [Serial Settings (USB)](Serial_Settings_Guide.md)
|
||||
- [Text & EPUB Reader](TXT___EPUB_Reader_Guide.md)
|
||||
- [Web Browser & IRC Guide](Web_App_Guide.md)
|
||||
- [SMS & Phone App Guide](SMS___Phone_App_Guide.md)
|
||||
- [About MeshCore](#about-meshcore)
|
||||
- [What is MeshCore?](#what-is-meshcore)
|
||||
- [Key Features](#key-features)
|
||||
- [What Can You Use MeshCore For?](#what-can-you-use-meshcore-for)
|
||||
- [How to Get Started](#how-to-get-started)
|
||||
- [MeshCore Flasher](#meshcore-flasher)
|
||||
- [MeshCore Clients](#meshcore-clients)
|
||||
- [Hardware Compatibility](#-hardware-compatibility)
|
||||
- [Contributing](#contributing)
|
||||
@@ -32,9 +62,134 @@ This fork was created specifically to focus on enabling BLE companion firmware f
|
||||
- [License](#-license)
|
||||
- [Third-Party Libraries](#third-party-libraries)
|
||||
|
||||
## T-Deck Pro Keyboard Controls
|
||||
---
|
||||
|
||||
The T-Deck Pro BLE companion firmware includes full keyboard support for standalone messaging without a phone.
|
||||
## Supported Devices
|
||||
|
||||
Meck currently targets two LilyGo devices:
|
||||
|
||||
| Device | Display | Input | LoRa | Battery | GPS | RTC |
|
||||
|--------|---------|-------|------|---------|-----|-----|
|
||||
| **T-Deck Pro** | 240×320 e-ink (GxEPD2) | TCA8418 keyboard + optional touch | SX1262 | BQ27220 fuel gauge, 1400 mAh | Yes | No (uses GPS time) |
|
||||
| **T5S3 E-Paper Pro** (V2, H752-B) | 960×540 e-ink (FastEPD, parallel) | GT911 capacitive touch (no keyboard) | SX1262 | BQ27220 fuel gauge, 1500 mAh | No (non-GPS variant) | Yes (PCF8563 hardware RTC) |
|
||||
|
||||
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`. |
|
||||
| `*.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)
|
||||
|
||||
If the device has never had Meck firmware (or you want a clean start), use the **merged** `.bin` file. This contains the bootloader, partition table, and application firmware combined into a single image.
|
||||
|
||||
**Using esptool.py:**
|
||||
|
||||
```
|
||||
esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
|
||||
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`.
|
||||
|
||||
**Using the MeshCore Flasher (web-based, T-Deck Pro only):**
|
||||
|
||||
1. Go to https://flasher.meshcore.co.uk
|
||||
2. Select **Custom Firmware**
|
||||
3. Select the **merged** `.bin` file you downloaded
|
||||
4. Click **Flash**, select your device in the popup, and click **Connect**
|
||||
|
||||
> **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
|
||||
|
||||
If the device is already running Meck (or any MeshCore-based firmware with a valid bootloader), use the **non-merged** `.bin` file. This is smaller and faster to flash since it only contains the application firmware.
|
||||
|
||||
**Using esptool.py:**
|
||||
|
||||
```
|
||||
esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
|
||||
write_flash 0x10000 meck_t5s3_standalone.bin
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
### 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+)
|
||||
|
||||
Meck supports multibyte path hash, bringing it in line with MeshCore firmware v1.14. The path hash controls how many bytes each repeater uses to identify itself in forwarded flood packets. Larger hashes reduce the chance of identity collisions at the cost of fewer maximum hops per packet.
|
||||
|
||||
You can configure the path hash size in the device settings (press **S** from the home screen on T-Deck Pro, or open Settings via the tile on T5S3) or set it via USB serial:
|
||||
|
||||
```
|
||||
set path.hash.mode 1
|
||||
```
|
||||
|
||||
| Mode | Bytes per hop | Max hops | Notes |
|
||||
|------|--------------|----------|-------|
|
||||
| 0 | 1 | 64 | Legacy — prone to hash collisions in larger networks |
|
||||
| 1 | 2 | 32 | Recommended — effectively eliminates collisions |
|
||||
| 2 | 3 | 21 | Maximum precision, rarely needed |
|
||||
|
||||
Nodes with different path hash modes can coexist on the same network. The mode only affects packets your node originates — the hash size is encoded in each packet's header, so receiving nodes adapt automatically.
|
||||
|
||||
For a detailed explanation of what multibyte path hash means and why it matters, see the [Path Diagnostics & Improvements write-up](https://buymeacoffee.com/ripplebiz/path-diagnostics-improvements).
|
||||
|
||||
---
|
||||
|
||||
## T-Deck Pro
|
||||
|
||||
### T-Deck Pro Build Variants
|
||||
|
||||
| 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.
|
||||
|
||||
### T-Deck Pro Keyboard Controls
|
||||
|
||||
The T-Deck Pro firmware includes full keyboard support for standalone messaging without a phone.
|
||||
|
||||
### Navigation (Home Screen)
|
||||
|
||||
@@ -51,7 +206,11 @@ The T-Deck Pro BLE companion firmware includes full keyboard support for standal
|
||||
| B | Open web browser (BLE and 4G variants only) |
|
||||
| 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)
|
||||
|
||||
@@ -59,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:**
|
||||
|
||||
@@ -86,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) |
|
||||
@@ -105,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.
|
||||
@@ -141,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:**
|
||||
|
||||
@@ -150,21 +338,33 @@ 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 |
|
||||
| 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.
|
||||
|
||||
If you've changed radio parameters, pressing Q will prompt you to apply changes before exiting.
|
||||
|
||||
> **Tip:** All device settings (plus mesh tuning parameters not available on-screen) can also be configured via USB serial. See the [Serial Settings Guide](Serial_Settings_Guide.md) for complete documentation.
|
||||
|
||||
### Compose Mode
|
||||
|
||||
| Key | Action |
|
||||
@@ -214,17 +414,253 @@ While in compose mode, press the **$** key to open the emoji picker. A scrollabl
|
||||
|
||||
Press **T** from the home screen to open the SMS & Phone app. The app opens to a menu screen where you can choose between the **Phone** dialer (for calling any number) or the **SMS Inbox** (for messaging and calling saved contacts).
|
||||
|
||||
For full documentation including key mappings, dialpad usage, contacts management, and troubleshooting, see the [SMS & Phone App Guide](SMS%20%26%20Phone%20App%20Guide.md).
|
||||
For full documentation including key mappings, dialpad usage, contacts management, and troubleshooting, see the [SMS & Phone App Guide](SMS___Phone_App_Guide.md).
|
||||
|
||||
### Web Browser & IRC
|
||||
|
||||
Press **B** from the home screen to open the web reader. This is available on the BLE and 4G variants (not the standalone audio variant, which excludes WiFi to preserve lowest-battery-usage design).
|
||||
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.
|
||||
|
||||
The browser is a text-centric reader best suited to text-heavy websites. It also includes basic web search via DuckDuckGo Lite, and can download EPUB files — follow a link to an `.epub` and it will be saved to the books folder on your SD card for reading later in the e-book reader.
|
||||
|
||||
For full documentation including key mappings, WiFi setup, bookmarks, IRC configuration, and SD card structure, see the [Web App Guide](Web%20App%20Guide.md).
|
||||
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
|
||||
|
||||
The LilyGo T5S3 E-Paper Pro (V2, H752-B) is a 4.7-inch e-ink device with capacitive touch and no physical keyboard. All navigation is done via touch gestures and the Boot button (GPIO0). The larger 960×540 display provides significantly more screen real estate than the T-Deck Pro's 240×320 panel.
|
||||
|
||||
### T5S3 Build Variants
|
||||
|
||||
| Variant | Environment | BLE | WiFi | Web Reader | Max Contacts |
|
||||
|---------|------------|-----|------|------------|-------------|
|
||||
| Standalone | `meck_t5s3_standalone` | — | — | No | 1,500 |
|
||||
| BLE Companion | `meck_t5s3_ble` | Yes | — | No | 500 |
|
||||
| WiFi Companion | `meck_t5s3_wifi` | — | Yes (TCP:5000) | Yes | 1,500 |
|
||||
|
||||
The WiFi variant connects to the MeshCore web app or meshcore.js over your local network. The web reader shares the same WiFi connection — no extra setup needed.
|
||||
|
||||
### Touch Navigation
|
||||
|
||||
The T5S3 uses a combination of touch gestures and the Boot button for all interaction. There is no physical keyboard — text entry uses an on-screen virtual keyboard that appears when needed.
|
||||
|
||||
**Core gesture types:**
|
||||
|
||||
| Gesture | Description |
|
||||
|---------|-------------|
|
||||
| **Tap** | Touch and release quickly. Context-dependent: opens tiles on home screen, selects items in lists, advances pages in readers. |
|
||||
| **Swipe** | Touch, drag at least 60 pixels, and release. Direction determines action (scroll, page turn, switch channel/filter). |
|
||||
| **Long press (touch)** | Touch and hold for 500ms+. Context-dependent: compose messages, open DMs, delete bookmarks. |
|
||||
|
||||
### T5S3 Home Screen
|
||||
|
||||
The home screen displays a 3×2 grid of tappable tiles:
|
||||
|
||||
| | Column 1 | Column 2 | Column 3 |
|
||||
|---|----------|----------|----------|
|
||||
| **Row 1** | Messages | Contacts | Settings |
|
||||
| **Row 2** | Reader | Notes | Browser (WiFi) / Discover (other) |
|
||||
|
||||
Tap a tile to open that screen. Tap outside the tile grid (or swipe left/right) to cycle between home pages. The additional home pages show BLE status, battery info, GPS status, and a hibernate option — same as the T-Deck Pro but navigated by swiping or tapping the left/right halves of the screen instead of pressing keys.
|
||||
|
||||
### Boot Button Controls
|
||||
|
||||
The Boot button (GPIO0, bottom of device) provides essential navigation and utility functions:
|
||||
|
||||
| Action | Effect |
|
||||
|--------|--------|
|
||||
| **Single click** | On home screen: cycle to next page. On other screens: go back (same as pressing Q on T-Deck Pro). In text reader reading mode: close book and return to file list. |
|
||||
| **Double-click** | Toggle backlight at full brightness (comfortable for indoor reading). |
|
||||
| **Triple-click** | Toggle backlight at low brightness (dim nighttime reading). |
|
||||
| **Long press** | Lock or unlock the screen. While locked, touch is disabled and a lock screen shows the time, battery percentage, and unread message count. |
|
||||
| **Long press during first 8 seconds after boot** | Enter CLI rescue mode (serial settings interface). |
|
||||
|
||||
### Backlight
|
||||
|
||||
The T5S3 has a warm-tone front-light controlled by PWM on GPIO11. Brightness ranges from 0 (off) to 255 (maximum).
|
||||
|
||||
- **Double-click Boot button** — toggle backlight on at 153/255 brightness (comfortable reading level)
|
||||
- **Triple-click Boot button** — toggle backlight on at low brightness (4/255, nighttime reading)
|
||||
- The backlight turns off automatically when the screen locks
|
||||
|
||||
### Lock Screen
|
||||
|
||||
Long press the Boot button to lock the device. The lock screen shows:
|
||||
- Current time in large text (HH:MM)
|
||||
- Battery percentage
|
||||
- Unread message count (if any)
|
||||
- "Hold button to unlock" hint
|
||||
|
||||
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.).
|
||||
|
||||
The virtual keyboard supports:
|
||||
- QWERTY letter layout with a symbol/number layer (tap the **123** key to switch)
|
||||
- Shift toggle for uppercase
|
||||
- Backspace and Enter keys
|
||||
- Phantom keystroke prevention (a brief cooldown after the keyboard opens prevents accidental taps)
|
||||
|
||||
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 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. 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.
|
||||
|
||||
### Clock & RTC
|
||||
|
||||
Unlike the T-Deck Pro (which relies on GPS for time), the T5S3 has a hardware RTC (PCF8563/BM8563) that maintains time across reboots as long as the battery has charge. On first use (or after a full battery drain), the clock needs to be set via USB serial:
|
||||
|
||||
```
|
||||
clock sync 1773554535
|
||||
```
|
||||
|
||||
Where the number is a Unix epoch timestamp. Quick one-liner from a macOS/Linux terminal:
|
||||
|
||||
```
|
||||
echo "clock sync $(date +%s)" > /dev/ttyACM0
|
||||
```
|
||||
|
||||
Once set, the RTC retains the time across reboots. See the [Serial Settings Guide](Serial_Settings_Guide.md) for full clock sync documentation including the PlatformIO auto-sync feature.
|
||||
|
||||
The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is persisted to flash.
|
||||
|
||||
### Touch Gestures by Screen
|
||||
|
||||
#### Home Screen
|
||||
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Tap tile | Open that screen (Messages, Contacts, Settings, Reader, Notes, Browser/Discover) |
|
||||
| Tap outside tiles (left half) | Previous home page |
|
||||
| Tap outside tiles (right half) | Next home page |
|
||||
| Swipe left / right | Next / previous home page |
|
||||
| Long press (touch) | Activate current page action (toggle BLE, hibernate, etc.) |
|
||||
|
||||
#### Channel Messages
|
||||
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Swipe up / down | Scroll messages |
|
||||
| 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 |
|
||||
|
||||
#### Contacts
|
||||
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| 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 | View unread DMs (if any), then compose DM |
|
||||
| Long press on Repeater contact | Open repeater admin login |
|
||||
|
||||
#### Text Reader (File List)
|
||||
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Swipe up / down | Scroll file list |
|
||||
| Tap | Open selected book |
|
||||
|
||||
#### Text Reader (Reading)
|
||||
|
||||
| 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 |
|
||||
| Long press (touch) | Close book, return to file list |
|
||||
| Tap status bar | Go to home screen |
|
||||
|
||||
#### Web Reader (WiFi variant)
|
||||
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Tap URL bar | Open virtual keyboard for URL entry |
|
||||
| Tap Search | Open virtual keyboard for DuckDuckGo search |
|
||||
| Tap reading area | Next page |
|
||||
| Tap footer (if links exist) | Open virtual keyboard to enter link number |
|
||||
| Swipe left / right (reading) | Next / previous page |
|
||||
| Swipe up / down (home/lists) | Scroll list |
|
||||
| Long press (reading) | Navigate back |
|
||||
| Long press on bookmark | Delete bookmark |
|
||||
| Long press on home | Exit web reader |
|
||||
|
||||
#### Settings
|
||||
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Swipe up / down | Scroll through settings |
|
||||
| Swipe left / right | Adjust value (same as A/D keys on T-Deck Pro) |
|
||||
| Tap | Toggle or edit selected setting |
|
||||
|
||||
#### Notes
|
||||
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Tap (while editing) | Open virtual keyboard for text entry |
|
||||
| Long press (while editing) | Save note and exit editor |
|
||||
|
||||
#### Discovery
|
||||
|
||||
| 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 |
|
||||
|---------|--------|
|
||||
| Swipe up / down | Scroll menu / response |
|
||||
| Long press (password entry) | Open virtual keyboard for admin password |
|
||||
|
||||
#### All Screens
|
||||
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Tap status bar (top of screen) | Return to home screen (except in text reader reading mode, where it advances the page) |
|
||||
|
||||
---
|
||||
|
||||
## About MeshCore
|
||||
|
||||
@@ -232,8 +668,8 @@ MeshCore is a lightweight, portable C++ library that enables multi-hop packet ro
|
||||
|
||||
## What is MeshCore?
|
||||
|
||||
MeshCore now supports a range of LoRa devices, allowing for easy flashing without the need to compile firmware manually. Users can flash a pre-built binary using tools like Adafruit ESPTool and interact with the network through a serial console.
|
||||
MeshCore provides the ability to create wireless mesh networks, similar to Meshtastic and Reticulum but with a focus on lightweight multi-hop packet routing for embedded projects. Unlike Meshtastic, which is tailored for casual LoRa communication, or Reticulum, which offers advanced networking, MeshCore balances simplicity with scalability, making it ideal for custom embedded solutions., where devices (nodes) can communicate over long distances by relaying messages through intermediate nodes. This is especially useful in off-grid, emergency, or tactical situations where traditional communication infrastructure is unavailable.
|
||||
MeshCore now supports a range of LoRa devices, allowing for easy flashing without the need to compile firmware manually. Users can flash a pre-built binary using tools like esptool.py and interact with the network through a serial console.
|
||||
MeshCore provides the ability to create wireless mesh networks, similar to Meshtastic and Reticulum but with a focus on lightweight multi-hop packet routing for embedded projects. Unlike Meshtastic, which is tailored for casual LoRa communication, or Reticulum, which offers advanced networking, MeshCore balances simplicity with scalability, making it ideal for custom embedded solutions, where devices (nodes) can communicate over long distances by relaying messages through intermediate nodes. This is especially useful in off-grid, emergency, or tactical situations where traditional communication infrastructure is unavailable.
|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -258,33 +694,22 @@ MeshCore provides the ability to create wireless mesh networks, similar to Mesht
|
||||
|
||||
- Watch the [MeshCore Intro Video](https://www.youtube.com/watch?v=t1qne8uJBAc) by Andy Kirby.
|
||||
- Read through our [Frequently Asked Questions](./docs/faq.md) section.
|
||||
- Flash the MeshCore firmware on a supported device.
|
||||
- Download firmware from the [Releases](https://github.com/pelgraine/Meck/releases) page and flash it using the instructions above.
|
||||
- Connect with a supported client.
|
||||
|
||||
For developers;
|
||||
For developers:
|
||||
|
||||
- Install [PlatformIO](https://docs.platformio.org) in [Visual Studio Code](https://code.visualstudio.com).
|
||||
- Clone and open the MeshCore repository in Visual Studio Code.
|
||||
- See the example applications you can modify and run:
|
||||
- [Companion Radio](./examples/companion_radio) - For use with an external chat app, over BLE, USB or WiFi.
|
||||
|
||||
## MeshCore Flasher
|
||||
|
||||
Download a copy of the Meck firmware bin from https://github.com/pelgraine/Meck/releases, then:
|
||||
|
||||
- Launch https://flasher.meshcore.co.uk
|
||||
- Select Custom Firmware
|
||||
- Select the .bin file you just downloaded, and click Open or press Enter.
|
||||
- Click Flash, then select your device in the popup window (eg. USB JTAG/serial debug unit cu.usbmodem101 as an example), then click Connect.
|
||||
- Once flashing is complete, you can connect with one of the MeshCore clients below.
|
||||
- Clone and open the Meck repository in Visual Studio Code.
|
||||
- Build for your target device using the environment names listed in the build variant tables above.
|
||||
|
||||
## MeshCore Clients
|
||||
|
||||
**Companion Firmware**
|
||||
|
||||
The companion firmware can be connected to via BLE.
|
||||
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 the T-Deck Pro, BLE is disabled by default at boot. Navigate to the Bluetooth home page and press Enter to enable BLE before connecting with a companion app.
|
||||
> **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
|
||||
@@ -294,7 +719,7 @@ The companion firmware can be connected to via BLE.
|
||||
|
||||
## 🛠 Hardware Compatibility
|
||||
|
||||
MeshCore is designed for devices listed in the [MeshCore Flasher](https://flasher.meshcore.co.uk)
|
||||
MeshCore is designed for devices listed in the [MeshCore Flasher](https://flasher.meshcore.co.uk). Meck specifically targets the LilyGo T-Deck Pro and LilyGo T5S3 E-Paper Pro.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -309,6 +734,8 @@ Here are some general principals you should try to adhere to:
|
||||
## Road-Map / To-Do
|
||||
|
||||
There are a number of fairly major features in the pipeline, with no particular time-frames attached yet. In partly chronological order:
|
||||
|
||||
**T-Deck Pro:**
|
||||
- [X] Companion radio: BLE
|
||||
- [X] Text entry for Public channel messages Companion BLE firmware
|
||||
- [X] View and compose all channel messages Companion BLE firmware
|
||||
@@ -318,12 +745,43 @@ There are a number of fairly major features in the pipeline, with no particular
|
||||
- [X] GPS time sync with on-device timezone setting
|
||||
- [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 for text-centric websites
|
||||
- [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, 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
|
||||
- [X] Contacts with filter cycling and long-press DM/admin
|
||||
- [X] Text reader with swipe page turns
|
||||
- [X] Web reader with virtual keyboard URL/search entry (WiFi variant)
|
||||
- [X] Settings screen with touch editing
|
||||
- [X] Serial clock sync for hardware RTC
|
||||
- [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
|
||||
|
||||
@@ -341,12 +799,15 @@ However, this firmware links against libraries with different license terms. Bec
|
||||
|---------|---------|-----------------|
|
||||
| [MeshCore](https://github.com/meshcore-dev/MeshCore) | MIT | Scott Powell / rippleradios.com |
|
||||
| [GxEPD2](https://github.com/ZinggJM/GxEPD2) | GPL-3.0 | Jean-Marc Zingg |
|
||||
| [FastEPD](https://github.com/bitbank2/FastEPD) | Apache-2.0 | Larry Bank (bitbank2) |
|
||||
| [ESP32-audioI2S](https://github.com/schreibfaul1/ESP32-audioI2S) | GPL-3.0 | schreibfaul1 (Wolle) |
|
||||
| [Adafruit GFX Library](https://github.com/adafruit/Adafruit-GFX-Library) | BSD | Adafruit |
|
||||
| [RadioLib](https://github.com/jgromes/RadioLib) | MIT | Jan Gromeš |
|
||||
| [SensorLib](https://github.com/lewisxhe/SensorLib) | MIT | Lewis He |
|
||||
| [JPEGDEC](https://github.com/bitbank2/JPEGDEC) | Apache-2.0 | Larry Bank (bitbank2) |
|
||||
| [PNGdec](https://github.com/bitbank2/PNGdec) | Apache-2.0 | Larry Bank (bitbank2) |
|
||||
| [CRC32](https://github.com/bakercp/CRC32) | MIT | Christopher Baker |
|
||||
| [base64](https://github.com/Densaugeo/base64_arduino) | MIT | densaugeo |
|
||||
| [Arduino Crypto](https://github.com/rweather/arduinolibs) | MIT | Rhys Weatherley |
|
||||
|
||||
Full license texts for each dependency are available in their respective repositories linked above.
|
||||
Full license texts for each dependency are available in their respective repositories linked above.
|
||||
@@ -59,10 +59,17 @@ All commands follow a simple pattern: `get` to read, `set` to write.
|
||||
| `get notify` | Keyboard flash notification (on/off) |
|
||||
| `get gps` | GPS status and interval |
|
||||
| `get pin` | BLE pairing PIN |
|
||||
| `get path.hash.mode` | Path hash size (0=1-byte, 1=2-byte, 2=3-byte) |
|
||||
| `get rxdelay` | Rx delay base (0=disabled) |
|
||||
| `get af` | Airtime factor |
|
||||
| `get multi.acks` | Redundant ACKs (0 or 1) |
|
||||
| `get int.thresh` | Interference threshold (0=disabled) |
|
||||
| `get gps.baud` | GPS baud rate (0=compile-time default) |
|
||||
| `get channels` | List all channels with index numbers |
|
||||
| `get presets` | List all radio presets with parameters |
|
||||
| `get pubkey` | Device public key (hex) |
|
||||
| `get firmware` | Firmware version string |
|
||||
| `clock` | Current RTC time (UTC + epoch) |
|
||||
|
||||
**4G variant only:**
|
||||
|
||||
@@ -163,6 +170,78 @@ set notify off
|
||||
set pin 123456
|
||||
```
|
||||
|
||||
#### Path Hash Mode
|
||||
|
||||
Controls the byte size of each repeater's identity stamp in forwarded flood packets. Larger hashes reduce collisions at the cost of fewer maximum hops.
|
||||
|
||||
```
|
||||
set path.hash.mode 1
|
||||
```
|
||||
|
||||
| Mode | Bytes/hop | Max hops | Notes |
|
||||
|------|-----------|----------|-------|
|
||||
| 0 | 1 | 64 | Legacy — prone to hash collisions in larger networks |
|
||||
| 1 | 2 | 32 | Recommended — effectively eliminates collisions |
|
||||
| 2 | 3 | 21 | Maximum precision, rarely needed |
|
||||
|
||||
Nodes with different modes can coexist — the mode only affects packets your node originates. The hash size is encoded in each packet's header, so receiving nodes adapt automatically.
|
||||
|
||||
### Mesh Tuning
|
||||
|
||||
These settings control how the device participates in the mesh network. They take effect immediately — no reboot required (except `gps.baud`).
|
||||
|
||||
#### Rx Delay (rxdelay)
|
||||
|
||||
Delays processing of flood packets based on signal quality. Stronger signals are processed first; weaker copies wait longer and are typically discarded as duplicates. Direct messages are always processed immediately.
|
||||
|
||||
```
|
||||
set rxdelay 3
|
||||
```
|
||||
|
||||
Range: 0–20 (0 = disabled, default). Higher values create larger timing differences between strong and weak signals. Values below 1.0 have no practical effect. See the [MeshSydney wiki](https://meshsydney.com/wiki) for detailed tuning profiles.
|
||||
|
||||
#### Airtime Factor (af)
|
||||
|
||||
Adjusts how long certain internal timing windows remain open. Does not change the LoRa radio parameters (SF, BW, CR) — those remain as configured.
|
||||
|
||||
```
|
||||
set af 1.0
|
||||
```
|
||||
|
||||
Range: 0–9 (default: 1.0). Keep this value consistent across nodes in your mesh for best coherence.
|
||||
|
||||
#### Multiple Acknowledgments (multi.acks)
|
||||
|
||||
Sends redundant ACK packets for direct messages. When enabled, two ACKs are sent (a multi-ack first, then the standard ACK), improving delivery confirmation reliability.
|
||||
|
||||
```
|
||||
set multi.acks 1
|
||||
```
|
||||
|
||||
Values: 0 (single ACK) or 1 (redundant ACKs, default).
|
||||
|
||||
#### Interference Threshold (int.thresh)
|
||||
|
||||
Enables channel activity scanning before transmitting. Not recommended unless your device is in a high RF interference environment — specifically where the noise floor is low but shows significant fluctuations indicating interference. Enabling this adds approximately 4 seconds of receive delay per packet.
|
||||
|
||||
```
|
||||
set int.thresh 14
|
||||
set int.thresh 0
|
||||
```
|
||||
|
||||
Values: 0 (disabled, default) or 14+ (14 is the typical setting). Values between 1–13 are not functional and will be rejected.
|
||||
|
||||
#### GPS Baud Rate (gps.baud)
|
||||
|
||||
Override the GPS serial baud rate. The default (0) uses the compile-time value of 38400. **Requires a reboot to take effect** — the GPS serial port is only configured at startup.
|
||||
|
||||
```
|
||||
set gps.baud 9600
|
||||
set gps.baud 0
|
||||
```
|
||||
|
||||
Valid rates: 0 (default), 4800, 9600, 19200, 38400, 57600, 115200.
|
||||
|
||||
### Channel Management
|
||||
|
||||
#### List Channels
|
||||
@@ -216,6 +295,68 @@ To clear a custom APN and revert to auto-detection on next boot:
|
||||
set apn
|
||||
```
|
||||
|
||||
### Clock Sync
|
||||
|
||||
Set the device's real-time clock from a Unix timestamp. This is especially important for the T5S3 E-Paper Pro which has no GPS to auto-set the clock. These are standalone commands (not `get`/`set` prefixed) — matching the same `clock sync` command used on MeshCore repeaters.
|
||||
|
||||
#### View Current Time
|
||||
|
||||
```
|
||||
clock
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
> 2026-03-13 04:22:15 UTC (epoch: 1773554535)
|
||||
```
|
||||
|
||||
If the clock has never been set:
|
||||
|
||||
```
|
||||
> not set (epoch: 0)
|
||||
```
|
||||
|
||||
#### Sync Clock from Serial
|
||||
|
||||
```
|
||||
clock sync 1773554535
|
||||
```
|
||||
|
||||
The value must be a Unix epoch timestamp in the 2024–2036 range.
|
||||
|
||||
**Quick one-liner from your terminal (macOS / Linux / WSL):**
|
||||
|
||||
```
|
||||
echo "clock sync $(date +%s)" > /dev/ttyACM0
|
||||
```
|
||||
|
||||
Or paste directly into the Arduino IDE Serial Monitor:
|
||||
|
||||
```
|
||||
clock sync 1773554535
|
||||
```
|
||||
|
||||
**Tip:** On macOS/Linux, run `date +%s` to get the current epoch. On Windows PowerShell: `[int](Get-Date -UFormat %s)`.
|
||||
|
||||
#### Boot-Time Auto-Sync (T5S3)
|
||||
|
||||
When the T5S3 boots with no valid RTC time and detects a USB serial host is connected, it sends a `MECK_CLOCK_REQ` handshake over serial. If you're using PlatformIO's serial monitor (`pio device monitor`), the built-in `clock_sync` monitor filter responds automatically with the host computer's current time — no user action required. The sync appears transparently in the boot log:
|
||||
|
||||
```
|
||||
MECK_CLOCK_REQ
|
||||
(Waiting 3s for clock sync from host...)
|
||||
> Clock synced to 1773554535
|
||||
```
|
||||
|
||||
If no USB host is connected (e.g. running on battery), the sync window is skipped entirely with no boot delay.
|
||||
|
||||
**Manual fallback:** If you're using a serial terminal that doesn't have the filter (e.g. `screen`, PuTTY), you can paste a `clock sync` command during the 3-second window, or any time after boot:
|
||||
|
||||
```
|
||||
clock sync $(date +%s)
|
||||
```
|
||||
|
||||
### System Commands
|
||||
|
||||
| Command | Description |
|
||||
|
||||
@@ -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
|
||||
38
boards/t5s3-epaper-pro.json
Normal file
38
boards/t5s3-epaper-pro.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"memory_type": "qio_opi",
|
||||
"partitions": "default_16MB.csv"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=0",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "esp32s3"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "LilyGo T5S3 E-Paper Pro",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://lilygo.cc/products/t5-e-paper-s3-pro",
|
||||
"vendor": "LILYGO"
|
||||
}
|
||||
@@ -252,6 +252,50 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
|
||||
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
|
||||
|
||||
// v1.1+ Meck fields — may not exist in older prefs files
|
||||
if (file.read((uint8_t *)&_prefs.gps_baudrate, sizeof(_prefs.gps_baudrate)) != sizeof(_prefs.gps_baudrate)) {
|
||||
_prefs.gps_baudrate = 0; // default: use compile-time GPS_BAUDRATE
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold)) != sizeof(_prefs.interference_threshold)) {
|
||||
_prefs.interference_threshold = 0; // default: disabled
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)) != sizeof(_prefs.dark_mode)) {
|
||||
_prefs.dark_mode = 0; // default: light mode
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)) != sizeof(_prefs.portrait_mode)) {
|
||||
_prefs.portrait_mode = 0; // default: landscape
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)) != sizeof(_prefs.auto_lock_minutes)) {
|
||||
_prefs.auto_lock_minutes = 0; // default: disabled
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)) != sizeof(_prefs.hint_shown)) {
|
||||
_prefs.hint_shown = 0; // default: show boot hint
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)) != sizeof(_prefs.large_font)) {
|
||||
_prefs.large_font = 0; // default: tiny font
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)) != sizeof(_prefs.tx_fail_reset_threshold)) {
|
||||
_prefs.tx_fail_reset_threshold = 3; // default: 3
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)) != sizeof(_prefs.rx_fail_reboot_threshold)) {
|
||||
_prefs.rx_fail_reboot_threshold = 3; // default: 3
|
||||
}
|
||||
|
||||
// Clamp to valid ranges
|
||||
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
|
||||
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
|
||||
if (_prefs.hint_shown > 1) _prefs.hint_shown = 0;
|
||||
if (_prefs.large_font > 1) _prefs.large_font = 0;
|
||||
if (_prefs.tx_fail_reset_threshold > 10) _prefs.tx_fail_reset_threshold = 3;
|
||||
if (_prefs.rx_fail_reboot_threshold > 10) _prefs.rx_fail_reboot_threshold = 3;
|
||||
// auto_lock_minutes: only accept known options (0, 2, 5, 10, 15, 30)
|
||||
{
|
||||
uint8_t alm = _prefs.auto_lock_minutes;
|
||||
if (alm != 0 && alm != 2 && alm != 5 && alm != 10 && alm != 15 && alm != 30) {
|
||||
_prefs.auto_lock_minutes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
@@ -291,6 +335,15 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)); // 90
|
||||
file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 91
|
||||
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 92
|
||||
file.write((uint8_t *)&_prefs.gps_baudrate, sizeof(_prefs.gps_baudrate)); // 93
|
||||
file.write((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold)); // 97
|
||||
file.write((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)); // 98
|
||||
file.write((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)); // 99
|
||||
file.write((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)); // 100
|
||||
file.write((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)); // 101
|
||||
file.write((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)); // 102
|
||||
file.write((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)); // 103
|
||||
file.write((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)); // 104
|
||||
|
||||
file.close();
|
||||
}
|
||||
@@ -443,6 +496,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;};
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,10 @@
|
||||
#include <Mesh.h>
|
||||
#include "RadioPresets.h" // Shared radio presets (serial CLI + settings screen)
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include "target.h" // for board.setBacklight() CLI command
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "ModemManager.h" // Serial CLI modem commands
|
||||
#endif
|
||||
@@ -257,7 +261,17 @@ float MyMesh::getAirtimeBudgetFactor() const {
|
||||
}
|
||||
|
||||
int MyMesh::getInterferenceThreshold() const {
|
||||
return 0; // disabled for now, until currentRSSI() problem is resolved
|
||||
return _prefs.interference_threshold;
|
||||
}
|
||||
|
||||
uint8_t MyMesh::getTxFailResetThreshold() const {
|
||||
return _prefs.tx_fail_reset_threshold;
|
||||
}
|
||||
uint8_t MyMesh::getRxFailRebootThreshold() const {
|
||||
return _prefs.rx_fail_reboot_threshold;
|
||||
}
|
||||
void MyMesh::onRxUnrecoverable() {
|
||||
board.reboot();
|
||||
}
|
||||
|
||||
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
|
||||
@@ -376,6 +390,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);
|
||||
}
|
||||
@@ -423,6 +438,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);
|
||||
@@ -489,7 +508,24 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
||||
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
|
||||
if (should_display && _ui) {
|
||||
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
|
||||
// For signed messages (room server posts): the extra bytes contain the
|
||||
// original poster's pub_key prefix. Look up their name and format as
|
||||
// "PosterName: message" so the UI shows who actually wrote it.
|
||||
if (txt_type == TXT_TYPE_SIGNED_PLAIN && extra && extra_len >= 4) {
|
||||
ContactInfo* poster = lookupContactByPubKey(extra, extra_len);
|
||||
if (poster) {
|
||||
char formatted[MAX_PACKET_PAYLOAD];
|
||||
snprintf(formatted, sizeof(formatted), "%s: %s", poster->name, text);
|
||||
_ui->newMsg(path_len, from.name, formatted, offline_queue_len, msg_path, pkt->_snr);
|
||||
} else {
|
||||
// Poster not in contacts — show raw text (no name prefix)
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
}
|
||||
} else {
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
}
|
||||
|
||||
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
|
||||
}
|
||||
#endif
|
||||
@@ -534,12 +570,12 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
|
||||
recipient.name, delay_millis, _prefs.path_hash_mode, _prefs.path_hash_mode + 1);
|
||||
// TODO: dynamic send_scope, depending on recipient and current 'home' Region
|
||||
if (send_scope.isNull()) {
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
} else {
|
||||
uint16_t codes[2];
|
||||
codes[0] = send_scope.calcTransportCode(pkt);
|
||||
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
|
||||
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, codes, delay_millis, getPathHashSize());
|
||||
}
|
||||
}
|
||||
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
@@ -556,12 +592,12 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
|
||||
|
||||
// TODO: have per-channel send_scope
|
||||
if (send_scope.isNull()) {
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
} else {
|
||||
uint16_t codes[2];
|
||||
codes[0] = send_scope.calcTransportCode(pkt);
|
||||
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
|
||||
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, codes, delay_millis, getPathHashSize());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,6 +764,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);
|
||||
|
||||
@@ -1133,6 +1176,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
_prefs.airtime_factor = 1.0; // one half
|
||||
_prefs.multi_acks = 1; // redundant ACKs on by default
|
||||
strcpy(_prefs.node_name, "NONAME");
|
||||
_prefs.freq = LORA_FREQ;
|
||||
_prefs.sf = LORA_SF;
|
||||
@@ -1183,6 +1227,30 @@ void MyMesh::begin(bool has_display) {
|
||||
_prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1
|
||||
_prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours
|
||||
_prefs.utc_offset_hours = constrain(_prefs.utc_offset_hours, -12, 14); // Valid timezone range
|
||||
// gps_baudrate: 0 means use compile-time default; validate known rates
|
||||
if (_prefs.gps_baudrate != 0 && _prefs.gps_baudrate != 4800 &&
|
||||
_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, 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) {
|
||||
@@ -1432,7 +1500,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
if (pkt) {
|
||||
if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop)
|
||||
unsigned long delay_millis = 0;
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
} else {
|
||||
sendZeroHop(pkt);
|
||||
}
|
||||
@@ -1555,6 +1623,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 {
|
||||
@@ -2130,20 +2205,26 @@ void MyMesh::enterCLIRescue() {
|
||||
|
||||
void MyMesh::checkCLIRescueCmd() {
|
||||
int len = strlen(cli_command);
|
||||
bool line_complete = false;
|
||||
while (Serial.available() && len < sizeof(cli_command)-1) {
|
||||
char c = Serial.read();
|
||||
if (c != '\n') {
|
||||
cli_command[len++] = c;
|
||||
cli_command[len] = 0;
|
||||
if (c == '\r' || c == '\n') {
|
||||
if (len > 0) {
|
||||
line_complete = true;
|
||||
Serial.println(); // echo newline
|
||||
}
|
||||
break; // stop reading — remaining LF (from CR+LF) is consumed next loop
|
||||
}
|
||||
cli_command[len++] = c;
|
||||
cli_command[len] = 0;
|
||||
Serial.print(c); // echo
|
||||
}
|
||||
if (len == sizeof(cli_command)-1) { // command buffer full
|
||||
cli_command[sizeof(cli_command)-1] = '\r';
|
||||
if (len == sizeof(cli_command)-1) { // buffer full — force processing
|
||||
line_complete = true;
|
||||
}
|
||||
|
||||
if (len > 0 && cli_command[len - 1] == '\r') { // received complete line
|
||||
cli_command[len - 1] = 0; // replace newline with C string null terminator
|
||||
if (line_complete && len > 0) {
|
||||
cli_command[len] = 0; // ensure null terminated
|
||||
|
||||
// =====================================================================
|
||||
// GET commands — read settings
|
||||
@@ -2174,6 +2255,25 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
_prefs.gps_enabled ? "on" : "off", _prefs.gps_interval);
|
||||
} else if (strcmp(key, "pin") == 0) {
|
||||
Serial.printf(" > %06d\n", _prefs.ble_pin);
|
||||
|
||||
// --- Mesh tuning parameters ---
|
||||
} else if (strcmp(key, "rxdelay") == 0) {
|
||||
Serial.printf(" > %.1f\n", _prefs.rx_delay_base);
|
||||
} else if (strcmp(key, "af") == 0) {
|
||||
Serial.printf(" > %.1f\n", _prefs.airtime_factor);
|
||||
} else if (strcmp(key, "multi.acks") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.multi_acks);
|
||||
} else if (strcmp(key, "int.thresh") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.interference_threshold);
|
||||
} else if (strcmp(key, "tx.fail.threshold") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.tx_fail_reset_threshold);
|
||||
} else if (strcmp(key, "rx.fail.threshold") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.rx_fail_reboot_threshold);
|
||||
} else if (strcmp(key, "gps.baud") == 0) {
|
||||
uint32_t effective = _prefs.gps_baudrate ? _prefs.gps_baudrate : GPS_BAUDRATE;
|
||||
Serial.printf(" > %lu (effective: %lu)\n",
|
||||
(unsigned long)_prefs.gps_baudrate, (unsigned long)effective);
|
||||
|
||||
} else if (strcmp(key, "radio") == 0) {
|
||||
Serial.printf(" > freq=%.3f bw=%.1f sf=%d cr=%d tx=%d\n",
|
||||
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
|
||||
@@ -2225,6 +2325,16 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.printf(" gps: %s (interval: %ds)\n",
|
||||
_prefs.gps_enabled ? "on" : "off", _prefs.gps_interval);
|
||||
Serial.printf(" pin: %06d\n", _prefs.ble_pin);
|
||||
Serial.printf(" rxdelay: %.1f\n", _prefs.rx_delay_base);
|
||||
Serial.printf(" af: %.1f\n", _prefs.airtime_factor);
|
||||
Serial.printf(" multi.acks: %d\n", _prefs.multi_acks);
|
||||
Serial.printf(" int.thresh: %d\n", _prefs.interference_threshold);
|
||||
Serial.printf(" tx.fail: %d\n", _prefs.tx_fail_reset_threshold);
|
||||
Serial.printf(" rx.fail: %d\n", _prefs.rx_fail_reboot_threshold);
|
||||
{
|
||||
uint32_t eff_baud = _prefs.gps_baudrate ? _prefs.gps_baudrate : GPS_BAUDRATE;
|
||||
Serial.printf(" gps.baud: %lu\n", (unsigned long)eff_baud);
|
||||
}
|
||||
#ifdef HAS_4G_MODEM
|
||||
Serial.printf(" modem: %s\n", ModemManager::loadEnabledConfig() ? "on" : "off");
|
||||
Serial.printf(" apn: %s\n", modemManager.getAPN());
|
||||
@@ -2245,6 +2355,14 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
char hex[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(hex, self_id.pub_key, PUB_KEY_SIZE);
|
||||
Serial.printf(" pubkey: %s\n", hex);
|
||||
{
|
||||
uint32_t clk = getRTCClock()->getCurrentTime();
|
||||
if (clk > 1704067200UL) {
|
||||
Serial.printf(" clock: %lu (valid)\n", (unsigned long)clk);
|
||||
} else {
|
||||
Serial.printf(" clock: not set\n");
|
||||
}
|
||||
}
|
||||
// List channels
|
||||
Serial.println(" channels:");
|
||||
bool chFound = false;
|
||||
@@ -2558,10 +2676,160 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.println(" > modem disabled");
|
||||
#endif
|
||||
|
||||
// --- Mesh tuning parameters ---
|
||||
} else if (memcmp(config, "rxdelay ", 8) == 0) {
|
||||
float val = atof(&config[8]);
|
||||
if (val >= 0.0f && val <= 20.0f) {
|
||||
_prefs.rx_delay_base = val;
|
||||
savePrefs();
|
||||
Serial.printf(" > rxdelay = %.1f\n", _prefs.rx_delay_base);
|
||||
} else {
|
||||
Serial.println(" Error: rxdelay out of range (0-20)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "af ", 3) == 0) {
|
||||
float val = atof(&config[3]);
|
||||
if (val >= 0.0f && val <= 9.0f) {
|
||||
_prefs.airtime_factor = val;
|
||||
savePrefs();
|
||||
Serial.printf(" > af = %.1f\n", _prefs.airtime_factor);
|
||||
} else {
|
||||
Serial.println(" Error: af out of range (0-9)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "multi.acks ", 11) == 0) {
|
||||
int val = atoi(&config[11]);
|
||||
if (val == 0 || val == 1) {
|
||||
_prefs.multi_acks = (uint8_t)val;
|
||||
savePrefs();
|
||||
Serial.printf(" > multi.acks = %d\n", _prefs.multi_acks);
|
||||
} else {
|
||||
Serial.println(" Error: use 0 or 1");
|
||||
}
|
||||
|
||||
// Interference threshold — not recommended unless the device is in a high
|
||||
// RF interference environment (low noise floor with significant fluctuations).
|
||||
// Enabling adds ~4s receive delay per packet for channel activity scanning.
|
||||
} else if (memcmp(config, "int.thresh ", 11) == 0) {
|
||||
int val = atoi(&config[11]);
|
||||
if (val == 0) {
|
||||
_prefs.interference_threshold = 0;
|
||||
savePrefs();
|
||||
Serial.println(" > int.thresh = 0 (disabled)");
|
||||
} else if (val >= 14 && val <= 255) {
|
||||
_prefs.interference_threshold = (uint8_t)val;
|
||||
savePrefs();
|
||||
Serial.printf(" > int.thresh = %d (enabled — adds ~4s rx delay)\n",
|
||||
_prefs.interference_threshold);
|
||||
Serial.println(" Note: only recommended for high RF interference environments");
|
||||
} else {
|
||||
Serial.println(" Error: use 0 (disabled) or 14+ (typical: 14)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "tx.fail.threshold ", 18) == 0) {
|
||||
int val = atoi(&config[18]);
|
||||
if (val < 0) val = 0;
|
||||
if (val > 10) val = 10;
|
||||
_prefs.tx_fail_reset_threshold = (uint8_t)val;
|
||||
savePrefs();
|
||||
if (val == 0) {
|
||||
Serial.println(" > tx fail reset disabled");
|
||||
} else {
|
||||
Serial.printf(" > tx fail reset after %d failures\n", val);
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "rx.fail.threshold ", 18) == 0) {
|
||||
int val = atoi(&config[18]);
|
||||
if (val < 0) val = 0;
|
||||
if (val > 10) val = 10;
|
||||
_prefs.rx_fail_reboot_threshold = (uint8_t)val;
|
||||
savePrefs();
|
||||
if (val == 0) {
|
||||
Serial.println(" > rx fail reboot disabled");
|
||||
} else {
|
||||
Serial.printf(" > reboot after %d rx recovery failures\n", val);
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "gps.baud ", 9) == 0) {
|
||||
uint32_t val = (uint32_t)atol(&config[9]);
|
||||
if (val == 0 || val == 4800 || val == 9600 || val == 19200 ||
|
||||
val == 38400 || val == 57600 || val == 115200) {
|
||||
_prefs.gps_baudrate = val;
|
||||
savePrefs();
|
||||
uint32_t effective = val ? val : GPS_BAUDRATE;
|
||||
Serial.printf(" > gps.baud = %lu (effective: %lu, reboot to apply)\n",
|
||||
(unsigned long)val, (unsigned long)effective);
|
||||
} else {
|
||||
Serial.println(" Error: use 0 (default), 4800, 9600, 19200, 38400, 57600, or 115200");
|
||||
}
|
||||
|
||||
// Backlight control (T5S3 E-Paper Pro only)
|
||||
} else if (memcmp(config, "backlight ", 10) == 0) {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const char* val = &config[10];
|
||||
if (strcmp(val, "on") == 0) {
|
||||
board.setBacklight(true);
|
||||
Serial.println(" > backlight ON");
|
||||
} else if (strcmp(val, "off") == 0) {
|
||||
board.setBacklight(false);
|
||||
Serial.println(" > backlight OFF");
|
||||
} else {
|
||||
int brightness = atoi(val);
|
||||
if (brightness >= 0 && brightness <= 255) {
|
||||
board.setBacklightBrightness((uint8_t)brightness);
|
||||
board.setBacklight(brightness > 0);
|
||||
Serial.printf(" > backlight brightness = %d\n", brightness);
|
||||
} else {
|
||||
Serial.println(" Error: use 'on', 'off', or 0-255");
|
||||
}
|
||||
}
|
||||
#else
|
||||
Serial.println(" Error: backlight not available on this device");
|
||||
#endif
|
||||
|
||||
} else {
|
||||
Serial.printf(" Error: unknown setting '%s' (try 'help')\n", config);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// CLOCK commands (standalone — matches repeater admin convention)
|
||||
// =====================================================================
|
||||
} else if (memcmp(cli_command, "clock sync ", 11) == 0) {
|
||||
uint32_t epoch = (uint32_t)strtoul(&cli_command[11], nullptr, 10);
|
||||
if (epoch > 1704067200UL && epoch < 2082758400UL) {
|
||||
getRTCClock()->setCurrentTime(epoch);
|
||||
Serial.printf(" > clock synced to %lu\n", (unsigned long)epoch);
|
||||
} else {
|
||||
Serial.println(" Error: invalid epoch (must be 2024-2036 range)");
|
||||
Serial.println(" Hint: on macOS/Linux run: date +%s");
|
||||
}
|
||||
} else if (strcmp(cli_command, "clock sync") == 0) {
|
||||
// Bare "clock sync" without a value — show usage
|
||||
Serial.println(" Usage: clock sync <unix_epoch>");
|
||||
Serial.println(" Hint: clock sync $(date +%s)");
|
||||
} else if (strcmp(cli_command, "clock") == 0) {
|
||||
uint32_t t = getRTCClock()->getCurrentTime();
|
||||
if (t > 1704067200UL) {
|
||||
// Break epoch into human-readable UTC
|
||||
uint32_t ep = t;
|
||||
int s = ep % 60; ep /= 60;
|
||||
int mi = ep % 60; ep /= 60;
|
||||
int h = ep % 24; ep /= 24;
|
||||
int yr = 1970;
|
||||
while (true) { int d = ((yr%4==0&&yr%100!=0)||yr%400==0)?366:365; if(ep<(uint32_t)d) break; ep-=d; yr++; }
|
||||
int mo = 1;
|
||||
while (true) {
|
||||
static const uint8_t dm[]={31,28,31,30,31,30,31,31,30,31,30,31};
|
||||
int d = (mo==2&&((yr%4==0&&yr%100!=0)||yr%400==0))?29:dm[mo-1];
|
||||
if(ep<(uint32_t)d) break; ep-=d; mo++;
|
||||
}
|
||||
int dy = ep + 1;
|
||||
Serial.printf(" > %04d-%02d-%02d %02d:%02d:%02d UTC (epoch: %lu)\n",
|
||||
yr, mo, dy, h, mi, s, (unsigned long)t);
|
||||
} else {
|
||||
Serial.printf(" > not set (epoch: %lu)\n", (unsigned long)t);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// HELP command
|
||||
// =====================================================================
|
||||
@@ -2574,6 +2842,20 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.println(" name, freq, bw, sf, cr, tx, utc, notify, pin");
|
||||
Serial.println(" path.hash.mode Path hash size (0=1B, 1=2B, 2=3B)");
|
||||
Serial.println("");
|
||||
Serial.println(" Mesh tuning:");
|
||||
Serial.println(" rxdelay <0-20> Rx delay base (0=disabled)");
|
||||
Serial.println(" af <0-9> Airtime factor");
|
||||
Serial.println(" multi.acks <0|1> Redundant ACKs (default: 1)");
|
||||
Serial.println(" int.thresh <0|14+> Interference threshold dB (0=off, 14=typical)");
|
||||
Serial.println(" tx.fail.threshold <0-10> TX fail radio reset (0=off, default 3)");
|
||||
Serial.println(" rx.fail.threshold <0-10> RX stuck reboot (0=off, default 3)");
|
||||
Serial.println(" gps.baud <rate> GPS baud (0=default, reboot to apply)");
|
||||
Serial.println("");
|
||||
Serial.println(" Clock:");
|
||||
Serial.println(" clock Show current RTC time (UTC)");
|
||||
Serial.println(" clock sync <epoch> Set RTC from Unix timestamp");
|
||||
Serial.println(" Hint: clock sync $(date +%s)");
|
||||
Serial.println("");
|
||||
Serial.println(" Compound commands:");
|
||||
Serial.println(" get all Dump all settings");
|
||||
Serial.println(" get radio Show all radio params");
|
||||
@@ -2596,6 +2878,11 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.println(" erase Format filesystem");
|
||||
Serial.println(" reboot Restart device");
|
||||
Serial.println(" ls / cat / rm File operations");
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
Serial.println("");
|
||||
Serial.println(" Display:");
|
||||
Serial.println(" set backlight on/off/0-255 Control front-light");
|
||||
#endif
|
||||
|
||||
// =====================================================================
|
||||
// Existing system commands (unchanged)
|
||||
@@ -2786,10 +3073,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 "7 March 2026"
|
||||
#define FIRMWARE_BUILD_DATE "27 March 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.9.9"
|
||||
#define FIRMWARE_VERSION "Meck v1.5"
|
||||
#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);
|
||||
@@ -136,6 +143,9 @@ public:
|
||||
protected:
|
||||
float getAirtimeBudgetFactor() const override;
|
||||
int getInterferenceThreshold() const override;
|
||||
uint8_t getTxFailResetThreshold() const override;
|
||||
uint8_t getRxFailRebootThreshold() const override;
|
||||
void onRxUnrecoverable() override;
|
||||
int calcRxDelay(float score, uint32_t air_time) const override;
|
||||
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
|
||||
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
|
||||
@@ -143,6 +153,7 @@ protected:
|
||||
uint8_t getAutoAddMaxHops() const override;
|
||||
bool filterRecvFloodPacket(mesh::Packet* packet) override;
|
||||
|
||||
uint8_t getPathHashSize() const override { return _prefs.path_hash_mode + 1; }
|
||||
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
|
||||
@@ -262,7 +273,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
|
||||
|
||||
@@ -33,4 +33,47 @@ struct NodePrefs { // persisted to file
|
||||
uint8_t ringtone_enabled; // Ringtone on incoming call (0=off, 1=on) — 4G only
|
||||
uint8_t path_hash_mode; // 0=1-byte (legacy), 1=2-byte, 2=3-byte path hashes
|
||||
uint8_t autoadd_max_hops; // 0=no limit, N=up to N-1 hops (max 64)
|
||||
uint32_t gps_baudrate; // GPS baud rate (0 = use compile-time GPS_BAUDRATE default)
|
||||
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)
|
||||
uint8_t large_font; // 0=tiny (built-in 6x8), 1=larger (FreeSans9pt) — T-Deck Pro only
|
||||
uint8_t tx_fail_reset_threshold; // 0=disabled, 1-10, default 3
|
||||
uint8_t rx_fail_reboot_threshold; // 0=disabled, 1-10, default 3
|
||||
|
||||
// --- Font helpers (inline, no overhead) ---
|
||||
// Returns the DisplayDriver text-size index for "small/body" text.
|
||||
// T-Deck Pro: 0 = built-in 6×8, 1 = FreeSans9pt.
|
||||
// T5S3: both 0 and 1 are 12pt fonts (regular vs bold) with identical line
|
||||
// height, so large_font has no layout effect there.
|
||||
inline uint8_t smallTextSize() const {
|
||||
return large_font ? 1 : 0;
|
||||
}
|
||||
|
||||
// Returns the virtual-coordinate line height matching smallTextSize().
|
||||
// T-Deck Pro size 0 → 9 (6×8 + 1px gap), size 1 → 11 (9pt ascent+descent).
|
||||
// T5S3 size 0/1 → same 12pt height → always 9 in virtual coords.
|
||||
inline int smallLineH() const {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
return 9;
|
||||
#else
|
||||
return large_font ? 11 : 9;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Returns the Y offset for selection highlight fillRect (T-Deck Pro only).
|
||||
// Size 0 (built-in font): cursor positions at top-left, +5 offset in
|
||||
// setCursor places text below → fillRect at y+5 aligns with text.
|
||||
// Size 1 (FreeSans9pt): cursor positions at baseline, ascenders render
|
||||
// upward → fillRect must start above baseline to cover ascenders.
|
||||
// T5S3: always 0 (both sizes use baseline fonts with highlight at y).
|
||||
inline int smallHighlightOff() const {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
return 0;
|
||||
#else
|
||||
return large_font ? -2 : 5;
|
||||
#endif
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
1084
examples/companion_radio/ui-new/Alarmscreen.h
Normal file
1084
examples/companion_radio/ui-new/Alarmscreen.h
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,8 @@
|
||||
// JPEG decoder for cover art — JPEGDEC by bitbank2
|
||||
#include <JPEGDEC.h>
|
||||
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
|
||||
@@ -151,6 +153,7 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Audio* _audio;
|
||||
Mode _mode;
|
||||
bool _sdReady;
|
||||
@@ -1193,10 +1196,10 @@ private:
|
||||
}
|
||||
|
||||
// Switch to tiny font for file list (6x8 built-in)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs ? _prefs->smallTextSize() : 0);
|
||||
|
||||
// Calculate visible items — tiny font uses ~8 virtual units per line
|
||||
int itemHeight = 8;
|
||||
// Calculate visible items
|
||||
int itemHeight = (_prefs ? _prefs->smallLineH() : 9) - 1;
|
||||
int listTop = 13;
|
||||
int listBottom = display.height() - 14; // Reserve footer space
|
||||
int visibleItems = (listBottom - listTop) / itemHeight;
|
||||
@@ -1208,7 +1211,7 @@ private:
|
||||
_scrollOffset = _selectedFile - visibleItems + 1;
|
||||
}
|
||||
|
||||
// Approx chars that fit in tiny font (~36 on 128 virtual width)
|
||||
// Approx chars for suffix/type tag sizing (still needed for type tag assembly)
|
||||
const int charsPerLine = 36;
|
||||
|
||||
// Draw file list
|
||||
@@ -1218,9 +1221,7 @@ private:
|
||||
|
||||
if (fileIdx == _selectedFile) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
// setCursor adds +5 to y internally, but fillRect does not.
|
||||
// Offset fillRect by +5 to align highlight bar with text.
|
||||
display.fillRect(0, y + 5, display.width(), itemHeight - 1);
|
||||
display.fillRect(0, y + (_prefs ? _prefs->smallHighlightOff() : 5), display.width(), itemHeight - 1);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -1231,29 +1232,15 @@ private:
|
||||
char fullLine[96];
|
||||
|
||||
if (fe.isDir) {
|
||||
// Directory entry: show as "/ FolderName" or just ".."
|
||||
if (fe.name == "..") {
|
||||
snprintf(fullLine, sizeof(fullLine), ".. (up)");
|
||||
} else {
|
||||
snprintf(fullLine, sizeof(fullLine), "/%s", fe.name.c_str());
|
||||
// Truncate if needed
|
||||
if ((int)strlen(fullLine) > charsPerLine - 1) {
|
||||
fullLine[charsPerLine - 4] = '.';
|
||||
fullLine[charsPerLine - 3] = '.';
|
||||
fullLine[charsPerLine - 2] = '.';
|
||||
fullLine[charsPerLine - 1] = '\0';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Audio file: "Title - Author [TYPE]"
|
||||
char lineBuf[80];
|
||||
|
||||
// Reserve space for type tag and bookmark indicator
|
||||
int suffixLen = fe.fileType.length() + 3; // " [M4B]" or " [MP3]"
|
||||
int bmkLen = fe.hasBookmark ? 2 : 0; // " >"
|
||||
int availChars = charsPerLine - suffixLen - bmkLen;
|
||||
if (availChars < 10) availChars = 10;
|
||||
|
||||
if (fe.displayAuthor.length() > 0) {
|
||||
snprintf(lineBuf, sizeof(lineBuf), "%s - %s",
|
||||
fe.displayTitle.c_str(), fe.displayAuthor.c_str());
|
||||
@@ -1261,24 +1248,13 @@ private:
|
||||
snprintf(lineBuf, sizeof(lineBuf), "%s", fe.displayTitle.c_str());
|
||||
}
|
||||
|
||||
// Truncate with ellipsis if needed
|
||||
if ((int)strlen(lineBuf) > availChars) {
|
||||
if (availChars > 3) {
|
||||
lineBuf[availChars - 3] = '.';
|
||||
lineBuf[availChars - 2] = '.';
|
||||
lineBuf[availChars - 1] = '.';
|
||||
lineBuf[availChars] = '\0';
|
||||
} else {
|
||||
lineBuf[availChars] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// Append file type tag
|
||||
snprintf(fullLine, sizeof(fullLine), "%s [%s]", lineBuf, fe.fileType.c_str());
|
||||
}
|
||||
|
||||
display.setCursor(2, y);
|
||||
display.print(fullLine);
|
||||
// Pixel-aware ellipsis — reserve space for bookmark indicator
|
||||
int reserveRight = (!fe.isDir && fe.hasBookmark) ? 10 : 2;
|
||||
display.drawTextEllipsized(2, y, display.width() - reserveRight, fullLine);
|
||||
|
||||
// Bookmark indicator (right-aligned, files only)
|
||||
if (!fe.isDir && fe.hasBookmark) {
|
||||
@@ -1464,8 +1440,8 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
AudiobookPlayerScreen(UITask* task, Audio* audio)
|
||||
: _task(task), _audio(audio), _mode(FILE_LIST),
|
||||
AudiobookPlayerScreen(UITask* task, Audio* audio, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _audio(audio), _mode(FILE_LIST),
|
||||
_sdReady(false), _i2sInitialized(false), _dacPowered(false),
|
||||
_displayRef(nullptr),
|
||||
_selectedFile(0), _scrollOffset(0),
|
||||
|
||||
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,11 +552,201 @@ public:
|
||||
|
||||
// Divider line
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === DM Inbox mode: show list of contacts with DMs ===
|
||||
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
|
||||
#define DM_INBOX_MAX 20
|
||||
struct DMInboxEntry {
|
||||
uint32_t hash;
|
||||
char name[32];
|
||||
int msgCount;
|
||||
int unreadCount;
|
||||
uint32_t newestTs;
|
||||
};
|
||||
DMInboxEntry inbox[DM_INBOX_MAX];
|
||||
int inboxCount = 0;
|
||||
|
||||
// Scan all DMs and group by peer hash
|
||||
for (int i = 0; i < _msgCount && i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
|
||||
if (_messages[idx].dm_peer_hash == 0) continue;
|
||||
|
||||
uint32_t h = _messages[idx].dm_peer_hash;
|
||||
|
||||
// Find existing entry by hash
|
||||
int found = -1;
|
||||
for (int j = 0; j < inboxCount; j++) {
|
||||
if (inbox[j].hash == h) { found = j; break; }
|
||||
}
|
||||
if (found < 0 && inboxCount < DM_INBOX_MAX) {
|
||||
found = inboxCount++;
|
||||
inbox[found].hash = h;
|
||||
inbox[found].name[0] = '\0';
|
||||
inbox[found].msgCount = 0;
|
||||
inbox[found].unreadCount = 0;
|
||||
inbox[found].newestTs = 0;
|
||||
|
||||
// Look up name from contacts by matching peer hash
|
||||
uint32_t numC = the_mesh.getNumContacts();
|
||||
ContactInfo ci;
|
||||
for (uint32_t c = 0; c < numC; c++) {
|
||||
if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == h) {
|
||||
strncpy(inbox[found].name, ci.name, 31);
|
||||
inbox[found].name[31] = '\0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback: extract from text if contact not found
|
||||
if (inbox[found].name[0] == '\0') {
|
||||
extractSenderName(_messages[idx].text, inbox[found].name, sizeof(inbox[found].name));
|
||||
}
|
||||
}
|
||||
if (found >= 0) {
|
||||
inbox[found].msgCount++;
|
||||
if (_messages[idx].timestamp > inbox[found].newestTs)
|
||||
inbox[found].newestTs = _messages[idx].timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Look up unread counts from per-contact array
|
||||
if (_dmUnreadPtr) {
|
||||
for (int e = 0; e < inboxCount; e++) {
|
||||
uint32_t numC = the_mesh.getNumContacts();
|
||||
ContactInfo ci;
|
||||
for (uint32_t c = 0; c < numC; c++) {
|
||||
if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == inbox[e].hash) {
|
||||
inbox[e].unreadCount = _dmUnreadPtr[c];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by newest timestamp descending (insertion sort)
|
||||
for (int i = 1; i < inboxCount; i++) {
|
||||
DMInboxEntry tmp2 = inbox[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && inbox[j].newestTs < tmp2.newestTs) {
|
||||
inbox[j + 1] = inbox[j];
|
||||
j--;
|
||||
}
|
||||
inbox[j + 1] = tmp2;
|
||||
}
|
||||
|
||||
// Render inbox list
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerH = 14;
|
||||
int footerH = 14;
|
||||
int maxY = display.height() - footerH;
|
||||
int y = headerH;
|
||||
int maxVisible = (maxY - headerH) / lineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
|
||||
// Clamp scroll
|
||||
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount > 0 ? inboxCount - 1 : 0;
|
||||
|
||||
if (inboxCount == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
display.print("No direct messages");
|
||||
display.setCursor(0, y + lineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("DMs from contacts appear here");
|
||||
#else
|
||||
display.print("A/D: Switch channel");
|
||||
#endif
|
||||
} else {
|
||||
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
|
||||
inboxCount - maxVisible));
|
||||
int endIdx = min(inboxCount, startIdx + maxVisible);
|
||||
|
||||
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
|
||||
bool selected = (i == _dmInboxScroll);
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineH);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Prefix: > for selected, unread indicator
|
||||
char prefix[6];
|
||||
if (inbox[i].unreadCount > 0) {
|
||||
snprintf(prefix, sizeof(prefix), "%s*%d", selected ? ">" : " ", inbox[i].unreadCount);
|
||||
} else {
|
||||
snprintf(prefix, sizeof(prefix), "%s ", selected ? ">" : " ");
|
||||
}
|
||||
display.print(prefix);
|
||||
|
||||
// Name (truncated)
|
||||
char filteredName[32];
|
||||
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
|
||||
|
||||
// Right side: message count + age
|
||||
char ageStr[8];
|
||||
uint32_t age = _rtc->getCurrentTime() - inbox[i].newestTs;
|
||||
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
|
||||
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
|
||||
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
|
||||
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
|
||||
|
||||
char rightStr[16];
|
||||
snprintf(rightStr, sizeof(rightStr), "(%d) %s", inbox[i].msgCount, ageStr);
|
||||
int rightW = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
int nameX = display.getTextWidth(prefix) + 2;
|
||||
int nameMaxW = display.width() - nameX - rightW - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
display.setCursor(display.width() - rightW, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineH;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Swipe:Nav");
|
||||
const char* rtInbox = "Hold:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
|
||||
display.print(rtInbox);
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bck A/D:Ch");
|
||||
const char* rtInbox = "Ent:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
|
||||
display.print(rtInbox);
|
||||
#endif
|
||||
|
||||
#ifdef USE_EINK
|
||||
return 5000;
|
||||
#else
|
||||
return 1000;
|
||||
#endif
|
||||
}
|
||||
|
||||
// --- Path detail overlay ---
|
||||
if (_showPathOverlay) {
|
||||
display.setTextSize(0);
|
||||
int lineH = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
int y = 14;
|
||||
|
||||
ChannelMessage* msg = getNewestReceivedMsg();
|
||||
@@ -639,6 +917,10 @@ public:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Back");
|
||||
const char* copyHint = "Tap:Dismiss";
|
||||
#else
|
||||
display.print("Q:Back");
|
||||
// Show scroll hint if path is scrollable
|
||||
if (msg && (msg->path_len & 63) > _pathHopsVisible && msg->path_len != 0xFF) {
|
||||
@@ -648,10 +930,11 @@ public:
|
||||
display.print(scrollHint);
|
||||
}
|
||||
const char* copyHint = "Ent:Copy";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(copyHint) - 2, footerY);
|
||||
display.print(copyHint);
|
||||
|
||||
#if AUTO_OFF_MILLIS == 0
|
||||
#ifdef USE_EINK
|
||||
return 5000;
|
||||
#else
|
||||
return 1000;
|
||||
@@ -659,18 +942,160 @@ public:
|
||||
}
|
||||
|
||||
if (channelMsgCount == 0) {
|
||||
display.setTextSize(0); // Tiny font for body text
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // Tiny font for body text
|
||||
display.setCursor(0, 20);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("No messages yet");
|
||||
display.setCursor(0, 30);
|
||||
display.print("A/D: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("C: Compose message");
|
||||
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("Hold: Compose reply");
|
||||
#else
|
||||
display.print("Q: Back to inbox");
|
||||
display.setCursor(0, 40);
|
||||
display.print("Ent: Compose reply");
|
||||
#endif
|
||||
} else {
|
||||
display.print("No messages yet");
|
||||
display.setCursor(0, 30);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("Long press: Compose");
|
||||
#else
|
||||
display.print("A/D: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("C: Compose message");
|
||||
#endif
|
||||
}
|
||||
display.setTextSize(1); // Restore for footer
|
||||
} else if (_viewChannelIdx == 0xFF && _dmInboxMode) {
|
||||
// =================================================================
|
||||
// DM Inbox: list of contacts/rooms you have DM history with
|
||||
// =================================================================
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
int y = headerHeight;
|
||||
|
||||
// Scan all DM messages and collect unique senders
|
||||
#define DM_INBOX_MAX 16
|
||||
struct InboxEntry {
|
||||
char name[24];
|
||||
int count;
|
||||
uint32_t newest_ts;
|
||||
};
|
||||
static InboxEntry inbox[DM_INBOX_MAX];
|
||||
int inboxCount = 0;
|
||||
|
||||
for (int i = 0; i < _msgCount; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
|
||||
|
||||
char sender[24];
|
||||
if (!extractSenderName(_messages[idx].text, sender, sizeof(sender))) continue;
|
||||
|
||||
// Find or add sender in inbox
|
||||
bool found = false;
|
||||
for (int j = 0; j < inboxCount; j++) {
|
||||
if (strcmp(inbox[j].name, sender) == 0) {
|
||||
inbox[j].count++;
|
||||
if (_messages[idx].timestamp > inbox[j].newest_ts)
|
||||
inbox[j].newest_ts = _messages[idx].timestamp;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found && inboxCount < DM_INBOX_MAX) {
|
||||
strncpy(inbox[inboxCount].name, sender, 23);
|
||||
inbox[inboxCount].name[23] = '\0';
|
||||
inbox[inboxCount].count = 1;
|
||||
inbox[inboxCount].newest_ts = _messages[idx].timestamp;
|
||||
inboxCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by newest timestamp descending (most recent first)
|
||||
for (int i = 1; i < inboxCount; i++) {
|
||||
InboxEntry tmp2 = inbox[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && inbox[j].newest_ts < tmp2.newest_ts) {
|
||||
inbox[j + 1] = inbox[j];
|
||||
j--;
|
||||
}
|
||||
inbox[j + 1] = tmp2;
|
||||
}
|
||||
|
||||
if (inboxCount == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
display.print("No conversations");
|
||||
} else {
|
||||
// Clamp scroll
|
||||
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount - 1;
|
||||
if (_dmInboxScroll < 0) _dmInboxScroll = 0;
|
||||
|
||||
int maxVisible = (maxY - headerHeight) / lineHeight;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
|
||||
inboxCount - maxVisible));
|
||||
int endIdx = min(inboxCount, startIdx + maxVisible);
|
||||
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
|
||||
bool selected = (i == _dmInboxScroll);
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
display.print(selected ? ">" : " ");
|
||||
|
||||
// Name (ellipsized)
|
||||
char filteredName[24];
|
||||
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
|
||||
|
||||
// Right side: message count + age
|
||||
char ageStr[8];
|
||||
uint32_t age = now - inbox[i].newest_ts;
|
||||
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
|
||||
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
|
||||
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
|
||||
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
|
||||
|
||||
char rightStr[16];
|
||||
snprintf(rightStr, sizeof(rightStr), "[%d] %s", inbox[i].count, ageStr);
|
||||
int rightW = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
int nameX = display.getTextWidth(">") + 2;
|
||||
int nameMaxW = display.width() - nameX - rightW - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
display.setCursor(display.width() - rightW, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0); // Tiny font for message body
|
||||
int lineHeight = 9; // 8px font + 1px spacing
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // Tiny font for message body
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px spacing
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int scrollBarW = 4; // Width of scroll indicator on right edge
|
||||
@@ -690,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;
|
||||
}
|
||||
}
|
||||
@@ -735,7 +1160,11 @@ public:
|
||||
int availH = maxY - y;
|
||||
if (maxFillH > availH) maxFillH = availH;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, contentW, maxFillH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, maxFillH);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), contentW, maxFillH);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Time indicator with hop count - inline on same line as message start
|
||||
@@ -807,7 +1236,9 @@ public:
|
||||
if (wb == ' ' || isEmojiEscape(wb)) break;
|
||||
charStr[0] = (char)wb;
|
||||
dblStr[0] = dblStr[1] = (char)wb;
|
||||
wordW += display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
int charAdv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
if (charAdv < 1) charAdv = 1;
|
||||
wordW += charAdv;
|
||||
}
|
||||
if (px + wordW > lineW) {
|
||||
px = 0;
|
||||
@@ -836,6 +1267,7 @@ public:
|
||||
charStr[0] = ' ';
|
||||
dblStr[0] = dblStr[1] = ' ';
|
||||
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
if (adv < 1) adv = 1; // Minimum advance (rounding fix for proportional fonts)
|
||||
if (px + adv > lineW) {
|
||||
px = 0;
|
||||
linesForThisMsg++;
|
||||
@@ -853,6 +1285,7 @@ public:
|
||||
charStr[0] = (char)b;
|
||||
dblStr[0] = dblStr[1] = (char)b;
|
||||
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
if (adv < 1) adv = 1; // Minimum advance (rounding fix for proportional fonts)
|
||||
if (px + adv > lineW) {
|
||||
px = 0;
|
||||
linesForThisMsg++;
|
||||
@@ -888,7 +1321,11 @@ public:
|
||||
if (maxFillH > availH) maxFillH = availH;
|
||||
if (usedH < maxFillH) {
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(0, y + 5, contentW, maxFillH - usedH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, maxFillH - usedH);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), contentW, maxFillH - usedH);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -943,20 +1380,47 @@ public:
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
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) {
|
||||
display.print("W/S:Sel V:Pth Q:X");
|
||||
const char* rightText = "Ent:Reply";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
} else 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";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if AUTO_OFF_MILLIS == 0 // e-ink
|
||||
#ifdef USE_EINK
|
||||
return 5000;
|
||||
#else
|
||||
return 1000;
|
||||
@@ -1046,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
|
||||
@@ -1086,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') {
|
||||
@@ -1101,22 +1645,64 @@ public:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (_viewChannelIdx > 0) {
|
||||
// Skip backwards over any empty/gap slots
|
||||
uint8_t prev = _viewChannelIdx - 1;
|
||||
bool found = false;
|
||||
while (true) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(prev, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = prev;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (prev == 0) break;
|
||||
prev--;
|
||||
}
|
||||
if (!found) {
|
||||
// No valid channel below → wrap to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
}
|
||||
} else {
|
||||
// Channel 0 → wrap to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
}
|
||||
_scrollPos = 0;
|
||||
markChannelRead(_viewChannelIdx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// D - next channel
|
||||
// D - next channel (includes DM tab at 0xFF)
|
||||
if (c == 'd' || c == 'D') {
|
||||
_replySelectMode = false;
|
||||
_replySelectPos = -1;
|
||||
ChannelDetails ch;
|
||||
uint8_t nextIdx = _viewChannelIdx + 1;
|
||||
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = nextIdx;
|
||||
} else {
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
// DM tab → wrap to channel 0
|
||||
_viewChannelIdx = 0;
|
||||
} else {
|
||||
// Skip forward over any empty/gap slots
|
||||
bool found = false;
|
||||
for (uint8_t next = _viewChannelIdx + 1; next < MAX_GROUP_CHANNELS; next++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(next, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = next;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// Past last channel → go to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
}
|
||||
}
|
||||
_scrollPos = 0;
|
||||
markChannelRead(_viewChannelIdx);
|
||||
|
||||
@@ -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 = the_mesh.getNodePrefs()->smallLineH();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
|
||||
#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 {
|
||||
@@ -204,8 +235,8 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body - contact rows ===
|
||||
display.setTextSize(0); // tiny font for compact rows
|
||||
int lineHeight = 9; // 8px font + 1px gap
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px gap
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
@@ -219,7 +250,11 @@ public:
|
||||
display.setCursor(0, y);
|
||||
display.print("No contacts");
|
||||
display.setCursor(0, y + lineHeight);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe to change filter");
|
||||
#else
|
||||
display.print("A/D: Change filter");
|
||||
#endif
|
||||
} else {
|
||||
// Center visible window around selected item (TextReaderScreen pattern)
|
||||
int maxVisible = (maxY - headerHeight) / lineHeight;
|
||||
@@ -237,7 +272,11 @@ public:
|
||||
// Highlight: fill LIGHT rect first, then draw DARK text on top
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -270,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
|
||||
@@ -297,19 +341,22 @@ public:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
// Left: Q:Bk
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bk");
|
||||
|
||||
// Center: A/D:Filter
|
||||
const char* mid = "A/D:Filtr";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
// Right: F:Dscvr
|
||||
const char* right = "F:Dscvr";
|
||||
display.print("Swipe:Filter");
|
||||
const char* right = "Hold:DM/Admin";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
// Left: Q:Bk
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bk A/D:Filter");
|
||||
|
||||
// Right: Tap/Ent:Select
|
||||
const char* right = "Tap/Ent:Select";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
return 5000; // e-ink: next render after 5s
|
||||
}
|
||||
|
||||
@@ -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 = the_mesh.getNodePrefs()->smallLineH();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
|
||||
#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();
|
||||
@@ -65,8 +91,8 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body — discovered node rows ===
|
||||
display.setTextSize(0); // tiny font for compact rows
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
@@ -79,7 +105,11 @@ public:
|
||||
display.print(active ? "Listening for adverts..." : "No nodes found");
|
||||
if (!active) {
|
||||
display.setCursor(4, 38);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Long press: Rescan");
|
||||
#else
|
||||
display.print("F: Scan again Q: Back");
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
// Center visible window around selected item
|
||||
@@ -96,7 +126,11 @@ public:
|
||||
// Highlight selected row
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -158,15 +192,23 @@ public:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Back");
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe:Scroll");
|
||||
|
||||
const char* mid = "Ent:Add";
|
||||
const char* mid = "Tap:Add";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
const char* right = "F:Rescan";
|
||||
const char* right = "Hold:Rescan";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.print("Q:Bk F:Rescan");
|
||||
|
||||
const char* right = "Tap/Ent:Add";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
// Faster refresh while actively scanning
|
||||
return active ? 1000 : 5000;
|
||||
|
||||
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 = the_mesh.getNodePrefs()->smallLineH();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
|
||||
#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(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
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 + the_mesh.getNodePrefs()->smallHighlightOff(), 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <SD.h>
|
||||
#include <vector>
|
||||
#include "Utf8CP437.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
@@ -52,9 +53,11 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Mode _mode;
|
||||
bool _sdReady;
|
||||
bool _initialized;
|
||||
uint8_t _lastFontPref;
|
||||
DisplayDriver* _display;
|
||||
|
||||
// Display layout (calculated once from display metrics)
|
||||
@@ -102,6 +105,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) {
|
||||
@@ -496,7 +503,11 @@ private:
|
||||
int rightX = display.width() - display.getTextWidth(tmp) - 2;
|
||||
|
||||
if (_selectedFile >= 1 && _selectedFile <= (int)_fileList.size()) {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const char* hint = "[Hold:Rename]";
|
||||
#else
|
||||
const char* hint = "[R:Rename]";
|
||||
#endif
|
||||
int hintX = rightX - display.getTextWidth(hint) - 4;
|
||||
display.setCursor(hintX, 0);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
@@ -510,8 +521,8 @@ private:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// File list with "+ New Note" at index 0
|
||||
display.setTextSize(0);
|
||||
int listLineH = 8;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int listLineH = _prefs->smallLineH();
|
||||
int startY = 14;
|
||||
int totalItems = 1 + (int)_fileList.size();
|
||||
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
|
||||
@@ -528,26 +539,24 @@ private:
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
if (i == 0) {
|
||||
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
|
||||
display.print(selected ? "> + New Note" : " + New Note");
|
||||
display.drawTextEllipsized(0, y, display.width() - 4,
|
||||
selected ? "> + New Note" : " + New Note");
|
||||
} else {
|
||||
String line = selected ? "> " : " ";
|
||||
String name = _fileList[i - 1];
|
||||
int maxLen = _charsPerLine - 4;
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name;
|
||||
display.print(line.c_str());
|
||||
line += _fileList[i - 1];
|
||||
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
|
||||
}
|
||||
y += listLineH;
|
||||
}
|
||||
@@ -558,9 +567,13 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("Q:Back W/S:Nav");
|
||||
|
||||
const char* right = "Ent:Open";
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe:Nav");
|
||||
const char* right = "Tap:Open";
|
||||
#else
|
||||
display.print("Q:Bk");
|
||||
const char* right = "Tap/Ent:Open";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
@@ -576,16 +589,20 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bck Ent:Edit");
|
||||
|
||||
const char* right = "Sh+Del:Del";
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap:Edit");
|
||||
const char* right = "Hold:Delete";
|
||||
#else
|
||||
display.print("Q:Bk Ent:Edit");
|
||||
const char* right = "X:Delete";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render current page using tiny font
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
int pageStart = _pageOffsets[_currentPage];
|
||||
@@ -663,9 +680,15 @@ private:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bck Ent:Edit");
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe:Page");
|
||||
|
||||
const char* right = "Sh+Del:Del";
|
||||
const char* right = "Tap:Edit";
|
||||
#else
|
||||
display.print("Q:Bk Ent:Edit");
|
||||
|
||||
const char* right = "X:Delete";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
@@ -696,7 +719,7 @@ private:
|
||||
int textAreaTop = 14;
|
||||
int textAreaBottom = display.height() - 16;
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
// Find cursor line
|
||||
int cursorLine = lineForPos(_cursorPos);
|
||||
@@ -745,7 +768,7 @@ private:
|
||||
|
||||
// If buffer is empty, show cursor at top
|
||||
if (_bufLen == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, textAreaTop);
|
||||
display.print("|");
|
||||
@@ -766,11 +789,25 @@ private:
|
||||
snprintf(status, sizeof(status), "Pg %d/%d", curPage, totalPg);
|
||||
display.print(status);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const char* mid = "Tap:Type";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
#endif
|
||||
|
||||
const char* right;
|
||||
if (_bufLen == 0 || !_dirty) {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
right = "Back";
|
||||
#else
|
||||
right = "Q:Back";
|
||||
#endif
|
||||
} else {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
right = "Hold:Save";
|
||||
#else
|
||||
right = "Sh+Del:Save";
|
||||
#endif
|
||||
}
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
@@ -789,7 +826,7 @@ private:
|
||||
display.setCursor(0, 20);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("From: ");
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
String origDisplay = _renameOriginal;
|
||||
if (origDisplay.length() > 30) origDisplay = origDisplay.substring(0, 27) + "...";
|
||||
display.print(origDisplay.c_str());
|
||||
@@ -800,7 +837,7 @@ private:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("To: ");
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
char displayName[NOTES_RENAME_MAX + 2];
|
||||
snprintf(displayName, sizeof(displayName), "%s|", _renameBuf);
|
||||
@@ -817,9 +854,13 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Cancel");
|
||||
const char* right = "Tap:Confirm";
|
||||
#else
|
||||
display.print("Q:Cancel");
|
||||
|
||||
const char* right = "Ent:Confirm";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
@@ -836,7 +877,7 @@ private:
|
||||
display.setCursor(0, 25);
|
||||
display.print("File:");
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setCursor(0, 38);
|
||||
String nameDisplay = _deleteTarget;
|
||||
if (nameDisplay.length() > 35) nameDisplay = nameDisplay.substring(0, 32) + "...";
|
||||
@@ -852,9 +893,13 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Cancel");
|
||||
const char* right = "Tap:Delete";
|
||||
#else
|
||||
display.print("Q:Cancel");
|
||||
|
||||
const char* right = "Ent:Delete";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
@@ -1033,6 +1078,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;
|
||||
@@ -1044,9 +1093,9 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
NotesScreen(UITask* task)
|
||||
: _task(task), _mode(FILE_LIST),
|
||||
_sdReady(false), _initialized(false), _display(nullptr),
|
||||
NotesScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _mode(FILE_LIST),
|
||||
_sdReady(false), _initialized(false), _lastFontPref(0), _display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5), _footerHeight(14),
|
||||
_editCharsPerLine(20), _editLineHeight(12), _editMaxLines(8),
|
||||
_selectedFile(0), _buf(nullptr), _bufLen(0), _cursorPos(0),
|
||||
@@ -1081,15 +1130,31 @@ public:
|
||||
// ---- Layout Init ----
|
||||
|
||||
void initLayout(DisplayDriver& display) {
|
||||
// Re-init if font preference changed since last layout
|
||||
uint8_t curFont = _prefs ? _prefs->large_font : 0;
|
||||
if (_initialized && curFont != _lastFontPref) {
|
||||
_initialized = false;
|
||||
Serial.println("Notes: font changed, recalculating layout");
|
||||
}
|
||||
if (_initialized) return;
|
||||
_lastFontPref = curFont;
|
||||
_display = &display;
|
||||
|
||||
// Tiny font metrics (for read mode)
|
||||
display.setTextSize(0);
|
||||
// Font metrics (for read mode)
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
|
||||
if (tenCharsW > 0) {
|
||||
_charsPerLine = (display.width() * 10) / tenCharsW;
|
||||
}
|
||||
// Proportional font: use average-width measurement instead of M-width
|
||||
if (_prefs && _prefs->large_font) {
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 60) _charsPerLine = 60;
|
||||
|
||||
@@ -1099,6 +1164,10 @@ public:
|
||||
} else {
|
||||
_lineHeight = 5;
|
||||
}
|
||||
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
|
||||
if (_prefs && _prefs->large_font) {
|
||||
_lineHeight = _prefs->smallLineH();
|
||||
}
|
||||
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _footerHeight;
|
||||
@@ -1124,12 +1193,16 @@ public:
|
||||
|
||||
void setSDReady(bool ready) { _sdReady = ready; }
|
||||
bool isSDReady() const { return _sdReady; }
|
||||
bool isDirty() const { return _dirty; }
|
||||
void triggerSaveAndExit() { saveAndExit(); }
|
||||
|
||||
void setTimestamp(uint32_t rtcTime, int8_t utcOffset) {
|
||||
_rtcTime = rtcTime;
|
||||
_utcOffset = utcOffset;
|
||||
}
|
||||
|
||||
void setTimeGetter(TimeGetterFn fn) { _getTimeFn = fn; }
|
||||
|
||||
void enter(DisplayDriver& display) {
|
||||
initLayout(display);
|
||||
scanFiles();
|
||||
@@ -1145,7 +1218,6 @@ public:
|
||||
bool isInFileList() const { return _mode == FILE_LIST; }
|
||||
bool isRenaming() const { return _mode == RENAMING; }
|
||||
bool isConfirmingDelete() const { return _mode == CONFIRM_DELETE; }
|
||||
bool isDirty() const { return _dirty; }
|
||||
bool isEmpty() const { return _bufLen == 0; }
|
||||
|
||||
// ---- Cursor Navigation (called from main.cpp) ----
|
||||
|
||||
@@ -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) {
|
||||
@@ -598,41 +601,77 @@ public:
|
||||
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Exit");
|
||||
renderFooterRight(display, footerY, "Hold:Type");
|
||||
#else
|
||||
display.print("Sh+Del:Exit");
|
||||
renderFooterRight(display, footerY, "Ent:Login");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Cancel");
|
||||
#else
|
||||
display.print("Sh+Del:Cancel");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_CATEGORY_MENU:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Exit");
|
||||
renderFooterMidRight(display, footerY, "Back:Exit", "Tap:Open", "Swipe:Sel");
|
||||
#else
|
||||
display.print("Sh+Del:Exit");
|
||||
renderFooterMidRight(display, footerY, "Sh+Del:Exit", "Ent:Open", "W/S:Sel");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_COMMAND_MENU:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Back");
|
||||
renderFooterMidRight(display, footerY, "Back:Back", "Tap:Run", "Swipe:Sel");
|
||||
#else
|
||||
display.print("Sh+Del:Back");
|
||||
renderFooterMidRight(display, footerY, "Sh+Del:Back", "Ent:Run", "W/S:Sel");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_PARAM_ENTRY:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Cancel");
|
||||
renderFooterRight(display, footerY, "Tap:Send");
|
||||
#else
|
||||
display.print("Sh+Del:Cancel");
|
||||
renderFooterRight(display, footerY, "Ent:Send");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_CONFIRM:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:No");
|
||||
renderFooterRight(display, footerY, "Tap:Yes");
|
||||
#else
|
||||
display.print("Sh+Del:No");
|
||||
renderFooterRight(display, footerY, "Ent:Yes");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_RESPONSE_VIEW:
|
||||
case STATE_ERROR:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Back");
|
||||
if (_responseTotalLines > bodyHeight / 9) {
|
||||
renderFooterRight(display, footerY, "Swipe:Scroll");
|
||||
}
|
||||
#else
|
||||
display.print("Sh+Del:Back");
|
||||
if (_responseTotalLines > bodyHeight / 9) {
|
||||
renderFooterRight(display, footerY, "W/S:Scrll");
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -738,8 +777,8 @@ private:
|
||||
// =====================================================================
|
||||
|
||||
void renderCategoryMenu(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
|
||||
// Clock drift info line
|
||||
if (_serverTime > 0) {
|
||||
@@ -823,8 +862,8 @@ private:
|
||||
// =====================================================================
|
||||
|
||||
void renderCommandMenu(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
const AdminCategoryDef& cat = CATEGORIES[_catSel];
|
||||
|
||||
// Category title
|
||||
@@ -986,7 +1025,7 @@ private:
|
||||
if (_pendingCmd) display.print(_pendingCmd->label);
|
||||
|
||||
y += 14;
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Show the param value if one was collected
|
||||
@@ -994,14 +1033,18 @@ private:
|
||||
char preview[80];
|
||||
snprintf(preview, sizeof(preview), "Value: %s", _paramBuf);
|
||||
display.print(preview);
|
||||
y += 10;
|
||||
y += the_mesh.getNodePrefs()->smallLineH() + 1;
|
||||
display.setCursor(0, y);
|
||||
}
|
||||
|
||||
if (_pendingCmd && (_pendingCmd->flags & CMDF_EXPECT_TIMEOUT)) {
|
||||
display.print("Timeout response is normal.");
|
||||
} else {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap=Yes Back=No");
|
||||
#else
|
||||
display.print("Enter=Yes Sh+Del=No");
|
||||
#endif
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
@@ -1028,8 +1071,8 @@ private:
|
||||
// =====================================================================
|
||||
|
||||
void renderResponse(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
|
||||
display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT);
|
||||
|
||||
@@ -1120,7 +1163,11 @@ private:
|
||||
bool selected, const char* label, bool warn) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else if (warn) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
@@ -1169,9 +1216,10 @@ inline bool RepeaterAdminScreen::doLogin() {
|
||||
if (the_mesh.uiLoginToRepeater(_contactIdx, _password, timeout_ms)) {
|
||||
_state = STATE_LOGGING_IN;
|
||||
_cmdSentAt = millis();
|
||||
// Add a 1.5s buffer over the mesh estimate; fall back to ADMIN_TIMEOUT_MS
|
||||
// if the estimate came back zero for any reason.
|
||||
_loginTimeoutMs = (timeout_ms > 0) ? timeout_ms + 1500 : ADMIN_TIMEOUT_MS;
|
||||
// Add a 5s buffer over the mesh estimate to account for blocking e-ink
|
||||
// refreshes (FastEPD ~1-2s per frame, VKB dismiss + login render = 2-3 frames).
|
||||
// Fall back to ADMIN_TIMEOUT_MS if the estimate came back zero.
|
||||
_loginTimeoutMs = (timeout_ms > 0) ? timeout_ms + 5000 : ADMIN_TIMEOUT_MS;
|
||||
_waitingForLogin = true;
|
||||
return true;
|
||||
} else {
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
#include "ModemManager.h"
|
||||
#include "SMSStore.h"
|
||||
#include "SMSContacts.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Limits
|
||||
#define SMS_INBOX_PAGE_SIZE 4
|
||||
@@ -51,6 +52,7 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
SubView _view;
|
||||
|
||||
// App menu state
|
||||
@@ -117,8 +119,8 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
SMSScreen(UITask* task)
|
||||
: _task(task), _view(APP_MENU)
|
||||
SMSScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _view(APP_MENU)
|
||||
, _menuCursor(0)
|
||||
, _convCount(0), _inboxCursor(0), _inboxScrollTop(0)
|
||||
, _msgCount(0), _msgScrollPos(0)
|
||||
@@ -276,7 +278,7 @@ public:
|
||||
|
||||
// Show modem state text if not ready
|
||||
if (ms != ModemState::READY && ms != ModemState::SENDING_SMS) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
const char* label = ModemManager::stateToString(ms);
|
||||
uint16_t labelW = display.getTextWidth(label);
|
||||
@@ -356,7 +358,7 @@ public:
|
||||
|
||||
// Modem status indicator
|
||||
ModemState ms = modemManager.getState();
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setCursor(4, y + lineHeight + 8);
|
||||
if (ms == ModemState::OFF || ms == ModemState::POWERING_ON ||
|
||||
ms == ModemState::INITIALIZING) {
|
||||
@@ -483,7 +485,7 @@ public:
|
||||
bool isAction = (row == 4); // Bottom row has action buttons
|
||||
|
||||
if (isAction) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
if (col == 2 && _phoneInputPos > 0) {
|
||||
display.setColor(DisplayDriver::GREEN); // CALL
|
||||
} else if (col == 1) {
|
||||
@@ -544,7 +546,7 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (_convCount == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 20);
|
||||
display.print("No conversations");
|
||||
@@ -560,8 +562,8 @@ public:
|
||||
}
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 10;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int lineHeight = _prefs->smallLineH() + 1;
|
||||
int y = 14;
|
||||
|
||||
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
|
||||
@@ -643,14 +645,14 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (_msgCount == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 25);
|
||||
display.print("No messages");
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 10;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int lineHeight = _prefs->smallLineH() + 1;
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
|
||||
@@ -764,12 +766,13 @@ public:
|
||||
// Message body
|
||||
display.setCursor(0, 14);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM");
|
||||
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
|
||||
if (charsPerLine < 12) charsPerLine = 12;
|
||||
|
||||
int composeLH = _prefs->smallLineH() + 1;
|
||||
int y = 14;
|
||||
int x = 0;
|
||||
char cs[2] = {0, 0};
|
||||
@@ -780,7 +783,7 @@ public:
|
||||
x++;
|
||||
if (x >= charsPerLine) {
|
||||
x = 0;
|
||||
y += 10;
|
||||
y += composeLH;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,7 +830,7 @@ public:
|
||||
int cnt = smsContacts.count();
|
||||
|
||||
if (cnt == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 25);
|
||||
display.print("No contacts saved");
|
||||
@@ -837,8 +840,8 @@ public:
|
||||
display.print("and press A to add");
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 10;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int lineHeight = _prefs->smallLineH() + 1;
|
||||
int y = 14;
|
||||
|
||||
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
|
||||
@@ -900,7 +903,7 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// Phone number (read-only)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 16);
|
||||
display.print("Phone: ");
|
||||
@@ -956,7 +959,7 @@ public:
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
@@ -1011,7 +1014,7 @@ public:
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
@@ -1070,7 +1073,7 @@ public:
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
@@ -1090,7 +1093,7 @@ public:
|
||||
display.print(timeBuf);
|
||||
|
||||
// Volume (left-aligned)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
char volLabel[12];
|
||||
snprintf(volLabel, sizeof(volLabel), "Vol: %d/5", _callVolume);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
#include <vector>
|
||||
#include "Utf8CP437.h"
|
||||
#include "EpubProcessor.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
@@ -15,7 +16,7 @@ class UITask;
|
||||
// ============================================================================
|
||||
#define BOOKS_FOLDER "/books"
|
||||
#define INDEX_FOLDER "/.indexes"
|
||||
#define INDEX_VERSION 5 // v5: UTF-8 aware word wrap (accented char support)
|
||||
#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
|
||||
@@ -97,16 +98,244 @@ inline WrapResult findLineBreak(const char* buffer, int bufLen, int lineStart, i
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pixel-width line breaking for proportional fonts (T5S3)
|
||||
//
|
||||
// Measures actual rendered text width via DisplayDriver::getTextWidth() at
|
||||
// each word boundary. This gives correct line breaks regardless of character
|
||||
// width variation in proportional fonts like FreeSans12pt.
|
||||
// maxChars is a safety upper bound to prevent runaway on spaceless lines.
|
||||
// ============================================================================
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
|
||||
inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineStart,
|
||||
DisplayDriver* display, int maxChars) {
|
||||
WrapResult result;
|
||||
result.lineEnd = lineStart;
|
||||
result.nextStart = lineStart;
|
||||
if (lineStart >= bufLen || !display) return result;
|
||||
|
||||
#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;
|
||||
|
||||
for (int i = lineStart; i < bufLen; i++) {
|
||||
char c = buffer[i];
|
||||
|
||||
// Newline handling (identical to char-count version)
|
||||
if (c == '\n') {
|
||||
result.lineEnd = i;
|
||||
result.nextStart = i + 1;
|
||||
if (result.nextStart < bufLen && buffer[result.nextStart] == '\r')
|
||||
result.nextStart++;
|
||||
return result;
|
||||
}
|
||||
if (c == '\r') {
|
||||
result.lineEnd = i;
|
||||
result.nextStart = i + 1;
|
||||
if (result.nextStart < bufLen && buffer[result.nextStart] == '\n')
|
||||
result.nextStart++;
|
||||
return result;
|
||||
}
|
||||
|
||||
if ((uint8_t)c >= 32) {
|
||||
// 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;
|
||||
uint32_t cp = decodeUtf8Char(buffer, bufLen, &decPos);
|
||||
uint8_t glyph = unicodeToCP437(cp);
|
||||
if (glyph >= 32 && measLen < 298) {
|
||||
measBuf[measLen++] = (char)glyph;
|
||||
charCount++;
|
||||
}
|
||||
i = decPos - 1; // -1 because the for loop will i++
|
||||
inWord = true;
|
||||
} 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;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: hard char limit (handles spaceless lines, URLs, etc.)
|
||||
if (charCount >= maxChars) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.lineEnd = bufLen;
|
||||
result.nextStart = bufLen;
|
||||
return result;
|
||||
}
|
||||
#endif // LilyGo_T5S3_EPaper_Pro
|
||||
|
||||
// ============================================================================
|
||||
// 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) {
|
||||
const int BUF_SIZE = 2048;
|
||||
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;
|
||||
|
||||
while (file.available() && (maxPages <= 0 || pagesAdded < maxPages)) {
|
||||
int bytesRead = file.readBytes(buffer + leftover, BUF_SIZE - leftover);
|
||||
int bufLen = leftover + bytesRead;
|
||||
if (bufLen == 0) break;
|
||||
|
||||
int pos = 0;
|
||||
while (pos < bufLen) {
|
||||
int lineStart = pos;
|
||||
WrapResult wrap = findLineBreak(buffer, bufLen, pos, charsPerLine);
|
||||
if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;
|
||||
|
||||
// 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 (pageBreak) {
|
||||
long pageFilePos = chunkFileStart + pos;
|
||||
pagePositions.push_back(pageFilePos);
|
||||
pagesAdded++;
|
||||
if (maxPages > 0 && pagesAdded >= maxPages) break;
|
||||
}
|
||||
if (pos >= bufLen) break;
|
||||
}
|
||||
|
||||
leftover = bufLen - pos;
|
||||
if (leftover > 0 && leftover < BUF_SIZE) {
|
||||
memmove(buffer, buffer + pos, leftover);
|
||||
} else {
|
||||
leftover = 0;
|
||||
}
|
||||
chunkFileStart = file.position() - leftover;
|
||||
}
|
||||
|
||||
return pagesAdded;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pixel-based Page Indexer for T5S3 (proportional font word wrap)
|
||||
// ============================================================================
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
inline int indexPagesWordWrapPixel(File& file, long startPos,
|
||||
std::vector<long>& pagePositions,
|
||||
int linesPerPage, int maxChars,
|
||||
DisplayDriver* display, int maxPages,
|
||||
NodePrefs* prefs = nullptr) {
|
||||
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
|
||||
char buffer[BUF_SIZE];
|
||||
|
||||
// Ensure body font is active for pixel measurement
|
||||
display->setTextSize(prefs ? prefs->smallTextSize() : 0);
|
||||
|
||||
file.seek(startPos);
|
||||
int pagesAdded = 0;
|
||||
int lineCount = 0;
|
||||
@@ -120,7 +349,7 @@ inline int indexPagesWordWrap(File& file, long startPos,
|
||||
|
||||
int pos = 0;
|
||||
while (pos < bufLen) {
|
||||
WrapResult wrap = findLineBreak(buffer, bufLen, pos, charsPerLine);
|
||||
WrapResult wrap = findLineBreakPixel(buffer, bufLen, pos, display, maxChars);
|
||||
if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;
|
||||
|
||||
lineCount++;
|
||||
@@ -145,8 +374,10 @@ inline int indexPagesWordWrap(File& file, long startPos,
|
||||
chunkFileStart = file.position() - leftover;
|
||||
}
|
||||
|
||||
display->setTextSize(1); // Restore
|
||||
return pagesAdded;
|
||||
}
|
||||
#endif // LilyGo_T5S3_EPaper_Pro
|
||||
|
||||
// ============================================================================
|
||||
// TextReaderScreen
|
||||
@@ -167,9 +398,11 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Mode _mode;
|
||||
bool _sdReady;
|
||||
bool _initialized; // Layout metrics calculated
|
||||
uint8_t _lastFontPref; // Font preference at last layout init (detect changes)
|
||||
bool _bootIndexed; // Boot-time pre-indexing done
|
||||
DisplayDriver* _display; // Stored reference for splash screens
|
||||
|
||||
@@ -177,6 +410,7 @@ private:
|
||||
int _charsPerLine;
|
||||
int _linesPerPage;
|
||||
int _lineHeight; // virtual coord units per text line
|
||||
int _textAreaHeight; // usable height for text (excluding header/footer)
|
||||
int _headerHeight;
|
||||
int _footerHeight;
|
||||
|
||||
@@ -200,6 +434,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.
|
||||
@@ -700,11 +939,13 @@ private:
|
||||
// Cache had no pages (e.g. dummy entry) — full index from scratch
|
||||
_pagePositions.push_back(0);
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
} else {
|
||||
long lastPos = cache->pagePositions.back();
|
||||
indexPagesWordWrap(_file, lastPos, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
}
|
||||
} else {
|
||||
// No cache — full index from scratch
|
||||
@@ -723,7 +964,8 @@ private:
|
||||
|
||||
_pagePositions.push_back(0);
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
}
|
||||
|
||||
// Save complete index
|
||||
@@ -846,8 +1088,8 @@ private:
|
||||
display.setCursor(0, 42);
|
||||
display.print("/books/ on SD card");
|
||||
} else {
|
||||
display.setTextSize(0); // Tiny font for file list
|
||||
int listLineH = 8; // Approximate tiny font line height in virtual coords
|
||||
display.setTextSize(_prefs->smallTextSize()); // Tiny font for file list
|
||||
int listLineH = _prefs->smallLineH();
|
||||
int startY = 14;
|
||||
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
@@ -863,17 +1105,19 @@ private:
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), listLineH);
|
||||
#else
|
||||
// setCursor adds +5 to y internally, but fillRect does not.
|
||||
// Offset fillRect by +5 to align highlight bar with text.
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// Set cursor AFTER fillRect so text draws on top of highlight
|
||||
display.setCursor(0, y);
|
||||
|
||||
int type = itemTypeAt(i);
|
||||
String line = selected ? "> " : " ";
|
||||
|
||||
@@ -883,10 +1127,6 @@ private:
|
||||
} else if (type == 1) {
|
||||
// Subdirectory
|
||||
line += "/" + dirNameAt(i);
|
||||
// Truncate if needed
|
||||
if ((int)line.length() > _charsPerLine) {
|
||||
line = line.substring(0, _charsPerLine - 3) + "...";
|
||||
}
|
||||
} else {
|
||||
// File
|
||||
int fi = fileIndexAt(i);
|
||||
@@ -899,16 +1139,11 @@ private:
|
||||
suffix = " *";
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
int maxLen = _charsPerLine - 4 - suffix.length();
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name + suffix;
|
||||
}
|
||||
|
||||
display.print(line.c_str());
|
||||
// Pixel-aware ellipsis — small margin prevents GxEPD edge wrapping
|
||||
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
|
||||
y += listLineH;
|
||||
}
|
||||
display.setTextSize(1); // Restore
|
||||
@@ -918,18 +1153,24 @@ private:
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("Q:Back W/S:Nav");
|
||||
|
||||
const char* right = "Ent:Open";
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home");
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bk");
|
||||
|
||||
const char* right = "Tap/Ent:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
}
|
||||
|
||||
void renderPage(DisplayDriver& display) {
|
||||
// Use tiny font for maximum text density
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
int y = 0;
|
||||
@@ -940,7 +1181,7 @@ 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;
|
||||
WrapResult wrap = findLineBreak(_pageBuf, _pageBufLen, pos, _charsPerLine);
|
||||
|
||||
@@ -950,6 +1191,7 @@ private:
|
||||
display.setCursor(0, y);
|
||||
// Print line with UTF-8 decoding: multi-byte sequences are decoded
|
||||
// to Unicode codepoints, then mapped to CP437 for the built-in font.
|
||||
bool lineHasContent = false;
|
||||
char charStr[2] = {0, 0};
|
||||
int j = pos;
|
||||
while (j < wrap.lineEnd && j < _pageBufLen) {
|
||||
@@ -965,6 +1207,7 @@ private:
|
||||
// Plain ASCII — print directly
|
||||
charStr[0] = (char)b;
|
||||
display.print(charStr);
|
||||
lineHasContent = true;
|
||||
j++;
|
||||
} else if (b >= 0xC0) {
|
||||
// UTF-8 lead byte — decode full sequence and map to CP437
|
||||
@@ -974,6 +1217,7 @@ private:
|
||||
if (glyph) {
|
||||
charStr[0] = (char)glyph;
|
||||
display.print(charStr);
|
||||
lineHasContent = true;
|
||||
}
|
||||
// If unmappable (glyph==0), just skip the character
|
||||
} else {
|
||||
@@ -981,11 +1225,20 @@ private:
|
||||
// Treat as CP437 pass-through (e.g. from EPUB numeric entity decoding).
|
||||
charStr[0] = (char)b;
|
||||
display.print(charStr);
|
||||
lineHasContent = true;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
y += _lineHeight;
|
||||
// Blank lines (paragraph breaks) get reduced height for compact layout.
|
||||
// Full _lineHeight for blank lines wastes too much space — on T5S3 each
|
||||
// blank line is ~34px, making paragraph gaps 7-8× the normal line spacing.
|
||||
// Using 40% height gives a visible paragraph break without wasting space.
|
||||
if (lineHasContent) {
|
||||
y += _lineHeight;
|
||||
} else {
|
||||
y += max(2, _lineHeight * 2 / 5); // ~40% height for blank lines
|
||||
}
|
||||
lineCount++;
|
||||
pos = wrap.nextStart;
|
||||
if (pos >= _pageBufLen) break;
|
||||
@@ -1001,43 +1254,108 @@ 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(_prefs->smallTextSize());
|
||||
display.setCursor(0, footerY);
|
||||
display.print(status);
|
||||
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
|
||||
}
|
||||
|
||||
public:
|
||||
TextReaderScreen(UITask* task)
|
||||
: _task(task), _mode(FILE_LIST), _sdReady(false), _initialized(false),
|
||||
TextReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _mode(FILE_LIST), _sdReady(false), _initialized(false), _lastFontPref(0),
|
||||
_bootIndexed(false), _display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
|
||||
_headerHeight(14), _footerHeight(14),
|
||||
_textAreaHeight(100), _headerHeight(14), _footerHeight(14),
|
||||
_selectedFile(0), _currentPath(BOOKS_FOLDER),
|
||||
_fileOpen(false), _currentPage(0), _totalPages(0),
|
||||
_pageBufLen(0), _contentDirty(true) {
|
||||
}
|
||||
|
||||
// Reset layout so it recalculates on next render (orientation change).
|
||||
// If a book is open, forces full reindex with new layout params.
|
||||
void invalidateLayout() {
|
||||
_initialized = false;
|
||||
if (_fileOpen) {
|
||||
_pagePositions.clear();
|
||||
_totalPages = 0;
|
||||
_currentPage = 0;
|
||||
_pageBufLen = 0;
|
||||
_contentDirty = true;
|
||||
Serial.println("TextReader: Layout invalidated, will reindex on next enter");
|
||||
}
|
||||
}
|
||||
|
||||
// Call once after display is available to calculate layout metrics
|
||||
void initLayout(DisplayDriver& display) {
|
||||
// Re-init if font preference changed since last layout
|
||||
uint8_t curFont = _prefs ? _prefs->large_font : 0;
|
||||
if (_initialized && curFont != _lastFontPref) {
|
||||
_initialized = false;
|
||||
Serial.println("TextReader: font changed, recalculating layout");
|
||||
}
|
||||
if (_initialized) return;
|
||||
_lastFontPref = curFont;
|
||||
|
||||
// Store display reference for splash screens during openBook
|
||||
_display = &display;
|
||||
|
||||
// Measure tiny font metrics using the display driver
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
// Measure character width: use 10 M's to get accurate average
|
||||
// Measure character width: use 10 M's for monospace (T-Deck Pro tiny font).
|
||||
// Proportional fonts (T5S3 and T-Deck Pro large_font) override below with
|
||||
// average-width measurement since M is the widest glyph (~40% wider than average).
|
||||
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
|
||||
if (tenCharsW > 0) {
|
||||
_charsPerLine = (display.width() * 10) / tenCharsW;
|
||||
}
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3 uses proportional font (FreeSans12pt) — measure average character
|
||||
// width from a representative English sample. M-based measurement is far
|
||||
// too conservative (M is the widest glyph), leaving half the line empty.
|
||||
{
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
// 95% factor as small safety margin for slightly-wider-than-average text
|
||||
_charsPerLine = (display.width() * sampleLen * 95) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 80) _charsPerLine = 80;
|
||||
#else
|
||||
// T-Deck Pro: large_font uses FreeSans9pt (proportional) — same fix
|
||||
if (_prefs && _prefs->large_font) {
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 60) _charsPerLine = 60;
|
||||
#endif
|
||||
|
||||
// Line height for built-in 6x8 font:
|
||||
// setCursor adds +5 to y, so effective text top = (y+5)*scale_y
|
||||
@@ -1052,24 +1370,140 @@ public:
|
||||
_lineHeight = 5; // Safe fallback
|
||||
}
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3 uses FreeSans12pt/FreeSerif12pt for size 0 (yAdvance=29px).
|
||||
{
|
||||
extern DISPLAY_CLASS display;
|
||||
_lineHeight = display.isPortraitMode() ? 5 : 8;
|
||||
}
|
||||
#else
|
||||
// T-Deck Pro large_font uses FreeSans9pt (yAdvance=22px at scale 1.5625×).
|
||||
// The 6x8 formula above gives ~5-7 which is way too small — lines overlap.
|
||||
// Use smallLineH() which is already tuned for this font.
|
||||
if (_prefs && _prefs->large_font) {
|
||||
_lineHeight = _prefs->smallLineH();
|
||||
}
|
||||
#endif
|
||||
|
||||
_headerHeight = 0; // No header in reading mode (maximize text area)
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _headerHeight - _footerHeight;
|
||||
_linesPerPage = textAreaHeight / _lineHeight;
|
||||
_textAreaHeight = display.height() - _headerHeight - _footerHeight;
|
||||
_linesPerPage = _textAreaHeight / _lineHeight;
|
||||
if (_linesPerPage < 5) _linesPerPage = 5;
|
||||
if (_linesPerPage > 40) _linesPerPage = 40;
|
||||
|
||||
display.setTextSize(1); // Restore
|
||||
_initialized = true;
|
||||
|
||||
Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d (display %dx%d)\n",
|
||||
_charsPerLine, _linesPerPage, _lineHeight, display.width(), display.height());
|
||||
Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d, textH=%d (display %dx%d)\n",
|
||||
_charsPerLine, _linesPerPage, _lineHeight, _textAreaHeight, display.width(), display.height());
|
||||
}
|
||||
|
||||
// ---- Boot-time Indexing ----
|
||||
// Called from setup() after SD card init. Scans files, pre-indexes first
|
||||
// 100 pages of each, and shows progress on the e-ink display.
|
||||
|
||||
// Pre-index files inside one level of subdirectories so navigating
|
||||
// into them later is instant (idx files already on SD).
|
||||
void bootIndexSubfolders() {
|
||||
// Work from the root-level _dirList that scanFiles() already populated.
|
||||
// Copy it -- scanFiles() will overwrite _dirList when we scan each subfolder.
|
||||
std::vector<String> subDirs = _dirList;
|
||||
if (subDirs.empty()) return;
|
||||
|
||||
Serial.printf("TextReader: Pre-indexing %d subfolders\n", (int)subDirs.size());
|
||||
|
||||
int totalSubFiles = 0;
|
||||
int cachedSubFiles = 0;
|
||||
int indexedSubFiles = 0;
|
||||
|
||||
for (int d = 0; d < (int)subDirs.size(); d++) {
|
||||
String subPath = String(BOOKS_FOLDER) + "/" + subDirs[d];
|
||||
_currentPath = subPath;
|
||||
scanFiles(); // populates _fileList for this subfolder
|
||||
|
||||
// Also pick up previously converted EPUB cache files for this subfolder
|
||||
String epubCachePath = subPath + "/.epub_cache";
|
||||
if (SD.exists(epubCachePath.c_str())) {
|
||||
File cacheDir = SD.open(epubCachePath.c_str());
|
||||
if (cacheDir && cacheDir.isDirectory()) {
|
||||
File cf = cacheDir.openNextFile();
|
||||
while (cf && _fileList.size() < READER_MAX_FILES) {
|
||||
if (!cf.isDirectory()) {
|
||||
String cname = String(cf.name());
|
||||
int cslash = cname.lastIndexOf('/');
|
||||
if (cslash >= 0) cname = cname.substring(cslash + 1);
|
||||
if (cname.endsWith(".txt") || cname.endsWith(".TXT")) {
|
||||
bool dup = false;
|
||||
for (int k = 0; k < (int)_fileList.size(); k++) {
|
||||
if (_fileList[k] == cname) { dup = true; break; }
|
||||
}
|
||||
if (!dup) _fileList.push_back(cname);
|
||||
}
|
||||
}
|
||||
cf = cacheDir.openNextFile();
|
||||
}
|
||||
cacheDir.close();
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < (int)_fileList.size(); i++) {
|
||||
totalSubFiles++;
|
||||
|
||||
// Try loading existing .idx cache -- if hit, skip
|
||||
FileCache tempCache;
|
||||
if (loadIndex(_fileList[i], tempCache)) {
|
||||
cachedSubFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip .epub files (converted on first open)
|
||||
if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) continue;
|
||||
|
||||
// Index this .txt file
|
||||
String fullPath = _currentPath + "/" + _fileList[i];
|
||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!file) {
|
||||
// Try epub cache fallback
|
||||
String cacheFallback = epubCachePath + "/" + _fileList[i];
|
||||
file = SD.open(cacheFallback.c_str(), FILE_READ);
|
||||
}
|
||||
if (!file) continue;
|
||||
|
||||
indexedSubFiles++;
|
||||
String displayName = subDirs[d] + "/" + _fileList[i];
|
||||
drawBootSplash(indexedSubFiles, 0, displayName);
|
||||
|
||||
FileCache cache;
|
||||
cache.filename = _fileList[i];
|
||||
cache.fileSize = file.size();
|
||||
cache.fullyIndexed = false;
|
||||
cache.lastReadPage = 0;
|
||||
cache.pagePositions.clear();
|
||||
cache.pagePositions.push_back(0);
|
||||
|
||||
indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1,
|
||||
_textAreaHeight, _lineHeight);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
|
||||
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
|
||||
cache.fullyIndexed, 0);
|
||||
|
||||
Serial.printf("TextReader: %s/%s - indexed %d pages%s\n",
|
||||
subDirs[d].c_str(), _fileList[i].c_str(),
|
||||
(int)cache.pagePositions.size(),
|
||||
cache.fullyIndexed ? " (complete)" : "");
|
||||
yield(); // Feed WDT between files
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("TextReader: Subfolder pre-index: %d files (%d cached, %d newly indexed)\n",
|
||||
totalSubFiles, cachedSubFiles, indexedSubFiles);
|
||||
}
|
||||
|
||||
void bootIndex(DisplayDriver& display) {
|
||||
if (!_sdReady) return;
|
||||
|
||||
@@ -1111,20 +1545,24 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
if (_fileList.size() == 0) {
|
||||
Serial.println("TextReader: No files to index");
|
||||
if (_fileList.size() == 0 && _dirList.size() == 0) {
|
||||
Serial.println("TextReader: No files or folders to index");
|
||||
_bootIndexed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
int cachedCount = 0;
|
||||
int needsIndexCount = 0;
|
||||
|
||||
// --- Pass 1 & 2: Index root-level files ---
|
||||
if (_fileList.size() > 0) {
|
||||
|
||||
// --- Pass 1: Fast cache load (no per-file splash screens) ---
|
||||
// Try to load existing .idx files from SD for every file.
|
||||
// This is just SD reads — no indexing, no e-ink refreshes.
|
||||
_fileCache.clear();
|
||||
_fileCache.resize(_fileList.size()); // Pre-allocate slots to maintain alignment with _fileList
|
||||
|
||||
int cachedCount = 0;
|
||||
int needsIndexCount = 0;
|
||||
|
||||
for (int i = 0; i < (int)_fileList.size(); i++) {
|
||||
if (loadIndex(_fileList[i], _fileCache[i])) {
|
||||
@@ -1176,7 +1614,8 @@ public:
|
||||
|
||||
int added = indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1);
|
||||
PREINDEX_PAGES - 1,
|
||||
_textAreaHeight, _lineHeight);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
|
||||
@@ -1189,6 +1628,26 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
} // end if (_fileList.size() > 0)
|
||||
|
||||
// --- Pass 3: Pre-index files inside subfolders (one level deep) ---
|
||||
// Save root state -- bootIndexSubfolders() will overwrite _fileList/_dirList
|
||||
// via scanFiles() as it iterates each subdirectory.
|
||||
if (_dirList.size() > 0) {
|
||||
std::vector<String> savedFileList = _fileList;
|
||||
std::vector<String> savedDirList = _dirList;
|
||||
std::vector<FileCache> savedFileCache = _fileCache;
|
||||
|
||||
bootIndexSubfolders();
|
||||
|
||||
// Restore root state
|
||||
_currentPath = String(BOOKS_FOLDER);
|
||||
_fileList = savedFileList;
|
||||
_dirList = savedDirList;
|
||||
_fileCache = savedFileCache;
|
||||
}
|
||||
|
||||
|
||||
// Deselect SD to free SPI bus
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
@@ -1215,6 +1674,17 @@ public:
|
||||
if (!_fileOpen) {
|
||||
_selectedFile = 0;
|
||||
_mode = FILE_LIST;
|
||||
} else if (_pagePositions.empty()) {
|
||||
// Layout was invalidated (orientation change) — reindex the open book
|
||||
Serial.println("TextReader: Reindexing after layout change");
|
||||
_pagePositions.push_back(0);
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
_totalPages = _pagePositions.size();
|
||||
if (_currentPage >= _totalPages) _currentPage = 0;
|
||||
_mode = READING;
|
||||
loadPageContent();
|
||||
} else {
|
||||
_mode = READING;
|
||||
loadPageContent();
|
||||
@@ -1225,6 +1695,49 @@ 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;
|
||||
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = startY;
|
||||
#else
|
||||
const int bodyTop = startY + (_prefs ? _prefs->smallHighlightOff() : 5);
|
||||
#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);
|
||||
@@ -1249,6 +1762,7 @@ public:
|
||||
if (_mode == FILE_LIST) {
|
||||
return handleFileListInput(c);
|
||||
} else if (_mode == READING) {
|
||||
if (_gotoMode) return handleGotoInput(c);
|
||||
return handleReadingInput(c);
|
||||
}
|
||||
return false;
|
||||
@@ -1337,7 +1851,8 @@ public:
|
||||
cache.pagePositions.push_back(0);
|
||||
indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1);
|
||||
PREINDEX_PAGES - 1,
|
||||
_textAreaHeight, _lineHeight);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
|
||||
@@ -1363,9 +1878,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();
|
||||
@@ -1374,6 +1889,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();
|
||||
@@ -1384,6 +1907,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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,14 @@
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AlarmScreen.h"
|
||||
#endif
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include "VirtualKeyboard.h"
|
||||
#endif
|
||||
|
||||
// MapScreen.h included in UITask.cpp and main.cpp only (PNGdec headers
|
||||
// conflict with BLE if pulled into the global include chain)
|
||||
|
||||
@@ -52,6 +60,9 @@ class UITask : public AbstractUITask {
|
||||
NodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
unsigned long _alert_expiry;
|
||||
bool _hintActive = false; // Boot navigation hint overlay
|
||||
unsigned long _hintExpiry = 0; // Auto-dismiss time for hint
|
||||
bool _pendingBootHint = false; // Deferred hint — show after splash screen
|
||||
int _msgcount;
|
||||
unsigned long ui_started_at, next_batt_chck;
|
||||
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
|
||||
@@ -75,16 +86,70 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* notes_screen; // Notes editor screen
|
||||
UIScreen* settings_screen; // Settings/onboarding screen
|
||||
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
UIScreen* alarm_screen; // Alarm clock screen (audio variant only)
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
|
||||
#endif
|
||||
UIScreen* repeater_admin; // Repeater admin screen
|
||||
UIScreen* discovery_screen; // Node discovery scan screen
|
||||
UIScreen* last_heard_screen; // Last heard passive advert list
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
|
||||
#endif
|
||||
UIScreen* map_screen; // Map tile screen (GPS + SD card tiles)
|
||||
UIScreen* curr;
|
||||
bool _homeShowingTiles = false; // Set by HomeScreen render when tile grid is visible
|
||||
int _tileGridVY = 44; // Virtual Y of tile grid top (updated each render)
|
||||
#if defined(LilyGo_T5S3_EPaper_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
|
||||
|
||||
VirtualKeyboard _vkb;
|
||||
bool _vkbActive = false;
|
||||
UIScreen* _screenBeforeVKB = nullptr;
|
||||
unsigned long _vkbOpenedAt = 0;
|
||||
|
||||
// Powersaving: light sleep when locked + idle (standalone only — no BLE/WiFi)
|
||||
// Wakes on LoRa packet (DIO1), boot button (GPIO0), or 30-min timer
|
||||
#if !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
|
||||
unsigned long _psLastActive = 0; // millis() at last wake or lock entry
|
||||
unsigned long _psNextSleepSecs = 60; // Seconds before first sleep (60s), then 5s cycles
|
||||
#endif
|
||||
#ifdef MECK_CARDKB
|
||||
bool _cardkbDetected = false;
|
||||
#endif
|
||||
#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();
|
||||
|
||||
@@ -113,15 +178,24 @@ public:
|
||||
|
||||
void gotoHomeScreen();
|
||||
void gotoChannelScreen(); // Navigate to channel message screen
|
||||
void gotoDMTab(); // Navigate directly to DM tab on channel screen
|
||||
void gotoDMConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0);
|
||||
void gotoContactsScreen(); // Navigate to contacts list
|
||||
void gotoTextReader(); // *** NEW: Navigate to text reader ***
|
||||
void gotoNotesScreen(); // Navigate to notes editor
|
||||
void gotoSettingsScreen(); // Navigate to settings
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
void gotoAlarmScreen(); // Navigate to alarm clock
|
||||
#endif
|
||||
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
|
||||
#ifdef MECK_WEB_READER
|
||||
void gotoWebReader(); // Navigate to web reader (browser)
|
||||
#endif
|
||||
@@ -132,6 +206,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() {
|
||||
@@ -140,17 +217,52 @@ 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; }
|
||||
bool isOnContactsScreen() const { return curr == contacts_screen; }
|
||||
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
|
||||
bool isOnHomeScreen() const { return curr == home; }
|
||||
bool isHomeShowingTiles() const { return _homeShowingTiles; }
|
||||
void setHomeShowingTiles(bool v) { _homeShowingTiles = v; }
|
||||
int getTileGridVY() const { return _tileGridVY; }
|
||||
void setTileGridVY(int vy) { _tileGridVY = vy; }
|
||||
bool isOnNotesScreen() const { return curr == notes_screen; }
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
bool isOnAlarmScreen() const { return curr == alarm_screen; }
|
||||
#endif
|
||||
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) || 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; }
|
||||
#endif
|
||||
@@ -168,12 +280,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;
|
||||
@@ -191,10 +306,16 @@ public:
|
||||
UIScreen* getContactsScreen() const { return contacts_screen; }
|
||||
UIScreen* getChannelScreen() const { return channel_screen; }
|
||||
UIScreen* getSettingsScreen() const { return settings_screen; }
|
||||
NodePrefs* getNodePrefs() const { return _node_prefs; }
|
||||
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
UIScreen* getAlarmScreen() const { return alarm_screen; }
|
||||
void setAlarmScreen(UIScreen* s) { alarm_screen = s; }
|
||||
#endif
|
||||
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; }
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
#include "ModemManager.h"
|
||||
#endif
|
||||
#include "Utf8CP437.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
@@ -1030,8 +1031,10 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Mode _mode;
|
||||
bool _initialized;
|
||||
uint8_t _lastFontPref;
|
||||
DisplayDriver* _display;
|
||||
|
||||
// Display layout (calculated once)
|
||||
@@ -1424,7 +1427,7 @@ private:
|
||||
_display->print("WiFi Setup");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Scanning for networks...");
|
||||
_display->endFrame();
|
||||
@@ -1524,7 +1527,7 @@ private:
|
||||
_display->print("Web Reader");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Connected!");
|
||||
_display->setCursor(0, 30);
|
||||
@@ -2306,7 +2309,7 @@ private:
|
||||
_display->print("Web Reader");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Fetch failed:");
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
@@ -2442,7 +2445,7 @@ private:
|
||||
_display->setTextSize(2);
|
||||
_display->setCursor(10, 20);
|
||||
_display->print("Logging in...");
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setCursor(10, 45);
|
||||
_display->print("Refreshing session...");
|
||||
@@ -2656,19 +2659,23 @@ private:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
if (_wifiState == WIFI_SCANNING) {
|
||||
display.setCursor(0, 18);
|
||||
display.print("Scanning for networks...");
|
||||
} else if (_wifiState == WIFI_SCAN_DONE) {
|
||||
int y = 14;
|
||||
int listLineH = 8;
|
||||
int listLineH = _prefs ? _prefs->smallLineH() : 9;
|
||||
for (int i = 0; i < _ssidCount && y < display.height() - 24; i++) {
|
||||
bool selected = (i == _selectedSSID);
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -2691,7 +2698,7 @@ private:
|
||||
y += 12;
|
||||
display.setCursor(0, y);
|
||||
display.print("Password:");
|
||||
y += 10;
|
||||
y += _prefs->smallLineH() + 1;
|
||||
display.setCursor(0, y);
|
||||
// Show masked password with brief reveal of last char
|
||||
char passBuf[WEB_WIFI_PASS_LEN + 2];
|
||||
@@ -2736,7 +2743,11 @@ private:
|
||||
}
|
||||
display.setCursor(0, 80);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap: Retry");
|
||||
#else
|
||||
display.print("Enter: Retry Q: Back");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Footer
|
||||
@@ -2745,7 +2756,14 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
if (_wifiState == WIFI_ENTERING_PASS)
|
||||
display.print("Tap: Enter Password Hold: Back");
|
||||
else
|
||||
display.print("Swipe: Navigate Tap: Select");
|
||||
#else
|
||||
display.print("Q:Back W/S:Nav Ent:Select");
|
||||
#endif
|
||||
}
|
||||
|
||||
void renderHome(DisplayDriver& display) {
|
||||
@@ -2756,7 +2774,7 @@ private:
|
||||
|
||||
if (isNetworkAvailable()) {
|
||||
display.print("Web Reader");
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
if (isWiFiConnected()) {
|
||||
IPAddress ip = WiFi.localIP();
|
||||
@@ -2782,7 +2800,7 @@ private:
|
||||
const int footerY = display.height() - 12;
|
||||
const int viewportH = display.height() - headerY - footerH;
|
||||
const int scrollbarW = 4;
|
||||
const int listLineH = 8;
|
||||
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
|
||||
const int sepH = 8; // Separator between IRC and web sections
|
||||
const int sectionH = listLineH; // Section header height
|
||||
int maxChars = _charsPerLine - 2; // Account for "> " prefix
|
||||
@@ -2860,7 +2878,7 @@ private:
|
||||
if (totalContentH <= viewportH) _homeScrollY = 0;
|
||||
|
||||
// ---- Render pass (with scroll offset) ----
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int y = headerY - _homeScrollY; // Start Y in screen coords
|
||||
itemIdx = 0;
|
||||
bool needsScroll = (totalContentH > viewportH);
|
||||
@@ -2877,7 +2895,11 @@ private:
|
||||
if (HOME_VISIBLE(y, ircH)) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -2912,7 +2934,11 @@ private:
|
||||
if (HOME_VISIBLE(y, urlBarH)) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -2945,7 +2971,11 @@ private:
|
||||
if (HOME_VISIBLE(y, searchBarH)) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -2994,7 +3024,11 @@ private:
|
||||
int contentW = display.width() - (needsScroll ? scrollbarW + 1 : 0);
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, contentW, itemH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, itemH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -3042,7 +3076,11 @@ private:
|
||||
int contentW = display.width() - (needsScroll ? scrollbarW + 1 : 0);
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, contentW, itemH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, itemH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -3108,6 +3146,19 @@ private:
|
||||
display.print("Type query Ent:Search");
|
||||
} else {
|
||||
char footerBuf[48];
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
bool onBookmark = (_homeSelected >= 3 && _homeSelected < 3 + (int)_bookmarks.size());
|
||||
bool onUrl = (_homeSelected == 1);
|
||||
bool onSearch = (_homeSelected == 2);
|
||||
if (onUrl)
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Tap: Enter URL Hold: Back");
|
||||
else if (onSearch)
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Tap: Search Hold: Back");
|
||||
else if (onBookmark)
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Swipe: Navigate Tap: Open Hold: Delete");
|
||||
else
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Swipe: Navigate Tap: Open Hold: Exit");
|
||||
#else
|
||||
bool hasData = (_cookieCount > 0 || !_history.empty());
|
||||
bool onBookmark = (_homeSelected >= 3 && _homeSelected < 3 + (int)_bookmarks.size());
|
||||
if (onBookmark && hasData)
|
||||
@@ -3118,6 +3169,7 @@ private:
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S Ent:Go X:Clr Ckies");
|
||||
else
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S:Nav Ent:Go");
|
||||
#endif
|
||||
display.print(footerBuf);
|
||||
}
|
||||
|
||||
@@ -3149,17 +3201,27 @@ private:
|
||||
display.setCursor(10, 20);
|
||||
display.print("Loading...");
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(10, 45);
|
||||
|
||||
// Show truncated URL
|
||||
char urlDisp[40];
|
||||
strncpy(urlDisp, _urlBuffer, 38);
|
||||
urlDisp[38] = '\0';
|
||||
display.print(urlDisp);
|
||||
// Word-wrap the URL across multiple lines
|
||||
int urlLen = strlen(_urlBuffer);
|
||||
int y = 45;
|
||||
int off = 0;
|
||||
int maxChars = _charsPerLine > 2 ? _charsPerLine - 2 : 30; // small margin
|
||||
while (off < urlLen && y < 85) {
|
||||
int lineLen = urlLen - off;
|
||||
if (lineLen > maxChars) lineLen = maxChars;
|
||||
char lineBuf[128];
|
||||
snprintf(lineBuf, sizeof(lineBuf), "%.*s", lineLen, _urlBuffer + off);
|
||||
display.setCursor(10, y);
|
||||
display.print(lineBuf);
|
||||
off += lineLen;
|
||||
y += 8;
|
||||
}
|
||||
|
||||
display.setCursor(10, 60);
|
||||
display.setCursor(10, y + 4);
|
||||
display.setTextSize(1);
|
||||
char progBuf[48];
|
||||
int elapsed = (int)((millis() - _fetchStartTime) / 1000);
|
||||
if (_fetchRetryCount > 0) {
|
||||
@@ -3184,7 +3246,7 @@ private:
|
||||
display.print("Download Complete");
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 16);
|
||||
display.print("Saved to /books/:");
|
||||
@@ -3206,15 +3268,19 @@ private:
|
||||
|
||||
display.setCursor(0, y + 6);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap: Open in Reader");
|
||||
#else
|
||||
display.print("Ent: Open in Reader");
|
||||
display.setCursor(0, y + 16);
|
||||
display.print("Q: Back to browser");
|
||||
#endif
|
||||
} else {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("Download Failed");
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 18);
|
||||
display.print(_fetchError.c_str());
|
||||
@@ -3223,7 +3289,11 @@ private:
|
||||
|
||||
display.setCursor(0, 56);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap: Back to browser");
|
||||
#else
|
||||
display.print("Q: Back to browser");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Footer
|
||||
@@ -3232,7 +3302,11 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print(_downloadOk ? "Tap: Open in Reader" : "Tap: Back");
|
||||
#else
|
||||
display.print(_downloadOk ? "Ent:Read Q:Back" : "Q:Back");
|
||||
#endif
|
||||
}
|
||||
|
||||
void renderReading(DisplayDriver& display) {
|
||||
@@ -3243,7 +3317,7 @@ private:
|
||||
return;
|
||||
}
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Determine page bounds
|
||||
@@ -3360,6 +3434,13 @@ private:
|
||||
if (_linkInputActive) {
|
||||
snprintf(linkBuf, sizeof(linkBuf), "#%d_ Ent:Go", _linkInput);
|
||||
hint = linkBuf;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
} else if (_linkCount > 0) {
|
||||
hint = "Tap: Page | Tap Footer Bar: Enter Link # | Hold: Back";
|
||||
} else {
|
||||
hint = "Tap: Page Hold: Back";
|
||||
}
|
||||
#else
|
||||
} else if (_formCount > 0 && _linkCount > 0) {
|
||||
hint = "L:Lnk F:Frm B:Bk Q:X";
|
||||
} else if (_formCount > 0) {
|
||||
@@ -3369,6 +3450,7 @@ private:
|
||||
} else {
|
||||
hint = "B:Bk Q:X";
|
||||
}
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(hint) - 2, footerY);
|
||||
display.print(hint);
|
||||
|
||||
@@ -3397,9 +3479,16 @@ private:
|
||||
// ---- Layout Initialization ----
|
||||
|
||||
void initLayout(DisplayDriver& display) {
|
||||
// Re-init if font preference changed since last layout
|
||||
uint8_t curFont = _prefs ? _prefs->large_font : 0;
|
||||
if (_initialized && curFont != _lastFontPref) {
|
||||
_initialized = false;
|
||||
Serial.println("WebReader: font changed, recalculating layout");
|
||||
}
|
||||
if (_initialized) return;
|
||||
_lastFontPref = curFont;
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
uint16_t mWidth = display.getTextWidth("M");
|
||||
if (mWidth > 0) {
|
||||
_charsPerLine = display.width() / mWidth;
|
||||
@@ -3408,6 +3497,19 @@ private:
|
||||
_charsPerLine = 40;
|
||||
_lineHeight = 5;
|
||||
}
|
||||
// Proportional font: use average-width measurement instead of M-width
|
||||
if (_prefs && _prefs->large_font && mWidth > 0) {
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
|
||||
if (_prefs && _prefs->large_font) {
|
||||
_lineHeight = _prefs->smallLineH();
|
||||
}
|
||||
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _footerHeight;
|
||||
@@ -3852,7 +3954,7 @@ private:
|
||||
if (_activeForm < 0 || _activeForm >= _formCount) return;
|
||||
WebForm& form = _forms[_activeForm];
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
// Header
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -3875,7 +3977,7 @@ private:
|
||||
display.drawRect(0, 9, display.width(), 1);
|
||||
|
||||
int y = 12;
|
||||
int lineH = 10; // Taller lines for form fields
|
||||
int lineH = _prefs->smallLineH() + 1; // Taller lines for form fields
|
||||
int visCount = getVisibleFieldCount(form);
|
||||
|
||||
// Render each visible field
|
||||
@@ -3895,7 +3997,11 @@ private:
|
||||
// Field value
|
||||
if (isActive) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), 9);
|
||||
#else
|
||||
display.fillRect(0, y + 4, display.width(), 9);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -3965,10 +4071,14 @@ private:
|
||||
display.print("Type text Ent:Next Q:Undo");
|
||||
} else {
|
||||
const char* hint;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
hint = "Swipe: Navigate Tap: Edit Hold: Back";
|
||||
#else
|
||||
if (_formCount > 1)
|
||||
hint = "W/S:Nav Ent:Edit </>:Form Q:Back";
|
||||
else
|
||||
hint = "W/S:Nav Ent:Edit/Go Q:Back";
|
||||
#endif
|
||||
display.print(hint);
|
||||
}
|
||||
}
|
||||
@@ -4575,9 +4685,9 @@ private:
|
||||
display.print("IRC Setup");
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int y = 16;
|
||||
int lineH = 10;
|
||||
int lineH = _prefs->smallLineH() + 1;
|
||||
|
||||
const char* labels[] = {"Server:", "Port:", "Nick:", "Channel:", "[ Connect ]"};
|
||||
const char* chanDisp = (_ircChannel[0] != '\0') ? _ircChannel : "(none)";
|
||||
@@ -4589,7 +4699,11 @@ private:
|
||||
bool sel = (_ircSetupField == i);
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineH);
|
||||
#else
|
||||
display.fillRect(0, y + 4, display.width(), lineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -4637,7 +4751,11 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe: Navigate Tap: Edit Hold: Back");
|
||||
#else
|
||||
display.print("W/S:Nav Ent:Edit/Go Q:Back");
|
||||
#endif
|
||||
}
|
||||
|
||||
bool handleIRCSetupInput(char c) {
|
||||
@@ -4727,7 +4845,7 @@ private:
|
||||
display.print(header);
|
||||
|
||||
// Connection indicator on right
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
if (!_ircConnected) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(display.width() - 42, -3);
|
||||
@@ -4753,7 +4871,7 @@ private:
|
||||
|
||||
if (_ircComposing) {
|
||||
// Compose text just above separator (tiny font to match messages)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, footerY - 12);
|
||||
char compDisp[IRC_COMPOSE_MAX + 4];
|
||||
@@ -4767,18 +4885,26 @@ private:
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap: Send Hold: Exit");
|
||||
#else
|
||||
display.print("Ent:Send Del:Exit");
|
||||
#endif
|
||||
} else {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap: Compose Swipe: Scroll Hold: Back");
|
||||
#else
|
||||
display.print("Ent:Msg W/S:Scrl Q:Bk");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Message area
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int msgAreaTop = 14;
|
||||
int msgAreaBottom = _ircComposing ? footerY - 16 : footerY - 4;
|
||||
int lineH = 8;
|
||||
int lineH = _prefs->smallLineH() - 1;
|
||||
int scrollBarW = 4;
|
||||
int lineW = _charsPerLine - 1; // Reserve space for scroll bar
|
||||
_ircLinesPerPage = (msgAreaBottom - msgAreaTop) / lineH;
|
||||
@@ -4962,8 +5088,8 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
WebReaderScreen(UITask* task)
|
||||
: _task(task), _mode(HOME), _initialized(false), _display(nullptr),
|
||||
WebReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _mode(HOME), _initialized(false), _lastFontPref(0), _display(nullptr),
|
||||
_charsPerLine(40), _linesPerPage(15), _lineHeight(5), _footerHeight(14),
|
||||
_wifiState(WIFI_IDLE), _ssidCount(0), _selectedSSID(0), _wifiPassLen(0),
|
||||
_urlLen(0), _urlCursor(0),
|
||||
@@ -5047,7 +5173,7 @@ public:
|
||||
_display->print("Web Reader");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Connecting to WiFi...");
|
||||
_display->endFrame();
|
||||
@@ -5159,6 +5285,33 @@ public:
|
||||
return (_mode == IRC_CHAT && _ircComposing) ||
|
||||
(_mode == IRC_SETUP && _ircSetupEditing);
|
||||
}
|
||||
|
||||
// ---- Accessors for T5S3 touch mapping and VKB integration ----
|
||||
int getHomeSelected() const { return _homeSelected; }
|
||||
int getLinkCount() const { return _linkCount; }
|
||||
int getBookmarkCount() const { return (int)_bookmarks.size(); }
|
||||
const char* getUrlText() const { return _urlBuffer; }
|
||||
|
||||
// Set URL text and activate editing mode (for VKB submit)
|
||||
void setUrlText(const char* text) {
|
||||
strncpy(_urlBuffer, text, WEB_MAX_URL_LEN - 1);
|
||||
_urlBuffer[WEB_MAX_URL_LEN - 1] = '\0';
|
||||
_urlLen = strlen(_urlBuffer);
|
||||
_urlEditing = true;
|
||||
}
|
||||
// Set search text and activate editing mode (for VKB submit)
|
||||
void setSearchText(const char* text) {
|
||||
strncpy(_searchBuffer, text, sizeof(_searchBuffer) - 1);
|
||||
_searchBuffer[sizeof(_searchBuffer) - 1] = '\0';
|
||||
_searchLen = strlen(_searchBuffer);
|
||||
_searchEditing = true;
|
||||
}
|
||||
// Set WiFi password text (for VKB submit)
|
||||
void setWifiPassText(const char* text) {
|
||||
strncpy(_wifiPass, text, WEB_WIFI_PASS_LEN - 1);
|
||||
_wifiPass[WEB_WIFI_PASS_LEN - 1] = '\0';
|
||||
_wifiPassLen = strlen(_wifiPass);
|
||||
}
|
||||
// Returns true if a password reveal is active and needs a refresh after expiry
|
||||
bool needsRevealRefresh() const {
|
||||
if (_formLastCharAt > 0 && (millis() - _formLastCharAt) < 900) {
|
||||
|
||||
63
examples/companion_radio/ui-new/homeicons.h
Normal file
63
examples/companion_radio/ui-new/homeicons.h
Normal file
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// HomeIcons — 12x12 icon sprites for T5S3 home screen tiles
|
||||
// MSB-first, 2 bytes per row (same format as emoji sprites)
|
||||
// =============================================================================
|
||||
|
||||
#include <stdint.h>
|
||||
#ifdef ESP32
|
||||
#include <pgmspace.h>
|
||||
#endif
|
||||
|
||||
#define HOME_ICON_W 12
|
||||
#define HOME_ICON_H 12
|
||||
|
||||
// ✉️ Envelope (Messages)
|
||||
static const uint8_t icon_envelope[] PROGMEM = {
|
||||
0xFF,0xF0, 0x80,0x10, 0xC0,0x30, 0xA0,0x50, 0x90,0x90, 0x89,0x10,
|
||||
0x86,0x10, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0xFF,0xF0,
|
||||
};
|
||||
|
||||
// 👥 People (Contacts)
|
||||
static const uint8_t icon_people[] PROGMEM = {
|
||||
0x31,0x80, 0x7B,0xC0, 0x7B,0xC0, 0x31,0x80, 0x00,0x00, 0x7B,0xC0,
|
||||
0xFD,0xE0, 0xFD,0xE0, 0x7B,0xC0, 0x00,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
|
||||
// 🎚 Sliders (Settings)
|
||||
static const uint8_t icon_gear[] PROGMEM = {
|
||||
0x22,0x20, 0x22,0x20, 0x72,0x70, 0x72,0x70, 0x27,0x20, 0x27,0x20,
|
||||
0x22,0x20, 0x72,0x20, 0x72,0x70, 0x22,0x70, 0x22,0x20, 0x22,0x20,
|
||||
};
|
||||
|
||||
// 📖 Book (Reader)
|
||||
static const uint8_t icon_book[] PROGMEM = {
|
||||
0x7F,0xC0, 0x41,0x40, 0x5D,0x40, 0x5D,0x40, 0x41,0x40, 0x5D,0x40,
|
||||
0x5D,0x40, 0x41,0x40, 0x5D,0x40, 0x41,0x40, 0x7F,0xC0, 0x00,0x00,
|
||||
};
|
||||
|
||||
// 🗒 Notepad (Notes)
|
||||
static const uint8_t icon_notepad[] PROGMEM = {
|
||||
0x3F,0xC0, 0x20,0x40, 0x2F,0x40, 0x20,0x40, 0x2F,0x40, 0x20,0x40,
|
||||
0x2F,0x40, 0x20,0x40, 0x2F,0x40, 0x20,0x40, 0x3F,0xC0, 0x00,0x00,
|
||||
};
|
||||
|
||||
// 🔍 Magnifying glass (Discover)
|
||||
static const uint8_t icon_search[] PROGMEM = {
|
||||
0x3C,0x00, 0x42,0x00, 0x81,0x00, 0x81,0x00, 0x81,0x00, 0x42,0x00,
|
||||
0x3C,0x00, 0x03,0x00, 0x01,0x80, 0x00,0xC0, 0x00,0x40, 0x00,0x00,
|
||||
};
|
||||
|
||||
// ⏰ Alarm Clock (AlarmScreen) — 12x12 home tile icon
|
||||
static const uint8_t icon_alarm[] PROGMEM = {
|
||||
0x40,0x40, 0x9E,0x20, 0x20,0x80, 0x44,0x40, 0x44,0x40, 0x46,0x40,
|
||||
0x40,0x40, 0x20,0x80, 0x1F,0x00, 0x00,0x00, 0x20,0x40, 0x40,0x20,
|
||||
};
|
||||
|
||||
// 🔔 Bell — 7x8 status bar indicator (alarm enabled)
|
||||
// MSB-first, 1 byte per row
|
||||
#define BELL_ICON_W 7
|
||||
#define BELL_ICON_H 8
|
||||
static const uint8_t icon_bell_small[] PROGMEM = {
|
||||
0x10, 0x38, 0x7C, 0x7C, 0x7C, 0xFE, 0x00, 0x10,
|
||||
};
|
||||
365
examples/companion_radio/ui-new/virtualkeyboard.h
Normal file
365
examples/companion_radio/ui-new/virtualkeyboard.h
Normal file
@@ -0,0 +1,365 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// VirtualKeyboard — On-screen QWERTY keyboard for T5S3 (touch-only devices)
|
||||
//
|
||||
// Renders in virtual coordinate space (128×128). Touch hit testing converts
|
||||
// physical GT911 coords (960×540) to virtual coords.
|
||||
//
|
||||
// Usage:
|
||||
// keyboard.open("To: General", "", 137); // label, initial text, max len
|
||||
// keyboard.render(display); // in render loop
|
||||
// keyboard.handleTap(vx, vy); // on touch tap (virtual coords)
|
||||
// if (keyboard.status() == VKB_SUBMITTED) { ... keyboard.getText() ... }
|
||||
// =============================================================================
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#ifndef VIRTUAL_KEYBOARD_H
|
||||
#define VIRTUAL_KEYBOARD_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
|
||||
enum VKBStatus { VKB_EDITING, VKB_SUBMITTED, VKB_CANCELLED };
|
||||
|
||||
// What the keyboard is being used for (dispatch on submit)
|
||||
enum VKBPurpose {
|
||||
VKB_CHANNEL_MSG, // Send to channel
|
||||
VKB_DM, // Direct message to contact
|
||||
VKB_ADMIN_PASSWORD, // Repeater admin login
|
||||
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
|
||||
VKB_WEB_SEARCH, // Web reader DuckDuckGo search query
|
||||
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 {
|
||||
public:
|
||||
static const int MAX_TEXT = 140;
|
||||
|
||||
VirtualKeyboard() : _status(VKB_CANCELLED), _purpose(VKB_CHANNEL_MSG),
|
||||
_contextIdx(0), _textLen(0), _shifted(false), _symbols(false) {
|
||||
_text[0] = '\0';
|
||||
_label[0] = '\0';
|
||||
}
|
||||
|
||||
void open(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx = 0) {
|
||||
_purpose = purpose;
|
||||
_contextIdx = contextIdx;
|
||||
_status = VKB_EDITING;
|
||||
_shifted = false;
|
||||
_symbols = false;
|
||||
_maxLen = (maxLen > 0 && maxLen < MAX_TEXT) ? maxLen : MAX_TEXT;
|
||||
|
||||
strncpy(_label, label, sizeof(_label) - 1);
|
||||
_label[sizeof(_label) - 1] = '\0';
|
||||
|
||||
if (initial && initial[0]) {
|
||||
strncpy(_text, initial, _maxLen);
|
||||
_text[_maxLen] = '\0';
|
||||
_textLen = strlen(_text);
|
||||
} else {
|
||||
_text[0] = '\0';
|
||||
_textLen = 0;
|
||||
}
|
||||
}
|
||||
|
||||
VKBStatus status() const { return _status; }
|
||||
VKBPurpose purpose() const { return _purpose; }
|
||||
int contextIdx() const { return _contextIdx; }
|
||||
const char* getText() const { return _text; }
|
||||
int getTextLen() const { return _textLen; }
|
||||
bool isActive() const { return _status == VKB_EDITING; }
|
||||
|
||||
// --- Render keyboard + input field ---
|
||||
void render(DisplayDriver& display) {
|
||||
// Header label (To: channel, DM: name, etc.)
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(2, 0);
|
||||
display.print(_label);
|
||||
|
||||
// Input text field
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, 10, 128, 18); // Border
|
||||
|
||||
display.setCursor(2, 12);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Show text with cursor
|
||||
char dispBuf[MAX_TEXT + 2];
|
||||
snprintf(dispBuf, sizeof(dispBuf), "%s_", _text);
|
||||
display.print(dispBuf);
|
||||
|
||||
// Character count
|
||||
{
|
||||
char countBuf[12];
|
||||
snprintf(countBuf, sizeof(countBuf), "%d/%d", _textLen, _maxLen);
|
||||
int cw = display.getTextWidth(countBuf);
|
||||
display.setCursor(128 - cw - 2, 0);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print(countBuf);
|
||||
}
|
||||
|
||||
// Separator
|
||||
display.drawRect(0, 30, 128, 1);
|
||||
|
||||
// --- Draw keyboard rows ---
|
||||
const char* const* layout = getLayout();
|
||||
|
||||
for (int row = 0; row < 3; row++) {
|
||||
int numKeys = strlen(layout[row]);
|
||||
int rowY = KEY_START_Y + row * (KEY_H + KEY_GAP);
|
||||
|
||||
// Calculate key width and starting X for this row
|
||||
int totalW = numKeys * KEY_W + (numKeys - 1) * KEY_GAP;
|
||||
int startX = (128 - totalW) / 2;
|
||||
|
||||
for (int k = 0; k < numKeys; k++) {
|
||||
int kx = startX + k * (KEY_W + KEY_GAP);
|
||||
char ch = layout[row][k];
|
||||
|
||||
// Draw key background (inverted for special keys)
|
||||
bool special = (ch == '<' || ch == '^' || ch == '~' || ch == '>' || ch == '\x01');
|
||||
if (special) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(kx, rowY + 1, KEY_W, KEY_H - 1);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(kx, rowY + 1, KEY_W, KEY_H - 1);
|
||||
}
|
||||
|
||||
// Draw key label
|
||||
char keyLabel[2] = { ch, '\0' };
|
||||
// Remap special chars to display labels
|
||||
if (ch == '<') keyLabel[0] = '<'; // Backspace
|
||||
if (ch == '^') keyLabel[0] = '^'; // Shift
|
||||
if (ch == '>') keyLabel[0] = '>'; // Enter
|
||||
|
||||
if (ch == '~') {
|
||||
// Space key — don't draw individual label
|
||||
} else if (ch == '\x01') {
|
||||
// Symbol toggle in row — show "ab" hint
|
||||
int lx = kx + KEY_W / 2 - display.getTextWidth("ab") / 2;
|
||||
display.setCursor(lx, rowY + 2);
|
||||
display.print("ab");
|
||||
} else {
|
||||
int lx = kx + KEY_W / 2 - display.getTextWidth(keyLabel) / 2;
|
||||
display.setCursor(lx, rowY + 2);
|
||||
display.print(keyLabel);
|
||||
}
|
||||
|
||||
// Restore color
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw row 4 with variable-width keys
|
||||
int r4y = KEY_START_Y + 3 * (KEY_H + KEY_GAP);
|
||||
drawRow4(display, r4y);
|
||||
|
||||
// Shift/symbol indicator
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
if (_shifted) {
|
||||
display.setCursor(2, 126);
|
||||
display.print("SHIFT");
|
||||
} else if (_symbols) {
|
||||
display.setCursor(2, 126);
|
||||
display.print("123");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handle touch tap (virtual coordinates) ---
|
||||
// Returns true if the tap was consumed
|
||||
bool handleTap(int vx, int vy) {
|
||||
if (_status != VKB_EDITING) return false;
|
||||
|
||||
// Check keyboard rows 0-2
|
||||
const char* const* layout = getLayout();
|
||||
|
||||
for (int row = 0; row < 3; row++) {
|
||||
int numKeys = strlen(layout[row]);
|
||||
int rowY = KEY_START_Y + row * (KEY_H + KEY_GAP);
|
||||
if (vy < rowY || vy >= rowY + KEY_H) continue;
|
||||
|
||||
int totalW = numKeys * KEY_W + (numKeys - 1) * KEY_GAP;
|
||||
int startX = (128 - totalW) / 2;
|
||||
|
||||
for (int k = 0; k < numKeys; k++) {
|
||||
int kx = startX + k * (KEY_W + KEY_GAP);
|
||||
if (vx >= kx && vx < kx + KEY_W) {
|
||||
char ch = layout[row][k];
|
||||
processKey(ch);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true; // Tap was in row area but between keys — consume
|
||||
}
|
||||
|
||||
// Check row 4 (variable width keys)
|
||||
int r4y = KEY_START_Y + 3 * (KEY_H + KEY_GAP);
|
||||
if (vy >= r4y && vy < r4y + KEY_H) {
|
||||
return handleRow4Tap(vx);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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;
|
||||
int _contextIdx;
|
||||
char _text[MAX_TEXT + 1];
|
||||
int _textLen;
|
||||
int _maxLen;
|
||||
char _label[40];
|
||||
bool _shifted;
|
||||
bool _symbols;
|
||||
|
||||
// Layout constants (virtual coords)
|
||||
static const int KEY_W = 11;
|
||||
static const int KEY_H = 19;
|
||||
static const int KEY_GAP = 1;
|
||||
static const int KEY_START_Y = 34;
|
||||
|
||||
// Key layouts — rows 0-2 as char arrays
|
||||
// Special: ^ = shift, < = backspace, # = symbols, > = enter, ~ = space
|
||||
const char* const* getLayout() const {
|
||||
static const char* const lower[3] = { "qwertyuiop", "asdfghjkl", "^zxcvbnm<" };
|
||||
static const char* const upper[3] = { "QWERTYUIOP", "ASDFGHJKL", "^ZXCVBNM<" };
|
||||
static const char* const syms[3] = { "1234567890", "-/:;()@$&#", "\x01.,?!'\"_<" };
|
||||
return _symbols ? syms : (_shifted ? upper : lower);
|
||||
}
|
||||
|
||||
// Row 4: variable-width keys [#/ABC] [,] [SPACE] [.] [Enter]
|
||||
// Defined by physical zones, not the char-array approach
|
||||
struct R4Key { int x; int w; char ch; const char* label; };
|
||||
|
||||
void drawRow4(DisplayDriver& display, int y) {
|
||||
// # or ABC toggle: x=4, w=20
|
||||
// comma: x=26, w=11
|
||||
// space: x=39, w=50
|
||||
// period: x=91, w=11
|
||||
// enter: x=104, w=20
|
||||
const R4Key keys[] = {
|
||||
{ 4, 20, '\x01', _symbols ? "ABC" : "123" },
|
||||
{ 26, 11, ',', "," },
|
||||
{ 39, 50, '~', "space" },
|
||||
{ 91, 11, '.', "." },
|
||||
{ 104, 20, '>', "Send" }
|
||||
};
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
bool special = (keys[i].ch == '\x01' || keys[i].ch == '>');
|
||||
if (special) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(keys[i].x, y + 1, keys[i].w, KEY_H - 1);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(keys[i].x, y + 1, keys[i].w, KEY_H - 1);
|
||||
}
|
||||
|
||||
// Center label in key
|
||||
display.setTextSize(0);
|
||||
int lw = display.getTextWidth(keys[i].label);
|
||||
int lx = keys[i].x + (keys[i].w - lw) / 2;
|
||||
display.setCursor(lx, y + 2);
|
||||
display.print(keys[i].label);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
bool handleRow4Tap(int vx) {
|
||||
const R4Key keys[] = {
|
||||
{ 4, 20, '\x01', nullptr },
|
||||
{ 26, 11, ',', nullptr },
|
||||
{ 39, 50, '~', nullptr },
|
||||
{ 91, 11, '.', nullptr },
|
||||
{ 104, 20, '>', nullptr }
|
||||
};
|
||||
for (int i = 0; i < 5; i++) {
|
||||
if (vx >= keys[i].x && vx < keys[i].x + keys[i].w) {
|
||||
processKey(keys[i].ch);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true; // Consume tap in row area
|
||||
}
|
||||
|
||||
void processKey(char ch) {
|
||||
if (ch == '^') {
|
||||
// Shift toggle
|
||||
_shifted = !_shifted;
|
||||
_symbols = false;
|
||||
} else if (ch == '\x01') {
|
||||
// Symbol/letter toggle
|
||||
_symbols = !_symbols;
|
||||
_shifted = false;
|
||||
} else if (ch == '<') {
|
||||
// Backspace
|
||||
if (_textLen > 0) {
|
||||
_textLen--;
|
||||
_text[_textLen] = '\0';
|
||||
}
|
||||
} else if (ch == '>') {
|
||||
// Enter/Send
|
||||
_status = VKB_SUBMITTED;
|
||||
} else if (ch == '~') {
|
||||
// Space
|
||||
if (_textLen < _maxLen) {
|
||||
_text[_textLen++] = ' ';
|
||||
_text[_textLen] = '\0';
|
||||
}
|
||||
} else {
|
||||
// Regular character
|
||||
if (_textLen < _maxLen) {
|
||||
_text[_textLen++] = ch;
|
||||
_text[_textLen] = '\0';
|
||||
// Auto-unshift after typing one character
|
||||
if (_shifted) _shifted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#endif // VIRTUAL_KEYBOARD_H
|
||||
#endif // LilyGo_T5S3_EPaper_Pro
|
||||
159
merge_firmware.py
Normal file
159
merge_firmware.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
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
|
||||
|
||||
Place this file in the project root alongside platformio.ini.
|
||||
Add to each environment (or the base section):
|
||||
extra_scripts = post:merge_firmware.py
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
env_name = env.subst("$PIOENV")
|
||||
|
||||
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")
|
||||
|
||||
# Verify all inputs exist
|
||||
for f in [bootloader, partitions, firmware]:
|
||||
if not os.path.isfile(f):
|
||||
print(f"[merge] WARNING: {f} not found, skipping merge")
|
||||
return
|
||||
|
||||
# Read flash settings from board config
|
||||
flash_mode = env.BoardConfig().get("build.flash_mode", "qio")
|
||||
flash_freq = env.BoardConfig().get("build.f_flash", "80000000L").rstrip("L")
|
||||
flash_size = env.BoardConfig().get("upload.flash_size", "16MB")
|
||||
mcu = env.BoardConfig().get("build.mcu", "esp32s3")
|
||||
|
||||
# Convert numeric frequency to esptool format
|
||||
freq_map = {"80000000": "80m", "40000000": "40m", "26000000": "26m", "20000000": "20m"}
|
||||
flash_freq_str = freq_map.get(flash_freq, "80m")
|
||||
|
||||
cmd = [
|
||||
env.subst("$PYTHONEXE"), "-m", "esptool",
|
||||
"--chip", mcu,
|
||||
"merge_bin",
|
||||
"-o", output,
|
||||
"--flash_mode", flash_mode,
|
||||
"--flash_freq", flash_freq_str,
|
||||
"--flash_size", flash_size,
|
||||
"0x0", bootloader,
|
||||
"0x8000", partitions,
|
||||
"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[-8:])}")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
size_kb = os.path.getsize(output) / 1024
|
||||
print(f"[merge] OK: {output} ({size_kb:.0f} KB)")
|
||||
else:
|
||||
print(f"[merge] FAILED: {result.stderr}")
|
||||
|
||||
env.AddPostAction("$BUILD_DIR/firmware.bin", merge_bin)
|
||||
@@ -56,7 +56,7 @@ build_src_filter =
|
||||
[esp32_base]
|
||||
extends = arduino_base
|
||||
platform = platformio/espressif32@6.11.0
|
||||
monitor_filters = esp32_exception_decoder
|
||||
monitor_filters = esp32_exception_decoder, clock_sync
|
||||
extra_scripts = merge-bin.py
|
||||
build_flags = ${arduino_base.build_flags}
|
||||
-D ESP32_PLATFORM
|
||||
|
||||
BIN
readback.bin
Normal file
BIN
readback.bin
Normal file
Binary file not shown.
@@ -36,7 +36,7 @@ uint32_t Dispatcher::getCADFailRetryDelay() const {
|
||||
return 200;
|
||||
}
|
||||
uint32_t Dispatcher::getCADFailMaxDuration() const {
|
||||
return 4000; // 4 seconds
|
||||
return 6000; // 6 seconds
|
||||
}
|
||||
|
||||
void Dispatcher::loop() {
|
||||
@@ -52,10 +52,28 @@ void Dispatcher::loop() {
|
||||
prev_isrecv_mode = is_recv;
|
||||
if (!is_recv) {
|
||||
radio_nonrx_start = _ms->getMillis();
|
||||
} else {
|
||||
rx_stuck_count = 0; // radio recovered — reset counter
|
||||
}
|
||||
}
|
||||
if (!is_recv && _ms->getMillis() - radio_nonrx_start > 8000) { // radio has not been in Rx mode for 8 seconds!
|
||||
_err_flags |= ERR_EVENT_STARTRX_TIMEOUT;
|
||||
|
||||
rx_stuck_count++;
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): RX stuck (attempt %d), calling onRxStuck()", getLogDateTime(), rx_stuck_count);
|
||||
onRxStuck();
|
||||
|
||||
uint8_t reboot_threshold = getRxFailRebootThreshold();
|
||||
if (reboot_threshold > 0 && rx_stuck_count >= reboot_threshold) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): RX unrecoverable after %d attempts", getLogDateTime(), rx_stuck_count);
|
||||
onRxUnrecoverable();
|
||||
}
|
||||
|
||||
// Reset state to give recovery the full 8s window before re-triggering
|
||||
radio_nonrx_start = _ms->getMillis();
|
||||
prev_isrecv_mode = true;
|
||||
cad_busy_start = 0;
|
||||
next_agc_reset_time = futureMillis(getAGCResetInterval());
|
||||
}
|
||||
|
||||
if (outbound) { // waiting for outbound send to be completed
|
||||
@@ -273,14 +291,31 @@ void Dispatcher::checkSend() {
|
||||
outbound_start = _ms->getMillis();
|
||||
bool success = _radio->startSendRaw(raw, len);
|
||||
if (!success) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): ERROR: send start failed!", getLogDateTime());
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): ERROR: send start failed!", getLogDateTime());
|
||||
|
||||
logTxFail(outbound, outbound->getRawLength());
|
||||
|
||||
releasePacket(outbound); // return to pool
|
||||
|
||||
// re-queue packet for retry instead of dropping it
|
||||
int retry_delay = getCADFailRetryDelay();
|
||||
unsigned long retry_time = futureMillis(retry_delay);
|
||||
_mgr->queueOutbound(outbound, 0, retry_time);
|
||||
outbound = NULL;
|
||||
next_tx_time = retry_time;
|
||||
|
||||
// count consecutive failures and reset radio if stuck
|
||||
uint8_t threshold = getTxFailResetThreshold();
|
||||
if (threshold > 0) {
|
||||
tx_fail_count++;
|
||||
if (tx_fail_count >= threshold) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): TX stuck (%d failures), resetting radio", getLogDateTime(), tx_fail_count);
|
||||
onTxStuck();
|
||||
tx_fail_count = 0;
|
||||
next_tx_time = futureMillis(2000);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
tx_fail_count = 0; // clear counter on successful TX start
|
||||
outbound_expiry = futureMillis(max_airtime);
|
||||
|
||||
#if MESH_PACKET_LOGGING
|
||||
|
||||
@@ -122,6 +122,8 @@ class Dispatcher {
|
||||
bool prev_isrecv_mode;
|
||||
uint32_t n_sent_flood, n_sent_direct;
|
||||
uint32_t n_recv_flood, n_recv_direct;
|
||||
uint8_t tx_fail_count;
|
||||
uint8_t rx_stuck_count;
|
||||
|
||||
void processRecvPacket(Packet* pkt);
|
||||
|
||||
@@ -142,6 +144,8 @@ protected:
|
||||
_err_flags = 0;
|
||||
radio_nonrx_start = 0;
|
||||
prev_isrecv_mode = true;
|
||||
tx_fail_count = 0;
|
||||
rx_stuck_count = 0;
|
||||
}
|
||||
|
||||
virtual DispatcherAction onRecvPacket(Packet* pkt) = 0;
|
||||
@@ -159,6 +163,11 @@ protected:
|
||||
virtual uint32_t getCADFailMaxDuration() const;
|
||||
virtual int getInterferenceThreshold() const { return 0; } // disabled by default
|
||||
virtual int getAGCResetInterval() const { return 0; } // disabled by default
|
||||
virtual uint8_t getTxFailResetThreshold() const { return 3; } // reset radio after N consecutive TX failures; 0=disabled
|
||||
virtual void onTxStuck() { _radio->resetAGC(); } // override to use doFullRadioReset() when available
|
||||
virtual uint8_t getRxFailRebootThreshold() const { return 3; } // reboot after N failed RX recovery attempts; 0=disabled
|
||||
virtual void onRxStuck() { _radio->resetAGC(); } // called each time RX stuck for 8s; override for deeper reset
|
||||
virtual void onRxUnrecoverable() { } // called when reboot threshold exceeded; override to call _board->reboot()
|
||||
|
||||
public:
|
||||
void begin();
|
||||
@@ -188,4 +197,4 @@ private:
|
||||
void checkSend();
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,10 @@
|
||||
#endif
|
||||
|
||||
void BaseChatMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
sendFlood(pkt, delay_millis);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
}
|
||||
void BaseChatMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
sendFlood(pkt, delay_millis);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
}
|
||||
|
||||
mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) {
|
||||
@@ -56,6 +56,14 @@ void BaseChatMesh::sendAckTo(const ContactInfo& dest, uint32_t ack_hash) {
|
||||
}
|
||||
|
||||
void BaseChatMesh::bootstrapRTCfromContacts() {
|
||||
// If the RTC already has a sane time (e.g. hardware RTC like PCF8563, or
|
||||
// GPS-synced), don't overwrite it with a potentially stale contact lastmod.
|
||||
// This bootstrap is only useful for boards with no hardware RTC at all.
|
||||
uint32_t current = getRTCClock()->getCurrentTime();
|
||||
if (current > 1704067200UL) { // Jan 1 2024 — matches EPOCH_MIN_SANE
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t latest = 0;
|
||||
for (int i = 0; i < num_contacts; i++) {
|
||||
if (contacts[i].lastmod > latest) {
|
||||
|
||||
@@ -130,6 +130,7 @@ protected:
|
||||
virtual void onContactResponse(const ContactInfo& contact, const uint8_t* data, uint8_t len) = 0;
|
||||
virtual void handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len);
|
||||
|
||||
virtual uint8_t getPathHashSize() const = 0;
|
||||
virtual void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0);
|
||||
virtual void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <Wire.h>
|
||||
#include "esp_wifi.h"
|
||||
#include "driver/rtc_io.h"
|
||||
#include "driver/gpio.h"
|
||||
|
||||
class ESP32Board : public mesh::MainBoard {
|
||||
protected:
|
||||
@@ -60,13 +61,20 @@ public:
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32S3) && defined(P_LORA_DIO_1) // Supported ESP32 variants
|
||||
if (rtc_gpio_is_valid_gpio((gpio_num_t)P_LORA_DIO_1)) { // Only enter sleep mode if P_LORA_DIO_1 is RTC pin
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // To wake up when receiving a LoRa packet
|
||||
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // Wake on LoRa packet
|
||||
|
||||
// T5S3: Also wake on boot button press (GPIO0, active LOW).
|
||||
// gpio_wakeup uses level trigger — works for light sleep only.
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && defined(PIN_USER_BTN)
|
||||
gpio_wakeup_enable((gpio_num_t)PIN_USER_BTN, GPIO_INTR_LOW_LEVEL);
|
||||
esp_sleep_enable_gpio_wakeup();
|
||||
#endif
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000); // To wake up every hour to do periodically jobs
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000ULL); // Timer wake (microseconds)
|
||||
}
|
||||
|
||||
esp_light_sleep_start(); // CPU enters light sleep
|
||||
esp_light_sleep_start(); // CPU halts here, resumes on wake
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -154,4 +162,4 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -185,7 +185,7 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
#define BLE_WRITE_MIN_INTERVAL 60
|
||||
#define BLE_WRITE_MIN_INTERVAL 30
|
||||
|
||||
bool SerialBLEInterface::isWriteBusy() const {
|
||||
return millis() < _last_write + BLE_WRITE_MIN_INTERVAL; // still too soon to start another write?
|
||||
|
||||
@@ -23,7 +23,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
|
||||
uint8_t buf[MAX_FRAME_SIZE];
|
||||
};
|
||||
|
||||
#define FRAME_QUEUE_SIZE 4
|
||||
#define FRAME_QUEUE_SIZE 8
|
||||
int recv_queue_len;
|
||||
Frame recv_queue[FRAME_QUEUE_SIZE];
|
||||
int send_queue_len;
|
||||
|
||||
347
src/helpers/ui/FastEPDDisplay.cpp
Normal file
347
src/helpers/ui/FastEPDDisplay.cpp
Normal file
@@ -0,0 +1,347 @@
|
||||
#include "FastEPDDisplay.h"
|
||||
#include "FastEPD.h"
|
||||
#include <string.h>
|
||||
|
||||
// Fallback if FastEPD doesn't define these constants
|
||||
#ifndef BBEP_SUCCESS
|
||||
#define BBEP_SUCCESS 0
|
||||
#endif
|
||||
#ifndef CLEAR_FAST
|
||||
#define CLEAR_FAST 0
|
||||
#endif
|
||||
#ifndef CLEAR_SLOW
|
||||
#define CLEAR_SLOW 1
|
||||
#endif
|
||||
#ifndef BB_MODE_1BPP
|
||||
#define BB_MODE_1BPP 0
|
||||
#endif
|
||||
|
||||
// FastEPD constants (defined in FastEPD.h)
|
||||
// BB_PANEL_LILYGO_T5PRO_V2 — board ID for V2 hardware
|
||||
// BB_MODE_1BPP — 1-bit per pixel mode
|
||||
// CLEAR_FAST, CLEAR_SLOW — full refresh modes
|
||||
|
||||
// Periodic slow (deep) refresh to clear ghosting
|
||||
#define FULL_SLOW_PERIOD 1 // every frame — eliminates ghosting (increase to 2+ for less flashing)
|
||||
|
||||
FastEPDDisplay::~FastEPDDisplay() {
|
||||
delete _canvas;
|
||||
delete _epd;
|
||||
}
|
||||
|
||||
bool FastEPDDisplay::begin() {
|
||||
if (_init) return true;
|
||||
|
||||
Serial.println("[FastEPD] Initializing T5S3 E-Paper Pro V2...");
|
||||
|
||||
// Create FastEPD instance and init hardware
|
||||
_epd = new FASTEPD;
|
||||
// Meshtastic-proven init for V2 hardware (pinned FastEPD fork commit)
|
||||
Serial.println("[FastEPD] Using BB_PANEL_LILYGO_T5PRO_V2");
|
||||
int rc = _epd->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
|
||||
if (rc != BBEP_SUCCESS) {
|
||||
Serial.printf("[FastEPD] initPanel FAILED: %d\n", rc);
|
||||
delete _epd;
|
||||
_epd = nullptr;
|
||||
return false;
|
||||
}
|
||||
Serial.printf("[FastEPD] Panel initialized (rc=%d)\n", rc);
|
||||
|
||||
// Enable display via PCA9535 GPIO (required for V2 hardware)
|
||||
// Pin 0 on PCA9535 = EP_OE (output enable for source driver)
|
||||
_epd->ioPinMode(0, OUTPUT);
|
||||
_epd->ioWrite(0, HIGH);
|
||||
Serial.println("[FastEPD] PCA9535 EP_OE set HIGH");
|
||||
|
||||
// Set 1-bit per pixel mode
|
||||
_epd->setMode(BB_MODE_1BPP);
|
||||
Serial.println("[FastEPD] Mode set to 1BPP");
|
||||
|
||||
// Create Adafruit_GFX canvas for drawing (960×540, 1-bit)
|
||||
// ~64KB, should auto-allocate in PSRAM on ESP32-S3 with PSRAM enabled
|
||||
_canvas = new GFXcanvas1(EPD_WIDTH, EPD_HEIGHT);
|
||||
if (!_canvas || !_canvas->getBuffer()) {
|
||||
Serial.println("[FastEPD] Canvas allocation FAILED!");
|
||||
return false;
|
||||
}
|
||||
Serial.printf("[FastEPD] Canvas allocated: %dx%d (%d bytes)\n",
|
||||
EPD_WIDTH, EPD_HEIGHT, (EPD_WIDTH * EPD_HEIGHT) / 8);
|
||||
|
||||
// Initial clear — white screen
|
||||
Serial.println("[FastEPD] Calling clearWhite()...");
|
||||
_epd->clearWhite();
|
||||
Serial.println("[FastEPD] Calling fullUpdate(true) for initial clear...");
|
||||
_epd->fullUpdate(true); // blocking initial clear
|
||||
_epd->backupPlane(); // Save clean state for subsequent diffs
|
||||
Serial.println("[FastEPD] Initial clear complete");
|
||||
|
||||
// Set canvas defaults
|
||||
_canvas->fillScreen(1); // White background (bit=1 → white in FastEPD)
|
||||
_canvas->setTextColor(0); // Black text (bit=0 → black in FastEPD)
|
||||
#ifdef MECK_SERIF_FONT
|
||||
_canvas->setFont(&FreeSerif12pt7b);
|
||||
#else
|
||||
_canvas->setFont(&FreeSans12pt7b);
|
||||
#endif
|
||||
_canvas->setTextWrap(false);
|
||||
|
||||
_curr_color = GxEPD_BLACK;
|
||||
_init = true;
|
||||
_isOn = true;
|
||||
|
||||
Serial.println("[FastEPD] Display ready (960x540, 1BPP)");
|
||||
return true;
|
||||
}
|
||||
|
||||
void FastEPDDisplay::turnOn() {
|
||||
if (!_init) begin();
|
||||
_isOn = true;
|
||||
}
|
||||
|
||||
void FastEPDDisplay::turnOff() {
|
||||
_isOn = false;
|
||||
}
|
||||
|
||||
void FastEPDDisplay::clear() {
|
||||
if (!_canvas) return;
|
||||
_canvas->fillScreen(1); // White
|
||||
_canvas->setTextColor(0);
|
||||
_frameCRC.reset();
|
||||
}
|
||||
|
||||
void FastEPDDisplay::startFrame(Color bkg) {
|
||||
if (!_canvas) return;
|
||||
_canvas->fillScreen(1); // White background
|
||||
_canvas->setTextColor(0); // Black text
|
||||
_curr_color = GxEPD_BLACK;
|
||||
_frameCRC.reset();
|
||||
_frameCRC.update<bool>(_darkMode);
|
||||
_frameCRC.update<bool>(_portraitMode);
|
||||
}
|
||||
|
||||
void FastEPDDisplay::setTextSize(int sz) {
|
||||
if (!_canvas) return;
|
||||
_frameCRC.update<int>(sz);
|
||||
|
||||
// Font mapping for 960×540 display at ~234 DPI
|
||||
// Toggle between font families via -D MECK_SERIF_FONT build flag
|
||||
switch(sz) {
|
||||
case 0: // Body text — reader content, settings rows, messages, footers
|
||||
#ifdef MECK_SERIF_FONT
|
||||
_canvas->setFont(&FreeSerif12pt7b);
|
||||
#else
|
||||
_canvas->setFont(&FreeSans12pt7b);
|
||||
#endif
|
||||
_canvas->setTextSize(1);
|
||||
break;
|
||||
case 1: // Headings — screen titles, channel names (bold, same height as body)
|
||||
_canvas->setFont(&FreeSansBold12pt7b);
|
||||
_canvas->setTextSize(1);
|
||||
break;
|
||||
case 2: // Large bold — MSG count, tile letters
|
||||
_canvas->setFont(&FreeSansBold18pt7b);
|
||||
_canvas->setTextSize(1);
|
||||
break;
|
||||
case 3: // Extra large — splash screen title
|
||||
_canvas->setFont(&FreeSansBold24pt7b);
|
||||
_canvas->setTextSize(1);
|
||||
break;
|
||||
case 5: // Clock face — lock screen (FreeSansBold24pt scaled 5×)
|
||||
_canvas->setFont(&FreeSansBold24pt7b);
|
||||
_canvas->setTextSize(5);
|
||||
break;
|
||||
default:
|
||||
#ifdef MECK_SERIF_FONT
|
||||
_canvas->setFont(&FreeSerif12pt7b);
|
||||
#else
|
||||
_canvas->setFont(&FreeSans12pt7b);
|
||||
#endif
|
||||
_canvas->setTextSize(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void FastEPDDisplay::setColor(Color c) {
|
||||
if (!_canvas) return;
|
||||
_frameCRC.update<Color>(c);
|
||||
|
||||
// Colours are inverted for e-paper:
|
||||
// DARK = background colour = WHITE on e-paper
|
||||
// LIGHT = foreground colour = BLACK on e-paper
|
||||
if (c == DARK) {
|
||||
_canvas->setTextColor(1); // White (background)
|
||||
_curr_color = GxEPD_WHITE;
|
||||
} else {
|
||||
_canvas->setTextColor(0); // Black (foreground)
|
||||
_curr_color = GxEPD_BLACK;
|
||||
}
|
||||
}
|
||||
|
||||
void FastEPDDisplay::setCursor(int x, int y) {
|
||||
if (!_canvas) return;
|
||||
_frameCRC.update<int>(x);
|
||||
_frameCRC.update<int>(y);
|
||||
|
||||
// Scale virtual coordinates to physical, with baseline offset.
|
||||
// The +5 pushes text baseline down so ascenders at y=0 are visible.
|
||||
_canvas->setCursor(
|
||||
(int)((x + offset_x) * scale_x),
|
||||
(int)((y + offset_y + 5) * scale_y)
|
||||
);
|
||||
}
|
||||
|
||||
void FastEPDDisplay::print(const char* str) {
|
||||
if (!_canvas || !str) return;
|
||||
_frameCRC.update<char>(str, strlen(str));
|
||||
_canvas->print(str);
|
||||
}
|
||||
|
||||
void FastEPDDisplay::fillRect(int x, int y, int w, int h) {
|
||||
if (!_canvas) return;
|
||||
_frameCRC.update<int>(x);
|
||||
_frameCRC.update<int>(y);
|
||||
_frameCRC.update<int>(w);
|
||||
_frameCRC.update<int>(h);
|
||||
|
||||
// Canvas uses 1-bit color: convert GxEPD color
|
||||
uint16_t canvasColor = (_curr_color == GxEPD_BLACK) ? 0 : 1;
|
||||
_canvas->fillRect(
|
||||
(int)((x + offset_x) * scale_x),
|
||||
(int)((y + offset_y) * scale_y),
|
||||
(int)(w * scale_x),
|
||||
(int)(h * scale_y),
|
||||
canvasColor
|
||||
);
|
||||
}
|
||||
|
||||
void FastEPDDisplay::drawRect(int x, int y, int w, int h) {
|
||||
if (!_canvas) return;
|
||||
_frameCRC.update<int>(x);
|
||||
_frameCRC.update<int>(y);
|
||||
_frameCRC.update<int>(w);
|
||||
_frameCRC.update<int>(h);
|
||||
|
||||
uint16_t canvasColor = (_curr_color == GxEPD_BLACK) ? 0 : 1;
|
||||
_canvas->drawRect(
|
||||
(int)((x + offset_x) * scale_x),
|
||||
(int)((y + offset_y) * scale_y),
|
||||
(int)(w * scale_x),
|
||||
(int)(h * scale_y),
|
||||
canvasColor
|
||||
);
|
||||
}
|
||||
|
||||
void FastEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
|
||||
if (!_canvas || !bits) return;
|
||||
_frameCRC.update<int>(x);
|
||||
_frameCRC.update<int>(y);
|
||||
_frameCRC.update<int>(w);
|
||||
_frameCRC.update<int>(h);
|
||||
_frameCRC.update<uint8_t>(bits, (w * h + 7) / 8);
|
||||
|
||||
uint16_t canvasColor = (_curr_color == GxEPD_BLACK) ? 0 : 1;
|
||||
uint16_t startX = (int)((x + offset_x) * scale_x);
|
||||
uint16_t startY = (int)((y + offset_y) * scale_y);
|
||||
uint16_t widthInBytes = (w + 7) / 8;
|
||||
|
||||
for (uint16_t by = 0; by < h; by++) {
|
||||
int y1 = startY + (int)(by * scale_y);
|
||||
int y2 = startY + (int)((by + 1) * scale_y);
|
||||
int block_h = y2 - y1;
|
||||
|
||||
for (uint16_t bx = 0; bx < w; bx++) {
|
||||
int x1 = startX + (int)(bx * scale_x);
|
||||
int x2 = startX + (int)((bx + 1) * scale_x);
|
||||
int block_w = x2 - x1;
|
||||
|
||||
uint16_t byteOffset = (by * widthInBytes) + (bx / 8);
|
||||
uint8_t bitMask = 0x80 >> (bx & 7);
|
||||
bool bitSet = pgm_read_byte(bits + byteOffset) & bitMask;
|
||||
|
||||
if (bitSet) {
|
||||
_canvas->fillRect(x1, y1, block_w, block_h, canvasColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t FastEPDDisplay::getTextWidth(const char* str) {
|
||||
if (!_canvas || !str) return 0;
|
||||
int16_t x1, y1;
|
||||
uint16_t w, h;
|
||||
_canvas->getTextBounds(str, 0, 0, &x1, &y1, &w, &h);
|
||||
return (uint16_t)ceil((w + 1) / scale_x);
|
||||
}
|
||||
|
||||
void FastEPDDisplay::endFrame() {
|
||||
if (!_epd || !_canvas) return;
|
||||
|
||||
uint32_t crc = _frameCRC.finalize();
|
||||
if (crc == _lastCRC) {
|
||||
return; // Frame unchanged, skip display update
|
||||
}
|
||||
_lastCRC = crc;
|
||||
|
||||
// Copy GFXcanvas1 buffer to FastEPD's current buffer — direct copy.
|
||||
// Both use same polarity: bit 1 = white, bit 0 = black.
|
||||
uint8_t* src = _canvas->getBuffer();
|
||||
uint8_t* dst = _epd->currentBuffer();
|
||||
size_t bufSize = ((uint32_t)EPD_WIDTH * EPD_HEIGHT) / 8;
|
||||
|
||||
if (!src || !dst) return;
|
||||
|
||||
memcpy(dst, src, bufSize);
|
||||
|
||||
// Dark mode: invert every byte in the buffer (white↔black)
|
||||
if (_darkMode) {
|
||||
for (size_t i = 0; i < bufSize; i++) dst[i] = ~dst[i];
|
||||
}
|
||||
|
||||
// Refresh strategy:
|
||||
// partialUpdate(true) — no flash, differential, keeps previous buffer
|
||||
// fullUpdate(false) — brief flash, clears ghosting (CLEAR_FAST)
|
||||
// fullUpdate(true) — full white flash, cleanest (boot only)
|
||||
//
|
||||
// Use partial for most frames. Periodic full refresh every N frames
|
||||
// to clear accumulated ghosting artifacts.
|
||||
_fullRefreshCount++;
|
||||
if (_forcePartial) {
|
||||
// VKB typing mode — no flash, fast differential update
|
||||
_epd->partialUpdate(true);
|
||||
_fullRefreshCount = 0; // Reset so next non-partial frame does full refresh
|
||||
} else if (_fullRefreshCount >= FULL_SLOW_PERIOD) {
|
||||
_fullRefreshCount = 0;
|
||||
_epd->fullUpdate(true); // Full clean refresh — clears all ghosting
|
||||
} else {
|
||||
_epd->partialUpdate(true); // No flash — differential
|
||||
}
|
||||
_epd->backupPlane();
|
||||
}
|
||||
|
||||
void FastEPDDisplay::setDarkMode(bool dark) {
|
||||
_darkMode = dark;
|
||||
_lastCRC = 0; // Force redraw
|
||||
Serial.printf("[FastEPD] Dark mode: %s\n", dark ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
void FastEPDDisplay::setPortraitMode(bool portrait) {
|
||||
if (_portraitMode == portrait) return;
|
||||
_portraitMode = portrait;
|
||||
|
||||
if (!_canvas) return;
|
||||
|
||||
if (portrait) {
|
||||
_canvas->setRotation(3); // 270° CW — USB-C on right when held portrait
|
||||
scale_x = (float)EPD_HEIGHT / 128.0f; // 540 / 128 = 4.21875
|
||||
scale_y = (float)EPD_WIDTH / 128.0f; // 960 / 128 = 7.5
|
||||
Serial.printf("[FastEPD] Portrait mode: ON (logical %dx%d, scale %.2f x %.2f)\n",
|
||||
EPD_HEIGHT, EPD_WIDTH, scale_x, scale_y);
|
||||
} else {
|
||||
_canvas->setRotation(0); // Normal landscape
|
||||
scale_x = (float)EPD_WIDTH / 128.0f; // 960 / 128 = 7.5
|
||||
scale_y = (float)EPD_HEIGHT / 128.0f; // 540 / 128 = 4.21875
|
||||
Serial.printf("[FastEPD] Portrait mode: OFF (logical %dx%d, scale %.2f x %.2f)\n",
|
||||
EPD_WIDTH, EPD_HEIGHT, scale_x, scale_y);
|
||||
}
|
||||
_lastCRC = 0; // Force redraw
|
||||
}
|
||||
136
src/helpers/ui/FastEPDDisplay.h
Normal file
136
src/helpers/ui/FastEPDDisplay.h
Normal file
@@ -0,0 +1,136 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// FastEPDDisplay — Parallel e-ink display driver for T5 S3 E-Paper Pro
|
||||
//
|
||||
// Architecture:
|
||||
// - FastEPD handles hardware init, power management, and display refresh
|
||||
// - Adafruit_GFX GFXcanvas1 handles all drawing/text rendering
|
||||
// - On endFrame(), canvas buffer is copied to FastEPD and display is updated
|
||||
//
|
||||
// This avoids depending on FastEPD's drawing API — only uses its well-tested
|
||||
// hardware interface (initPanel, fullUpdate, partialUpdate, currentBuffer).
|
||||
// =============================================================================
|
||||
|
||||
#include <Adafruit_GFX.h>
|
||||
#include "variant.h" // EPD_WIDTH, EPD_HEIGHT (only compiled for T5S3 builds)
|
||||
#include <Fonts/FreeSans9pt7b.h>
|
||||
#include <Fonts/FreeSans12pt7b.h>
|
||||
#include <Fonts/FreeSans18pt7b.h>
|
||||
#include <Fonts/FreeSans24pt7b.h>
|
||||
#include <Fonts/FreeSansBold12pt7b.h>
|
||||
#include <Fonts/FreeSansBold18pt7b.h>
|
||||
#include <Fonts/FreeSansBold24pt7b.h>
|
||||
#include <Fonts/FreeSerif12pt7b.h>
|
||||
#include <Fonts/FreeSerif18pt7b.h>
|
||||
|
||||
#include "DisplayDriver.h"
|
||||
|
||||
// GxEPD2 color constant compatibility — MapScreen uses these directly
|
||||
#ifndef GxEPD_BLACK
|
||||
#define GxEPD_BLACK 0x0000
|
||||
#endif
|
||||
#ifndef GxEPD_WHITE
|
||||
#define GxEPD_WHITE 0xFFFF
|
||||
#endif
|
||||
|
||||
// Forward declare FastEPD class (actual include in .cpp)
|
||||
class FASTEPD;
|
||||
|
||||
// Inline CRC32 for frame change detection
|
||||
// (Copied from GxEPDDisplay.h — avoids CRC32/PNGdec name collision)
|
||||
class FrameCRC32 {
|
||||
uint32_t _crc = 0xFFFFFFFF;
|
||||
public:
|
||||
void reset() { _crc = 0xFFFFFFFF; }
|
||||
template<typename T> void update(T val) {
|
||||
const uint8_t* p = (const uint8_t*)&val;
|
||||
for (size_t i = 0; i < sizeof(T); i++) {
|
||||
_crc ^= p[i];
|
||||
for (int b = 0; b < 8; b++)
|
||||
_crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1));
|
||||
}
|
||||
}
|
||||
template<typename T> void update(const T* data, size_t len) {
|
||||
const uint8_t* p = (const uint8_t*)data;
|
||||
for (size_t i = 0; i < len * sizeof(T); i++) {
|
||||
_crc ^= p[i];
|
||||
for (int b = 0; b < 8; b++)
|
||||
_crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1));
|
||||
}
|
||||
}
|
||||
uint32_t finalize() { return _crc ^ 0xFFFFFFFF; }
|
||||
};
|
||||
|
||||
|
||||
class FastEPDDisplay : public DisplayDriver {
|
||||
FASTEPD* _epd;
|
||||
GFXcanvas1* _canvas; // Adafruit_GFX 1-bit drawing surface (960×540)
|
||||
bool _init = false;
|
||||
bool _isOn = false;
|
||||
uint16_t _curr_color; // GxEPD_BLACK or GxEPD_WHITE for canvas drawing
|
||||
FrameCRC32 _frameCRC;
|
||||
uint32_t _lastCRC = 0;
|
||||
int _fullRefreshCount = 0; // Track for periodic slow refresh
|
||||
uint32_t _lastUpdateMs = 0; // Rate limiting — minimum interval between refreshes
|
||||
bool _forcePartial = false; // When true, use partial updates (VKB typing)
|
||||
bool _darkMode = false; // Invert all pixels (black bg, white text)
|
||||
bool _portraitMode = false; // Rotated 90° (540×960 logical)
|
||||
|
||||
// Virtual 128×128 → physical canvas mapping (runtime, changes with portrait)
|
||||
float scale_x = 7.5f; // 960 / 128 (landscape default)
|
||||
float scale_y = 4.21875f; // 540 / 128 (landscape default)
|
||||
static constexpr float offset_x = 0.0f;
|
||||
static constexpr float offset_y = 0.0f;
|
||||
|
||||
public:
|
||||
FastEPDDisplay() : DisplayDriver(128, 128), _epd(nullptr), _canvas(nullptr) {}
|
||||
~FastEPDDisplay();
|
||||
|
||||
bool begin();
|
||||
|
||||
bool isOn() override { return _isOn; }
|
||||
void turnOn() override;
|
||||
void turnOff() override;
|
||||
void clear() override;
|
||||
void startFrame(Color bkg = DARK) override;
|
||||
void setTextSize(int sz) override;
|
||||
void setColor(Color c) override;
|
||||
void setCursor(int x, int y) override;
|
||||
void print(const char* str) override;
|
||||
void fillRect(int x, int y, int w, int h) override;
|
||||
void drawRect(int x, int y, int w, int h) override;
|
||||
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
|
||||
uint16_t getTextWidth(const char* str) override;
|
||||
void endFrame() override;
|
||||
|
||||
// --- Raw pixel access for MapScreen (bypasses scaling) ---
|
||||
void drawPixelRaw(int16_t x, int16_t y, uint16_t color) {
|
||||
if (_canvas) _canvas->drawPixel(x, y, color ? 1 : 0);
|
||||
}
|
||||
int16_t rawWidth() { return EPD_WIDTH; }
|
||||
int16_t rawHeight() { return EPD_HEIGHT; }
|
||||
|
||||
void drawTextRaw(int16_t x, int16_t y, const char* text, uint16_t color) {
|
||||
if (!_canvas) return;
|
||||
_canvas->setFont(NULL);
|
||||
_canvas->setTextSize(3); // 3× built-in 5×7 = 15×21, readable on 960×540
|
||||
_canvas->setTextColor(color ? 1 : 0);
|
||||
_canvas->setCursor(x, y);
|
||||
_canvas->print(text);
|
||||
}
|
||||
|
||||
void invalidateFrameCRC() { _lastCRC = 0; }
|
||||
|
||||
// Temporarily force partial (no-flash) updates — use during VKB typing
|
||||
void setForcePartial(bool partial) { _forcePartial = partial; }
|
||||
bool isForcePartial() const { return _forcePartial; }
|
||||
|
||||
// Dark mode — invert all pixels in endFrame (black bg, white text)
|
||||
void setDarkMode(bool dark);
|
||||
bool isDarkMode() const { return _darkMode; }
|
||||
|
||||
// Portrait mode — rotate canvas 90° (540×960 logical), swap scale factors
|
||||
void setPortraitMode(bool portrait);
|
||||
bool isPortraitMode() const { return _portraitMode; }
|
||||
};
|
||||
@@ -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,23 +61,38 @@ 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() {
|
||||
display.fillScreen(GxEPD_WHITE);
|
||||
display.setTextColor(GxEPD_BLACK);
|
||||
if (_darkMode) {
|
||||
display.fillScreen(GxEPD_BLACK);
|
||||
display.setTextColor(GxEPD_WHITE);
|
||||
} else {
|
||||
display.fillScreen(GxEPD_WHITE);
|
||||
display.setTextColor(GxEPD_BLACK);
|
||||
}
|
||||
display_crc.reset();
|
||||
}
|
||||
|
||||
void GxEPDDisplay::startFrame(Color bkg) {
|
||||
display.fillScreen(GxEPD_WHITE);
|
||||
display.setTextColor(_curr_color = GxEPD_BLACK);
|
||||
if (_darkMode) {
|
||||
display.fillScreen(GxEPD_BLACK);
|
||||
display.setTextColor(_curr_color = GxEPD_WHITE);
|
||||
} else {
|
||||
display.fillScreen(GxEPD_WHITE);
|
||||
display.setTextColor(_curr_color = GxEPD_BLACK);
|
||||
}
|
||||
display_crc.reset();
|
||||
}
|
||||
|
||||
@@ -90,26 +105,43 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
void GxEPDDisplay::setColor(Color c) {
|
||||
display_crc.update<Color> (c);
|
||||
// colours need to be inverted for epaper displays
|
||||
if (c == DARK) {
|
||||
display.setTextColor(_curr_color = GxEPD_WHITE);
|
||||
if (_darkMode) {
|
||||
// Dark mode: DARK = black (background), LIGHT/GREEN/YELLOW = white (foreground)
|
||||
if (c == DARK) {
|
||||
display.setTextColor(_curr_color = GxEPD_BLACK);
|
||||
} else {
|
||||
display.setTextColor(_curr_color = GxEPD_WHITE);
|
||||
}
|
||||
} else {
|
||||
display.setTextColor(_curr_color = GxEPD_BLACK);
|
||||
// Normal e-paper: DARK = white (background), LIGHT/GREEN/YELLOW = black (foreground)
|
||||
if (c == DARK) {
|
||||
display.setTextColor(_curr_color = GxEPD_WHITE);
|
||||
} else {
|
||||
display.setTextColor(_curr_color = GxEPD_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
// T5S3 E-Paper Pro uses parallel e-ink (FastEPD), not SPI (GxEPD2)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include "FastEPDDisplay.h"
|
||||
using GxEPDDisplay = FastEPDDisplay;
|
||||
#else
|
||||
|
||||
#include <SPI.h>
|
||||
#include <Wire.h>
|
||||
|
||||
@@ -57,6 +63,7 @@ class GxEPDDisplay : public DisplayDriver {
|
||||
#endif
|
||||
bool _init = false;
|
||||
bool _isOn = false;
|
||||
bool _darkMode = false;
|
||||
uint16_t _curr_color;
|
||||
FrameCRC32 display_crc;
|
||||
int last_display_crc_value = 0;
|
||||
@@ -73,6 +80,11 @@ public:
|
||||
bool isOn() override {return _isOn;};
|
||||
void turnOn() override;
|
||||
void turnOff() override;
|
||||
|
||||
// Dark mode — inverts background/foreground for e-ink
|
||||
bool isDarkMode() const { return _darkMode; }
|
||||
void setDarkMode(bool on) { _darkMode = on; }
|
||||
|
||||
void clear() override;
|
||||
void startFrame(Color bkg = DARK) override;
|
||||
void setTextSize(int sz) override;
|
||||
@@ -104,4 +116,6 @@ public:
|
||||
// Force endFrame() to push to display even if CRC unchanged
|
||||
// (needed because drawPixelRaw bypasses CRC tracking)
|
||||
void invalidateFrameCRC() { last_display_crc_value = 0; }
|
||||
};
|
||||
};
|
||||
|
||||
#endif // !LilyGo_T5S3_EPaper_Pro
|
||||
113
variants/lilygo_t5s3_epaper_pro/CPUPowerManager.h
Normal file
113
variants/lilygo_t5s3_epaper_pro/CPUPowerManager.h
Normal file
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// CPU Frequency Scaling for ESP32-S3
|
||||
//
|
||||
// Typical current draw (CPU only, rough):
|
||||
// 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 and 40MHz.
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
#ifndef CPU_FREQ_IDLE
|
||||
#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_BOOST
|
||||
#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), _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)) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
void setBoost() {
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_BOOST);
|
||||
_boosted = true;
|
||||
MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST);
|
||||
}
|
||||
_boost_started = millis();
|
||||
}
|
||||
|
||||
void setIdle() {
|
||||
if (_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_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;
|
||||
};
|
||||
|
||||
#endif // ESP32
|
||||
209
variants/lilygo_t5s3_epaper_pro/Pcf85063clock.h
Normal file
209
variants/lilygo_t5s3_epaper_pro/Pcf85063clock.h
Normal file
@@ -0,0 +1,209 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// PCF85063Clock — PCF8563/BM8563 RTC driver for T5S3 E-Paper Pro
|
||||
//
|
||||
// Time registers at 0x02–0x08 (PCF8563 layout):
|
||||
// 0x02 Seconds, 0x03 Minutes, 0x04 Hours,
|
||||
// 0x05 Days, 0x06 Weekdays, 0x07 Months, 0x08 Years
|
||||
// =============================================================================
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include <MeshCore.h>
|
||||
|
||||
#define PCF8563_ADDR 0x51
|
||||
#define PCF8563_REG_SECONDS 0x02
|
||||
|
||||
// Reject timestamps outside 2024–2036 (blocks MeshCore contacts garbage)
|
||||
#define EPOCH_MIN_SANE 1704067200UL
|
||||
#define EPOCH_MAX_SANE 2082758400UL
|
||||
|
||||
class PCF85063Clock : public mesh::RTCClock {
|
||||
public:
|
||||
PCF85063Clock() : _wire(nullptr), _millis_offset(0),
|
||||
_has_hw_time(false), _time_set_this_session(false) {}
|
||||
|
||||
bool begin(TwoWire& wire) {
|
||||
_wire = &wire;
|
||||
|
||||
_wire->beginTransmission(PCF8563_ADDR);
|
||||
if (_wire->endTransmission() != 0) {
|
||||
Serial.println("[RTC] PCF8563 not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Repair any corrupted registers from prior wrong-offset writes
|
||||
repairRegisters();
|
||||
|
||||
uint32_t t = readHardwareTime();
|
||||
if (t > EPOCH_MIN_SANE && t < EPOCH_MAX_SANE) {
|
||||
_has_hw_time = true;
|
||||
_millis_offset = t - (millis() / 1000);
|
||||
Serial.printf("[RTC] PCF8563 OK, time=%lu\n", t);
|
||||
} else {
|
||||
_has_hw_time = false;
|
||||
Serial.printf("[RTC] PCF8563 no valid time (%lu), awaiting BLE sync\n", t);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t getCurrentTime() override {
|
||||
if (_time_set_this_session) {
|
||||
return _millis_offset + (millis() / 1000);
|
||||
}
|
||||
if (_has_hw_time && _wire) {
|
||||
uint32_t t = readHardwareTime();
|
||||
if (t > EPOCH_MIN_SANE && t < EPOCH_MAX_SANE) {
|
||||
_millis_offset = t - (millis() / 1000);
|
||||
return t;
|
||||
}
|
||||
_has_hw_time = false;
|
||||
}
|
||||
return _millis_offset + (millis() / 1000);
|
||||
}
|
||||
|
||||
void setCurrentTime(uint32_t time) override {
|
||||
if (time < EPOCH_MIN_SANE || time > EPOCH_MAX_SANE) {
|
||||
Serial.printf("[RTC] setCurrentTime(%lu) REJECTED\n", time);
|
||||
return;
|
||||
}
|
||||
_millis_offset = time - (millis() / 1000);
|
||||
_time_set_this_session = true;
|
||||
Serial.printf("[RTC] setCurrentTime(%lu) OK\n", time);
|
||||
if (_wire) writeHardwareTime(time);
|
||||
}
|
||||
|
||||
private:
|
||||
TwoWire* _wire;
|
||||
uint32_t _millis_offset;
|
||||
bool _has_hw_time;
|
||||
bool _time_set_this_session;
|
||||
|
||||
// ---- Register helpers ----
|
||||
void writeReg(uint8_t reg, uint8_t val) {
|
||||
_wire->beginTransmission(PCF8563_ADDR);
|
||||
_wire->write(reg);
|
||||
_wire->write(val);
|
||||
_wire->endTransmission();
|
||||
}
|
||||
|
||||
uint8_t readReg(uint8_t reg) {
|
||||
_wire->beginTransmission(PCF8563_ADDR);
|
||||
_wire->write(reg);
|
||||
if (_wire->endTransmission(false) != 0) return 0xFF;
|
||||
if (_wire->requestFrom((uint8_t)PCF8563_ADDR, (uint8_t)1) != 1) return 0xFF;
|
||||
return _wire->read();
|
||||
}
|
||||
|
||||
// ---- Fix registers corrupted by prior PCF85063A-mode writes ----
|
||||
void repairRegisters() {
|
||||
uint8_t hours = readReg(0x04) & 0x3F;
|
||||
if (bcd2dec(hours) > 23) {
|
||||
Serial.printf("[RTC] Repairing hours (0x%02X→0x00)\n", hours);
|
||||
writeReg(0x04, 0x00);
|
||||
}
|
||||
uint8_t days = readReg(0x05) & 0x3F;
|
||||
if (bcd2dec(days) == 0 || bcd2dec(days) > 31) {
|
||||
Serial.printf("[RTC] Repairing days (0x%02X→0x01)\n", days);
|
||||
writeReg(0x05, 0x01);
|
||||
}
|
||||
uint8_t month = readReg(0x07) & 0x1F;
|
||||
if (bcd2dec(month) == 0 || bcd2dec(month) > 12) {
|
||||
Serial.printf("[RTC] Repairing month (0x%02X→0x01)\n", month);
|
||||
writeReg(0x07, 0x01);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- BCD ----
|
||||
static uint8_t bcd2dec(uint8_t bcd) { return ((bcd >> 4) * 10) + (bcd & 0x0F); }
|
||||
static uint8_t dec2bcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); }
|
||||
|
||||
// ---- Date helpers ----
|
||||
static bool isLeap(int y) { return (y%4==0 && y%100!=0) || y%400==0; }
|
||||
static int daysInMonth(int m, int y) {
|
||||
static const uint8_t d[] = {31,28,31,30,31,30,31,31,30,31,30,31};
|
||||
return (m==2 && isLeap(y)) ? 29 : d[m-1];
|
||||
}
|
||||
|
||||
static uint32_t toEpoch(int yr, int mo, int dy, int h, int mi, int s) {
|
||||
uint32_t days = 0;
|
||||
for (int y = 1970; y < yr; y++) days += isLeap(y) ? 366 : 365;
|
||||
for (int m = 1; m < mo; m++) days += daysInMonth(m, yr);
|
||||
days += (dy - 1);
|
||||
return days * 86400UL + h * 3600UL + mi * 60UL + s;
|
||||
}
|
||||
|
||||
static void fromEpoch(uint32_t ep, int& yr, int& mo, int& dy, int& h, int& mi, int& s) {
|
||||
s = ep % 60; ep /= 60;
|
||||
mi = ep % 60; ep /= 60;
|
||||
h = ep % 24; ep /= 24;
|
||||
yr = 1970;
|
||||
while (true) { int d = isLeap(yr)?366:365; if (ep<(uint32_t)d) break; ep-=d; yr++; }
|
||||
mo = 1;
|
||||
while (true) { int d = daysInMonth(mo,yr); if (ep<(uint32_t)d) break; ep-=d; mo++; }
|
||||
dy = ep + 1;
|
||||
}
|
||||
|
||||
// ---- Read time (burst from 0x02) ----
|
||||
uint32_t readHardwareTime() {
|
||||
_wire->beginTransmission(PCF8563_ADDR);
|
||||
_wire->write(PCF8563_REG_SECONDS);
|
||||
if (_wire->endTransmission(false) != 0) return 0;
|
||||
if (_wire->requestFrom((uint8_t)PCF8563_ADDR, (uint8_t)7) != 7) return 0;
|
||||
|
||||
uint8_t raw[7];
|
||||
for (int i = 0; i < 7; i++) raw[i] = _wire->read();
|
||||
|
||||
if (raw[0] & 0x80) {
|
||||
Serial.println("[RTC] OS flag set — clearing");
|
||||
writeReg(PCF8563_REG_SECONDS, raw[0] & 0x7F);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int second = bcd2dec(raw[0] & 0x7F);
|
||||
int minute = bcd2dec(raw[1] & 0x7F);
|
||||
int hour = bcd2dec(raw[2] & 0x3F);
|
||||
int day = bcd2dec(raw[3] & 0x3F);
|
||||
int month = bcd2dec(raw[5] & 0x1F);
|
||||
int year = 2000 + bcd2dec(raw[6]);
|
||||
|
||||
if (month<1 || month>12 || day<1 || day>31 || hour>23 || minute>59 || second>59)
|
||||
return 0;
|
||||
|
||||
return toEpoch(year, month, day, hour, minute, second);
|
||||
}
|
||||
|
||||
// ---- Write time (burst to 0x02) ----
|
||||
void writeHardwareTime(uint32_t epoch) {
|
||||
int year, month, day, hour, minute, second;
|
||||
fromEpoch(epoch, year, month, day, hour, minute, second);
|
||||
|
||||
static const int dow[] = {0,3,2,5,0,3,5,1,4,6,2,4};
|
||||
int y = year; if (month < 3) y--;
|
||||
int wday = (y + y/4 - y/100 + y/400 + dow[month-1] + day) % 7;
|
||||
int yr = year - 2000;
|
||||
|
||||
// Stop clock
|
||||
writeReg(0x00, 0x20);
|
||||
delay(5);
|
||||
|
||||
// Burst write
|
||||
_wire->beginTransmission(PCF8563_ADDR);
|
||||
_wire->write(PCF8563_REG_SECONDS);
|
||||
_wire->write(dec2bcd(second) & 0x7F);
|
||||
_wire->write(dec2bcd(minute));
|
||||
_wire->write(dec2bcd(hour));
|
||||
_wire->write(dec2bcd(day));
|
||||
_wire->write(dec2bcd(wday));
|
||||
_wire->write(dec2bcd(month));
|
||||
_wire->write(dec2bcd(yr));
|
||||
_wire->endTransmission();
|
||||
delay(5);
|
||||
|
||||
// Restart clock
|
||||
writeReg(0x00, 0x00);
|
||||
|
||||
Serial.printf("[RTC] Wrote %04d-%02d-%02d %02d:%02d:%02d\n",
|
||||
year, month, day, hour, minute, second);
|
||||
}
|
||||
};
|
||||
461
variants/lilygo_t5s3_epaper_pro/T5S3Board.cpp
Normal file
461
variants/lilygo_t5s3_epaper_pro/T5S3Board.cpp
Normal file
@@ -0,0 +1,461 @@
|
||||
#include <Arduino.h>
|
||||
#include "variant.h"
|
||||
#include "T5S3Board.h"
|
||||
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
|
||||
|
||||
void T5S3Board::begin() {
|
||||
MESH_DEBUG_PRINTLN("T5S3Board::begin() - starting");
|
||||
|
||||
// Initialize I2C with T5S3 V2 pins
|
||||
// Note: No explicit peripheral power enable needed on T5S3
|
||||
// (unlike T-Deck Pro's PIN_PERF_POWERON)
|
||||
Wire.begin(I2C_SDA, I2C_SCL);
|
||||
Wire.setClock(100000); // 100kHz for reliable fuel gauge communication
|
||||
MESH_DEBUG_PRINTLN("T5S3Board::begin() - I2C initialized (SDA=%d, SCL=%d)", I2C_SDA, I2C_SCL);
|
||||
|
||||
// Call parent class begin (handles CPU freq, etc.)
|
||||
// Note: ESP32Board::begin() also calls Wire.begin() but with our
|
||||
// PIN_BOARD_SDA/SCL defines it will use the same pins — harmless.
|
||||
ESP32Board::begin();
|
||||
|
||||
// Configure backlight (off by default — save power)
|
||||
#ifdef BOARD_BL_EN
|
||||
pinMode(BOARD_BL_EN, OUTPUT);
|
||||
digitalWrite(BOARD_BL_EN, LOW);
|
||||
MESH_DEBUG_PRINTLN("T5S3Board::begin() - backlight pin configured (GPIO%d)", BOARD_BL_EN);
|
||||
#endif
|
||||
|
||||
// Configure user button
|
||||
pinMode(PIN_USER_BTN, INPUT);
|
||||
|
||||
// Configure LoRa SPI MISO pullup
|
||||
pinMode(P_LORA_MISO, INPUT_PULLUP);
|
||||
|
||||
// Handle wake from deep sleep
|
||||
esp_reset_reason_t reason = esp_reset_reason();
|
||||
if (reason == ESP_RST_DEEPSLEEP) {
|
||||
uint64_t wakeup_source = esp_sleep_get_ext1_wakeup_status();
|
||||
if (wakeup_source & (1ULL << P_LORA_DIO_1)) {
|
||||
startup_reason = BD_STARTUP_RX_PACKET;
|
||||
}
|
||||
rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS);
|
||||
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
|
||||
}
|
||||
|
||||
// Test BQ27220 communication and configure design capacity
|
||||
#if HAS_BQ27220
|
||||
uint16_t voltage = getBattMilliVolts();
|
||||
MESH_DEBUG_PRINTLN("T5S3Board::begin() - Battery voltage: %d mV", voltage);
|
||||
configureFuelGauge();
|
||||
#endif
|
||||
|
||||
// Early low-voltage protection
|
||||
#if HAS_BQ27220 && defined(AUTO_SHUTDOWN_MILLIVOLTS)
|
||||
{
|
||||
uint16_t bootMv = getBattMilliVolts();
|
||||
if (bootMv > 0 && bootMv < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
Serial.printf("CRITICAL: Boot voltage %dmV < %dmV — sleeping immediately\n",
|
||||
bootMv, AUTO_SHUTDOWN_MILLIVOLTS);
|
||||
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
|
||||
esp_sleep_enable_ext1_wakeup(1ULL << PIN_USER_BTN, ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
MESH_DEBUG_PRINTLN("T5S3Board::begin() - complete");
|
||||
}
|
||||
|
||||
// ---- BQ27220 register helpers (static, file-local) ----
|
||||
|
||||
#if HAS_BQ27220
|
||||
static uint16_t bq27220_read16(uint8_t reg) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2) != 2) return 0;
|
||||
uint16_t val = Wire.read();
|
||||
val |= (Wire.read() << 8);
|
||||
return val;
|
||||
}
|
||||
|
||||
static uint8_t bq27220_read8(uint8_t reg) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)1) != 1) return 0;
|
||||
return Wire.read();
|
||||
}
|
||||
|
||||
static bool bq27220_writeControl(uint16_t subcmd) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x00);
|
||||
Wire.write(subcmd & 0xFF);
|
||||
Wire.write((subcmd >> 8) & 0xFF);
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---- BQ27220 public interface ----
|
||||
|
||||
uint16_t T5S3Board::getBattMilliVolts() {
|
||||
#if HAS_BQ27220
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(BQ27220_REG_VOLTAGE);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
uint8_t count = Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2);
|
||||
if (count != 2) return 0;
|
||||
uint16_t voltage = Wire.read();
|
||||
voltage |= (Wire.read() << 8);
|
||||
return voltage;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint8_t T5S3Board::getBatteryPercent() {
|
||||
#if HAS_BQ27220
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(BQ27220_REG_SOC);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
uint8_t count = Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2);
|
||||
if (count != 2) return 0;
|
||||
uint16_t soc = Wire.read();
|
||||
soc |= (Wire.read() << 8);
|
||||
return (uint8_t)min(soc, (uint16_t)100);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t T5S3Board::getAvgCurrent() {
|
||||
#if HAS_BQ27220
|
||||
return (int16_t)bq27220_read16(BQ27220_REG_AVG_CURRENT);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t T5S3Board::getAvgPower() {
|
||||
#if HAS_BQ27220
|
||||
return (int16_t)bq27220_read16(BQ27220_REG_AVG_POWER);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t T5S3Board::getTimeToEmpty() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_TIME_TO_EMPTY);
|
||||
#else
|
||||
return 0xFFFF;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t T5S3Board::getRemainingCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_REMAIN_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t T5S3Board::getFullChargeCapacity() {
|
||||
#if HAS_BQ27220
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
if (fcc > BQ27220_DESIGN_CAPACITY_MAH) fcc = BQ27220_DESIGN_CAPACITY_MAH;
|
||||
return fcc;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t T5S3Board::getDesignCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t T5S3Board::getBattTemperature() {
|
||||
#if HAS_BQ27220
|
||||
uint16_t raw = bq27220_read16(BQ27220_REG_TEMPERATURE);
|
||||
return (int16_t)(raw - 2731); // 0.1°K to 0.1°C
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---- BQ27220 Design Capacity configuration ----
|
||||
// The BQ27220 ships with a 3000 mAh default. T5S3 uses a 1500 mAh cell.
|
||||
// This function checks on boot and writes the correct value via the
|
||||
// MAC Data Memory interface if needed. The value persists in battery-backed
|
||||
// RAM, so this typically only writes once (or after a full battery disconnect).
|
||||
//
|
||||
// When DC and DE are already correct but FCC is stuck (common after initial
|
||||
// flash), the root cause is Qmax Cell 0 (0x9106) and stored FCC (0x929D)
|
||||
// retaining factory 3000 mAh defaults. This function detects and fixes all
|
||||
// three layers: DC/DE, Qmax, and stored FCC.
|
||||
|
||||
bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
#if HAS_BQ27220
|
||||
uint16_t currentDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
|
||||
|
||||
if (currentDC == designCapacity_mAh) {
|
||||
// Design Capacity correct, but check if Full Charge Capacity is sane.
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity already correct, FCC=%d mAh\n", fcc);
|
||||
if (fcc >= designCapacity_mAh * 3 / 2) {
|
||||
// FCC is >=150% of design — stale from factory defaults (typically 3000 mAh).
|
||||
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
|
||||
Serial.printf("BQ27220: FCC %d >> DC %d, checking Design Energy (target %d mWh)\n",
|
||||
fcc, designCapacity_mAh, designEnergy);
|
||||
|
||||
// Unseal to read data memory and issue RESET
|
||||
bq27220_writeControl(0x0414); delay(2);
|
||||
bq27220_writeControl(0x3672); delay(2);
|
||||
// Full Access
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
|
||||
// Enter CFG_UPDATE to access data memory
|
||||
bq27220_writeControl(0x0090);
|
||||
bool ready = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
delay(20);
|
||||
uint16_t opSt = bq27220_read16(BQ27220_REG_OP_STATUS);
|
||||
if (opSt & 0x0400) { ready = true; break; }
|
||||
}
|
||||
if (ready) {
|
||||
// Read Design Energy at data memory address 0x92A1
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint16_t currentDE = (oldMSB << 8) | oldLSB;
|
||||
|
||||
if (currentDE != designEnergy) {
|
||||
// Design Energy actually needs updating — write it
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint8_t newMSB = (designEnergy >> 8) & 0xFF;
|
||||
uint8_t newLSB = designEnergy & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: DE old=%d new=%d mWh, writing\n", currentDE, designEnergy);
|
||||
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.write(newMSB); Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(newChk); Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
// Exit with reinit since we actually changed data
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Design Energy written, exited CFG_UPDATE");
|
||||
} else {
|
||||
// DC and DE are both correct, but FCC is stuck.
|
||||
// Root cause: Qmax Cell 0 (0x9106) and stored FCC (0x929D) retain
|
||||
// factory 3000 mAh defaults. Overwrite both with designCapacity_mAh.
|
||||
Serial.printf("BQ27220: DE correct (%d mWh) — fixing Qmax + stored FCC\n", currentDE);
|
||||
|
||||
// --- Helper lambda for MAC data memory 2-byte write ---
|
||||
// Reads old value + checksum, computes differential checksum, writes new value.
|
||||
auto writeDM16 = [](uint16_t addr, uint16_t newVal) -> bool {
|
||||
// Select address
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(addr & 0xFF);
|
||||
Wire.write((addr >> 8) & 0xFF);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint16_t oldVal = (oldMSB << 8) | oldLSB;
|
||||
|
||||
if (oldVal == newVal) {
|
||||
Serial.printf("BQ27220: [0x%04X] already %d, skip\n", addr, newVal);
|
||||
return true; // already correct
|
||||
}
|
||||
|
||||
uint8_t newMSB = (newVal >> 8) & 0xFF;
|
||||
uint8_t newLSB = newVal & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: [0x%04X] %d -> %d\n", addr, oldVal, newVal);
|
||||
|
||||
// Write new value
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(addr & 0xFF);
|
||||
Wire.write((addr >> 8) & 0xFF);
|
||||
Wire.write(newMSB);
|
||||
Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
|
||||
// Write checksum
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60);
|
||||
Wire.write(newChk);
|
||||
Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Overwrite Qmax Cell 0 (IT Cfg) — this is what FCC is derived from
|
||||
writeDM16(0x9106, designCapacity_mAh);
|
||||
|
||||
// Overwrite stored FCC reference (Gas Gauging, 2 bytes before DC)
|
||||
writeDM16(0x929D, designCapacity_mAh);
|
||||
|
||||
// Exit with reinit to apply the new values
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Qmax + stored FCC updated, exited CFG_UPDATE");
|
||||
}
|
||||
} else {
|
||||
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE check");
|
||||
}
|
||||
|
||||
// Seal first, then issue RESET.
|
||||
// RESET forces the gauge to fully reinitialize its Impedance Track
|
||||
// algorithm and recalculate FCC from the current DC/DE values.
|
||||
bq27220_writeControl(0x0030); // SEAL
|
||||
delay(5);
|
||||
Serial.println("BQ27220: Issuing RESET to force FCC recalculation...");
|
||||
bq27220_writeControl(0x0041); // RESET
|
||||
delay(2000); // Full reset needs generous settle time
|
||||
|
||||
fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: FCC after RESET: %d mAh (target <= %d)\n", fcc, designCapacity_mAh);
|
||||
|
||||
if (fcc > designCapacity_mAh * 3 / 2) {
|
||||
// RESET didn't fix FCC — the gauge IT algorithm is stubbornly
|
||||
// retaining its learned value. This typically resolves after one
|
||||
// full charge/discharge cycle. Software clamp in
|
||||
// getFullChargeCapacity() ensures correct display regardless.
|
||||
Serial.printf("BQ27220: FCC still stale at %d — software clamp active\n", fcc);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.printf("BQ27220: Updating Design Capacity from %d to %d mAh\n", currentDC, designCapacity_mAh);
|
||||
|
||||
// Step 1: Unseal (default unseal keys)
|
||||
bq27220_writeControl(0x0414); delay(2);
|
||||
bq27220_writeControl(0x3672); delay(2);
|
||||
|
||||
// Step 2: Full Access
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
|
||||
// Step 3: Enter CFG_UPDATE
|
||||
bq27220_writeControl(0x0090);
|
||||
bool cfgReady = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
delay(20);
|
||||
uint16_t opStatus = bq27220_read16(BQ27220_REG_OP_STATUS);
|
||||
if (opStatus & 0x0400) { cfgReady = true; break; }
|
||||
}
|
||||
if (!cfgReady) {
|
||||
Serial.println("BQ27220: Timeout waiting for CFGUPDATE");
|
||||
bq27220_writeControl(0x0092);
|
||||
bq27220_writeControl(0x0030);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 4: Write Design Capacity at 0x929F
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0x9F); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dataLen = bq27220_read8(0x61);
|
||||
|
||||
uint8_t newMSB = (designCapacity_mAh >> 8) & 0xFF;
|
||||
uint8_t newLSB = designCapacity_mAh & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0x9F); Wire.write(0x92);
|
||||
Wire.write(newMSB); Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(newChk); Wire.write(dataLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
// Step 4a: Write Design Energy at 0x92A1
|
||||
{
|
||||
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
uint8_t deOldMSB = bq27220_read8(0x40);
|
||||
uint8_t deOldLSB = bq27220_read8(0x41);
|
||||
uint8_t deOldChk = bq27220_read8(0x60);
|
||||
uint8_t deLen = bq27220_read8(0x61);
|
||||
uint8_t deNewMSB = (designEnergy >> 8) & 0xFF;
|
||||
uint8_t deNewLSB = designEnergy & 0xFF;
|
||||
uint8_t deTemp = (255 - deOldChk - deOldMSB - deOldLSB);
|
||||
uint8_t deNewChk = 255 - ((deTemp + deNewMSB + deNewLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: Design Energy: old=%d new=%d mWh\n",
|
||||
(deOldMSB << 8) | deOldLSB, designEnergy);
|
||||
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.write(deNewMSB); Wire.write(deNewLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(deNewChk); Wire.write(deLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
}
|
||||
|
||||
// Step 5: Exit CFG_UPDATE with reinit
|
||||
bq27220_writeControl(0x0091);
|
||||
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
|
||||
delay(200);
|
||||
|
||||
// Step 6: Seal
|
||||
bq27220_writeControl(0x0030);
|
||||
delay(5);
|
||||
|
||||
// Step 7: Force RESET to reinitialize FCC from new DC/DE
|
||||
bq27220_writeControl(0x0041); // RESET
|
||||
delay(1000);
|
||||
|
||||
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
uint16_t newFCC = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Post-config DC=%d FCC=%d mAh\n", verifyDC, newFCC);
|
||||
|
||||
return verifyDC == designCapacity_mAh;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
97
variants/lilygo_t5s3_epaper_pro/T5S3Board.h
Normal file
97
variants/lilygo_t5s3_epaper_pro/T5S3Board.h
Normal file
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include "variant.h"
|
||||
#include <Wire.h>
|
||||
#include <Arduino.h>
|
||||
#include "helpers/ESP32Board.h"
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
// BQ27220 Fuel Gauge Registers (shared with TDeckBoard)
|
||||
#define BQ27220_REG_TEMPERATURE 0x06
|
||||
#define BQ27220_REG_VOLTAGE 0x08
|
||||
#define BQ27220_REG_CURRENT 0x0C
|
||||
#define BQ27220_REG_SOC 0x2C
|
||||
#define BQ27220_REG_REMAIN_CAP 0x10
|
||||
#define BQ27220_REG_FULL_CAP 0x12
|
||||
#define BQ27220_REG_AVG_CURRENT 0x14
|
||||
#define BQ27220_REG_TIME_TO_EMPTY 0x16
|
||||
#define BQ27220_REG_AVG_POWER 0x24
|
||||
#define BQ27220_REG_DESIGN_CAP 0x3C
|
||||
#define BQ27220_REG_OP_STATUS 0x3A
|
||||
|
||||
class T5S3Board : public ESP32Board {
|
||||
public:
|
||||
void begin();
|
||||
|
||||
void powerOff() override {
|
||||
btStop();
|
||||
// Turn off backlight before sleeping
|
||||
#ifdef BOARD_BL_EN
|
||||
digitalWrite(BOARD_BL_EN, LOW);
|
||||
#endif
|
||||
}
|
||||
|
||||
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
|
||||
// Hold LoRa DIO1 and NSS 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((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
} else {
|
||||
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1) | (1ULL << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
}
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000ULL);
|
||||
}
|
||||
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
// BQ27220 fuel gauge interface (identical register protocol to TDeckBoard)
|
||||
uint16_t getBattMilliVolts() override;
|
||||
uint8_t getBatteryPercent();
|
||||
int16_t getAvgCurrent();
|
||||
int16_t getAvgPower();
|
||||
uint16_t getTimeToEmpty();
|
||||
uint16_t getRemainingCapacity();
|
||||
uint16_t getFullChargeCapacity();
|
||||
uint16_t getDesignCapacity();
|
||||
int16_t getBattTemperature();
|
||||
bool configureFuelGauge(uint16_t designCapacity_mAh = BQ27220_DESIGN_CAPACITY_MAH);
|
||||
|
||||
// Backlight control (GPIO11 — functional warm-tone front-light, PWM capable)
|
||||
// Brightness 0-255 (0=off, 153=comfortable reading, 255=max)
|
||||
bool _backlightOn = false;
|
||||
uint8_t _backlightBrightness = 153; // Same default as Meshtastic
|
||||
|
||||
void setBacklight(bool on) {
|
||||
#ifdef BOARD_BL_EN
|
||||
_backlightOn = on;
|
||||
analogWrite(BOARD_BL_EN, on ? _backlightBrightness : 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
void setBacklightBrightness(uint8_t brightness) {
|
||||
#ifdef BOARD_BL_EN
|
||||
_backlightBrightness = brightness;
|
||||
if (_backlightOn) {
|
||||
analogWrite(BOARD_BL_EN, brightness);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool isBacklightOn() const { return _backlightOn; }
|
||||
|
||||
void toggleBacklight() {
|
||||
setBacklight(!_backlightOn);
|
||||
}
|
||||
|
||||
const char* getManufacturerName() const {
|
||||
return "LilyGo T5S3 E-Paper Pro";
|
||||
}
|
||||
};
|
||||
19
variants/lilygo_t5s3_epaper_pro/pins_arduino.h
Normal file
19
variants/lilygo_t5s3_epaper_pro/pins_arduino.h
Normal file
@@ -0,0 +1,19 @@
|
||||
#ifndef Pins_Arduino_h
|
||||
#define Pins_Arduino_h
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#define USB_VID 0x303a
|
||||
#define USB_PID 0x1001
|
||||
|
||||
// Default Wire will be mapped to RTC, Touch, PCA9535, BQ25896, BQ27220, TPS65185
|
||||
static const uint8_t SDA = 39;
|
||||
static const uint8_t SCL = 40;
|
||||
|
||||
// Default SPI will be mapped to LoRa + SD card
|
||||
static const uint8_t SS = 46; // LoRa CS
|
||||
static const uint8_t MOSI = 13;
|
||||
static const uint8_t MISO = 21;
|
||||
static const uint8_t SCK = 14;
|
||||
|
||||
#endif /* Pins_Arduino_h */
|
||||
169
variants/lilygo_t5s3_epaper_pro/platformio.ini
Normal file
169
variants/lilygo_t5s3_epaper_pro/platformio.ini
Normal file
@@ -0,0 +1,169 @@
|
||||
; ===========================================================================
|
||||
; LilyGo T5 S3 E-Paper Pro (H752-B / V2 hardware)
|
||||
; 4.7" parallel e-ink (960x540), GT911 touch, SX1262 LoRa, no keyboard
|
||||
; ===========================================================================
|
||||
;
|
||||
; Place t5s3-epaper-pro.json in boards/ directory.
|
||||
; Place variant files in variants/LilyGo_T5S3_EPaper_Pro/
|
||||
; Place FastEPDDisplay.h/.cpp in src/helpers/ui/
|
||||
;
|
||||
|
||||
[LilyGo_T5S3_EPaper_Pro]
|
||||
extends = esp32_base
|
||||
extra_scripts = post:merge_firmware.py
|
||||
board = t5s3-epaper-pro
|
||||
board_build.flash_mode = qio
|
||||
board_build.f_flash = 80000000L
|
||||
board_build.arduino.memory_type = qio_opi
|
||||
board_upload.flash_size = 16MB
|
||||
build_flags =
|
||||
${esp32_base.build_flags}
|
||||
-I variants/LilyGo_T5S3_EPaper_Pro
|
||||
-D LilyGo_T5S3_EPaper_Pro
|
||||
-D T5_S3_EPAPER_PRO_V2
|
||||
-D BOARD_HAS_PSRAM=1
|
||||
-D CORE_DEBUG_LEVEL=1
|
||||
-D FORMAT_SPIFFS_IF_FAILED=1
|
||||
-D FORMAT_LITTLEFS_IF_FAILED=1
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_DIO2_AS_RF_SWITCH
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=2.4f
|
||||
-D P_LORA_DIO_1=10
|
||||
-D P_LORA_NSS=46
|
||||
-D P_LORA_RESET=1
|
||||
-D P_LORA_BUSY=47
|
||||
-D P_LORA_SCLK=14
|
||||
-D P_LORA_MISO=21
|
||||
-D P_LORA_MOSI=13
|
||||
-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 HAS_BQ27220=1
|
||||
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
|
||||
-D PIN_USER_BTN=0
|
||||
-D SDCARD_USE_SPI1
|
||||
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/LilyGo_T5S3_EPaper_Pro>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
WebServer
|
||||
DNSServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; T5S3 standalone — touch UI (stub), verify display rendering
|
||||
; Uses FastEPD for parallel e-ink, Adafruit GFX for drawing
|
||||
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_t5s3_standalone]
|
||||
extends = LilyGo_T5S3_EPaper_Pro
|
||||
build_flags =
|
||||
${LilyGo_T5S3_EPaper_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D OFFLINE_QUEUE_SIZE=1
|
||||
-D CHANNEL_MSG_HISTORY_SIZE=800
|
||||
-D DISPLAY_CLASS=FastEPDDisplay
|
||||
-D USE_EINK
|
||||
-D MECK_CARDKB
|
||||
-D MECK_OTA_UPDATE=1
|
||||
; -D MECK_SERIF_FONT ; FreeSerif (Times New Roman-like)
|
||||
; ; Default (no flag): FreeSans (Arial-like)
|
||||
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/FastEPDDisplay.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_T5S3_EPaper_Pro.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
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; 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]
|
||||
extends = LilyGo_T5S3_EPaper_Pro
|
||||
build_flags =
|
||||
${LilyGo_T5S3_EPaper_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D DISPLAY_CLASS=FastEPDDisplay
|
||||
-D USE_EINK
|
||||
-D MECK_CARDKB
|
||||
-D MECK_OTA_UPDATE=1
|
||||
; -D MECK_SERIF_FONT
|
||||
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/FastEPDDisplay.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_T5S3_EPaper_Pro.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
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; T5S3 WiFi companion — touch UI, WiFi phone bridging, web browser
|
||||
; Connect via MeshCore web app or meshcore.js over local network (TCP:5000)
|
||||
; MECK_WEB_READER: shares WiFi companion connection — no extra setup needed
|
||||
; Flash: pio run -e meck_t5s3_wifi -t upload
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_t5s3_wifi]
|
||||
extends = LilyGo_T5S3_EPaper_Pro
|
||||
build_flags =
|
||||
${LilyGo_T5S3_EPaper_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-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>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/FastEPDDisplay.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_T5S3_EPaper_Pro.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
|
||||
91
variants/lilygo_t5s3_epaper_pro/target.cpp
Normal file
91
variants/lilygo_t5s3_epaper_pro/target.cpp
Normal file
@@ -0,0 +1,91 @@
|
||||
#include <Arduino.h>
|
||||
#include "variant.h"
|
||||
#include "target.h"
|
||||
|
||||
T5S3Board board;
|
||||
|
||||
// LoRa radio on separate SPI bus
|
||||
// T5S3 V2 SPI pins: SCLK=14, MISO=21, MOSI=13 (shared with SD card)
|
||||
#if defined(P_LORA_SCLK)
|
||||
static SPIClass loraSpi(HSPI);
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, loraSpi);
|
||||
#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);
|
||||
|
||||
PCF85063Clock rtc_clock;
|
||||
|
||||
// No GPS on H752-B
|
||||
#if HAS_GPS
|
||||
GPSStreamCounter gpsStream(Serial2);
|
||||
MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
|
||||
EnvironmentSensorManager sensors(gps);
|
||||
#else
|
||||
SensorManager sensors;
|
||||
#endif
|
||||
|
||||
// Phase 2: Display
|
||||
#ifdef DISPLAY_CLASS
|
||||
DISPLAY_CLASS display;
|
||||
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
MESH_DEBUG_PRINTLN("radio_init() - starting");
|
||||
|
||||
// NOTE: board.begin() is called by main.cpp setup() before radio_init()
|
||||
// I2C is already initialized there with correct pins
|
||||
|
||||
// PCF85063 hardware RTC — reads correct registers (0x04–0x0A)
|
||||
// Unlike AutoDiscoverRTCClock which uses RTClib's PCF8563 driver (wrong registers)
|
||||
rtc_clock.begin(Wire);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - PCF85063 RTC started");
|
||||
|
||||
#if defined(P_LORA_SCLK)
|
||||
MESH_DEBUG_PRINTLN("radio_init() - initializing LoRa SPI (SCLK=%d, MISO=%d, MOSI=%d, NSS=%d)...",
|
||||
P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
|
||||
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - SPI initialized, calling radio.std_init()...");
|
||||
bool result = radio.std_init(&loraSpi);
|
||||
if (result) {
|
||||
radio.setPreambleLength(32);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("radio_init() - radio.std_init() returned: %s", result ? "SUCCESS" : "FAILED");
|
||||
return result;
|
||||
#else
|
||||
MESH_DEBUG_PRINTLN("radio_init() - calling radio.std_init() without custom SPI...");
|
||||
bool result = radio.std_init();
|
||||
if (result) {
|
||||
radio.setPreambleLength(32);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
|
||||
}
|
||||
return result;
|
||||
#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);
|
||||
}
|
||||
|
||||
void radio_reset_agc() {
|
||||
radio.setRxBoostedGainMode(true);
|
||||
}
|
||||
50
variants/lilygo_t5s3_epaper_pro/target.h
Normal file
50
variants/lilygo_t5s3_epaper_pro/target.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
// Include variant.h first to ensure all board-specific defines are available
|
||||
#include "variant.h"
|
||||
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include <T5S3Board.h>
|
||||
#include "PCF85063Clock.h"
|
||||
|
||||
// Display support — FastEPDDisplay for parallel e-ink (not GxEPD2)
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include <helpers/ui/FastEPDDisplay.h>
|
||||
#include <helpers/ui/MomentaryButton.h>
|
||||
#endif
|
||||
|
||||
// No GPS on H752-B (non-GPS variant)
|
||||
// If porting to H752-01/H752-02 with GPS, enable this:
|
||||
#if HAS_GPS
|
||||
#include "helpers/sensors/EnvironmentSensorManager.h"
|
||||
#include "helpers/sensors/MicroNMEALocationProvider.h"
|
||||
#include "GPSStreamCounter.h"
|
||||
#else
|
||||
#include <helpers/SensorManager.h>
|
||||
#endif
|
||||
|
||||
extern T5S3Board board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
extern PCF85063Clock rtc_clock;
|
||||
|
||||
#if HAS_GPS
|
||||
extern GPSStreamCounter gpsStream;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
#else
|
||||
extern SensorManager sensors;
|
||||
#endif
|
||||
|
||||
#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();
|
||||
void radio_reset_agc();
|
||||
189
variants/lilygo_t5s3_epaper_pro/variant.h
Normal file
189
variants/lilygo_t5s3_epaper_pro/variant.h
Normal file
@@ -0,0 +1,189 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// LilyGo T5 S3 E-Paper Pro V2 (H752-01/H752-B) - Pin Definitions for Meck
|
||||
//
|
||||
// 4.7" parallel e-ink (ED047TC1, 960x540, 16-grey) — NO SPI display
|
||||
// GT911 capacitive touch (no physical keyboard)
|
||||
// SX1262 LoRa, BQ27220+BQ25896 battery, PCF85063 RTC, PCA9535 IO expander
|
||||
// =============================================================================
|
||||
|
||||
// Board identifier
|
||||
#define LilyGo_T5S3_EPaper_Pro 1
|
||||
#define T5_S3_EPAPER_PRO_V2 1
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// I2C Bus — shared by GT911, PCF85063, PCA9535, BQ25896, BQ27220, TPS65185
|
||||
// -----------------------------------------------------------------------------
|
||||
#define I2C_SDA 39
|
||||
#define I2C_SCL 40
|
||||
|
||||
// Aliases for ESP32Board base class compatibility
|
||||
#define PIN_BOARD_SDA I2C_SDA
|
||||
#define PIN_BOARD_SCL I2C_SCL
|
||||
|
||||
// I2C Device Addresses
|
||||
#define I2C_ADDR_GT911 0x5D // Touch controller
|
||||
#define I2C_ADDR_PCF85063 0x51 // RTC
|
||||
#define I2C_ADDR_PCA9535 0x20 // IO expander (e-ink power control)
|
||||
#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
|
||||
// Different from T-Deck Pro! (T-Deck: 33/47/36, T5S3: 13/21/14)
|
||||
// -----------------------------------------------------------------------------
|
||||
#define BOARD_SPI_SCLK 14
|
||||
#define BOARD_SPI_MISO 21
|
||||
#define BOARD_SPI_MOSI 13
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// LoRa Radio (SX1262)
|
||||
// SPI bus shared with SD card, different chip selects
|
||||
// -----------------------------------------------------------------------------
|
||||
#define P_LORA_NSS 46
|
||||
#define P_LORA_DIO_1 10 // IRQ
|
||||
#define P_LORA_RESET 1
|
||||
#define P_LORA_BUSY 47
|
||||
#define P_LORA_SCLK BOARD_SPI_SCLK
|
||||
#define P_LORA_MISO BOARD_SPI_MISO
|
||||
#define P_LORA_MOSI BOARD_SPI_MOSI
|
||||
// Note: No P_LORA_EN on T5S3 — LoRa is always powered
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// E-Ink Display (ED047TC1 — 8-bit parallel, NOT SPI)
|
||||
// Driven by epdiy/FastEPD library via TPS65185 + PCA9535
|
||||
// GxEPD2 is NOT used on this board.
|
||||
// -----------------------------------------------------------------------------
|
||||
// Parallel data bus (directly wired to ESP32-S3 GPIOs)
|
||||
#define EP_D0 5
|
||||
#define EP_D1 6
|
||||
#define EP_D2 7
|
||||
#define EP_D3 15
|
||||
#define EP_D4 16
|
||||
#define EP_D5 17
|
||||
#define EP_D6 18
|
||||
#define EP_D7 8
|
||||
|
||||
// Control signals
|
||||
#define EP_CKV 48 // Clock vertical
|
||||
#define EP_STH 41 // Start horizontal
|
||||
#define EP_LEH 42 // Latch enable horizontal
|
||||
#define EP_STV 45 // Start vertical
|
||||
#define EP_CKH 4 // Clock horizontal (edge)
|
||||
|
||||
// E-ink power is managed by TPS65185 through PCA9535 IO expander:
|
||||
// PCA9535 IO10 -> EP_OE (output enable, source driver)
|
||||
// PCA9535 IO11 -> EP_MODE (output mode, gate driver)
|
||||
// PCA9535 IO13 -> TPS_PWRUP
|
||||
// PCA9535 IO14 -> VCOM_CTRL
|
||||
// PCA9535 IO15 -> TPS_WAKEUP
|
||||
// PCA9535 IO16 -> TPS_PWR_GOOD (input)
|
||||
// PCA9535 IO17 -> TPS_INT (input)
|
||||
|
||||
// Display dimensions — native resolution of ED047TC1
|
||||
#define EPD_WIDTH 960
|
||||
#define EPD_HEIGHT 540
|
||||
|
||||
// Backlight (warm-tone front-light — functional on V2!)
|
||||
#define BOARD_BL_EN 11
|
||||
|
||||
// We do NOT define DISPLAY_CLASS or EINK_DISPLAY_MODEL here.
|
||||
// The parallel display uses FastEPD, not GxEPD2.
|
||||
// DISPLAY_CLASS will be defined in platformio.ini as FastEPDDisplay
|
||||
// for builds that include display support.
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Touch Controller (GT911)
|
||||
// No physical keyboard on this board — touch-only input
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_TOUCHSCREEN 1
|
||||
#define GT911_PIN_INT 3
|
||||
#define GT911_PIN_RST 9
|
||||
#define GT911_PIN_SDA I2C_SDA
|
||||
#define GT911_PIN_SCL I2C_SCL
|
||||
|
||||
// No keyboard
|
||||
// #define HAS_PHYSICAL_KEYBOARD 0
|
||||
|
||||
// Compatibility: main.cpp references CST328 touch (T-Deck Pro).
|
||||
// Map to GT911 equivalents so shared code compiles.
|
||||
// The actual touch init for T5S3 will use GT911 in Phase 2.
|
||||
#define CST328_PIN_INT GT911_PIN_INT
|
||||
#define CST328_PIN_RST GT911_PIN_RST
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SD Card — shares SPI bus with LoRa
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_SDCARD
|
||||
#define SDCARD_USE_SPI1
|
||||
#define SDCARD_CS 12
|
||||
#define SPI_CS SDCARD_CS
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GPS — Not present on H752-B (non-GPS variant)
|
||||
// If a GPS model is used (H752-01/H752-02), define HAS_GPS=1
|
||||
// and uncomment the GPS pins below.
|
||||
// -----------------------------------------------------------------------------
|
||||
// #define HAS_GPS 1
|
||||
// #define GPS_BAUDRATE 38400
|
||||
// #define GPS_RX_PIN 44
|
||||
// #define GPS_TX_PIN 43
|
||||
|
||||
// Fallback for code that references GPS_BAUDRATE without HAS_GPS guard
|
||||
// (e.g. MyMesh.cpp CLI rescue command)
|
||||
#ifndef GPS_BAUDRATE
|
||||
#define GPS_BAUDRATE 9600
|
||||
#endif
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// RTC — PCF85063 (proper hardware RTC, battery-backed!)
|
||||
// This is a significant upgrade over T-Deck Pro which has no RTC.
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_PCF85063_RTC 1
|
||||
#define PCF85063_I2C_ADDR 0x51
|
||||
#define PCF85063_INT_PIN 2
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PCA9535 IO Expander
|
||||
// Controls e-ink power sequencing and has a user button
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_PCA9535 1
|
||||
#define PCA9535_I2C_ADDR 0x20
|
||||
#define PCA9535_INT_PIN 38
|
||||
|
||||
// PCA9535 pin assignments (directly from LilyGo schematic):
|
||||
// Port 0 (IO0x): IO00-IO07 — mostly unused/reserved
|
||||
// Port 1 (IO1x):
|
||||
#define PCA9535_EP_OE 0 // IO10 — EP output enable (source driver)
|
||||
#define PCA9535_EP_MODE 1 // IO11 — EP mode (gate driver)
|
||||
#define PCA9535_BUTTON 2 // IO12 — User button via IO expander
|
||||
#define PCA9535_TPS_PWRUP 3 // IO13 — TPS65185 power up
|
||||
#define PCA9535_VCOM_CTRL 4 // IO14 — VCOM control
|
||||
#define PCA9535_TPS_WAKEUP 5 // IO15 — TPS65185 wakeup
|
||||
#define PCA9535_TPS_PWRGOOD 6 // IO16 — TPS65185 power good (input)
|
||||
#define PCA9535_TPS_INT 7 // IO17 — TPS65185 interrupt (input)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Buttons & Controls
|
||||
// -----------------------------------------------------------------------------
|
||||
#define BUTTON_PIN 0 // Boot button (GPIO0)
|
||||
#define PIN_USER_BTN 0
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Power Management
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_BQ27220 1
|
||||
#define BQ27220_I2C_ADDR 0x55
|
||||
|
||||
// T5S3 E-Paper Pro battery (1500 mAh — larger than T-Deck Pro's 1400 mAh)
|
||||
#ifndef BQ27220_DESIGN_CAPACITY_MAH
|
||||
#define BQ27220_DESIGN_CAPACITY_MAH 1500
|
||||
#endif
|
||||
|
||||
#define AUTO_SHUTDOWN_MILLIVOLTS 2800
|
||||
|
||||
// No explicit peripheral power pin on T5S3 (unlike T-Deck Pro's PIN_PERF_POWERON)
|
||||
// Peripherals are always powered when the board is on.
|
||||
@@ -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");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[LilyGo_TDeck_Pro]
|
||||
extends = esp32_base
|
||||
extra_scripts = post:merge_firmware.py
|
||||
board = t-deck_pro
|
||||
board_build.flash_mode = qio
|
||||
board_build.f_flash = 80000000L
|
||||
@@ -10,6 +11,7 @@ build_flags =
|
||||
${sensor_base.build_flags}
|
||||
-I variants/LilyGo_TDeck_Pro
|
||||
-D LilyGo_TDeck_Pro
|
||||
-D HAS_GPS=1
|
||||
-D BOARD_HAS_PSRAM=1
|
||||
-D CORE_DEBUG_LEVEL=1
|
||||
-D FORMAT_SPIFFS_IF_FAILED=1
|
||||
@@ -81,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}
|
||||
@@ -92,6 +96,9 @@ lib_deps =
|
||||
zinggjm/GxEPD2@^1.5.9
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
bitbank2/PNGdec@^1.0.1
|
||||
WebServer
|
||||
DNSServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Meck unified builds — one codebase, six variants via build flags
|
||||
@@ -110,6 +117,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>
|
||||
@@ -142,7 +150,8 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.9WiFi"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.5.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -157,6 +166,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
|
||||
@@ -165,8 +175,9 @@ build_flags =
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-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>
|
||||
@@ -192,7 +203,8 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.94G"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.5.4G"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -222,7 +234,8 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.94G.WiFi"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.5.4G.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -245,10 +258,11 @@ build_flags =
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D OFFLINE_QUEUE_SIZE=1
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.94G.SA"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.5.4G.SA"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
|
||||
@@ -49,19 +49,11 @@ bool radio_init() {
|
||||
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - SPI initialized, calling radio.std_init()...");
|
||||
bool result = radio.std_init(&loraSpi);
|
||||
if (result) {
|
||||
radio.setPreambleLength(32);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("radio_init() - radio.std_init() returned: %s", result ? "SUCCESS" : "FAILED");
|
||||
return result;
|
||||
#else
|
||||
MESH_DEBUG_PRINTLN("radio_init() - calling radio.std_init() without custom SPI...");
|
||||
bool result = radio.std_init();
|
||||
if (result) {
|
||||
radio.setPreambleLength(32);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
|
||||
}
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
@@ -75,6 +67,14 @@ void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setSpreadingFactor(sf);
|
||||
radio.setBandwidth(bw);
|
||||
radio.setCodingRate(cr);
|
||||
|
||||
// Longer preamble for low SF improves reliability — each symbol is shorter
|
||||
// at low SF, so more symbols are needed for reliable detection.
|
||||
// SF <= 8 gets 32 symbols (~65ms at SF7/62.5kHz); SF >= 9 keeps 16 (already ~131ms+).
|
||||
// See: https://github.com/meshcore-dev/MeshCore/pull/1954
|
||||
uint16_t preamble = (sf <= 8) ? 32 : 16;
|
||||
radio.setPreambleLength(preamble);
|
||||
MESH_DEBUG_PRINTLN("radio_set_params() - bw=%.1f sf=%u preamble=%u", bw, sf, preamble);
|
||||
}
|
||||
|
||||
void radio_set_tx_power(uint8_t dbm) {
|
||||
|
||||
Reference in New Issue
Block a user