mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
29 Commits
crowpanel-
...
v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||
405
README.md
405
README.md
@@ -1,10 +1,19 @@
|
||||
## 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 with the assistance of Claude AI using Meshcore v1.11 code.
|
||||
|
||||
<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)
|
||||
- [Flashing Firmware](#flashing-firmware)
|
||||
- [First-Time Flash (Merged Firmware)](#first-time-flash-merged-firmware)
|
||||
- [Upgrading Firmware](#upgrading-firmware)
|
||||
- [SD Card Launcher](#sd-card-launcher)
|
||||
- [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)
|
||||
- [Clock & Timezone](#clock--timezone)
|
||||
@@ -13,18 +22,31 @@ This fork was created specifically to focus on enabling BLE companion firmware f
|
||||
- [Sending a Direct Message](#sending-a-direct-message)
|
||||
- [Repeater Admin Screen](#repeater-admin-screen)
|
||||
- [Settings Screen](#settings-screen)
|
||||
- [Serial Settings (USB)](Serial_Settings_Guide.md)
|
||||
- [Compose Mode](#compose-mode)
|
||||
- [Symbol Entry (Sym Key)](#symbol-entry-sym-key)
|
||||
- [Emoji Picker](#emoji-picker)
|
||||
- [SMS & Phone App (4G only)](#sms--phone-app-4g-only)
|
||||
- [Web Browser & IRC](#web-browser--irc)
|
||||
- [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)
|
||||
@@ -33,9 +55,109 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## 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 flashes at address `0x0` by default, so the merged file is the correct choice here for first-time flashes.
|
||||
|
||||
### 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.
|
||||
|
||||
### SD Card 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.
|
||||
|
||||
---
|
||||
|
||||
## 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 + Standalone | `meck_audio_standalone` | — | — | — | PCM5102A | No | 1,500 |
|
||||
| 4G + BLE | `meck_4g_ble` | Yes | Yes | A7682E | — | Yes | 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)
|
||||
|
||||
@@ -159,6 +281,7 @@ Press **S** from the home screen to open settings. On first boot (when the devic
|
||||
| Coding Rate | W / S to adjust (5–8), Enter to confirm |
|
||||
| TX Power | W / S to adjust (1–20 dBm), Enter to confirm |
|
||||
| UTC Offset | W / S to adjust (-12 to +14), Enter to confirm |
|
||||
| Path Hash Mode | A / D to cycle (0 = 1-byte, 1 = 2-byte, 2 = 3-byte), Enter to confirm |
|
||||
| Channels | View existing channels, add hashtag channels, or delete non-primary channels (X) |
|
||||
| Device Info | Public key and firmware version (read-only) |
|
||||
|
||||
@@ -219,7 +342,7 @@ 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
|
||||
|
||||
@@ -229,7 +352,218 @@ The web reader home screen provides access to the **IRC client**, the **URL bar*
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### Display Settings
|
||||
|
||||
The T5S3 Settings screen includes two additional options not available on the T-Deck Pro:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Dark Mode** | Inverts the display — white text on black background. Tap to toggle on/off. |
|
||||
| **Portrait Mode** | Rotates the display 90° from landscape (960×540) to portrait (540×960). Touch coordinates are automatically remapped. Text reader layout recalculates on orientation change. |
|
||||
|
||||
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 |
|
||||
| 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 | Open virtual keyboard to 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 |
|
||||
| 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 |
|
||||
| Long press | Rescan for nodes |
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -237,8 +571,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
|
||||
|
||||
@@ -263,33 +597,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 (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 is disabled by default at boot. On the T-Deck Pro, navigate to the Bluetooth home page and press Enter to enable BLE. On the T5S3, navigate to the Bluetooth home page and long-press the screen to toggle BLE on.
|
||||
|
||||
- Web: https://app.meshcore.nz
|
||||
- Android: https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android
|
||||
@@ -299,7 +622,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
|
||||
|
||||
@@ -314,6 +637,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
|
||||
@@ -323,13 +648,30 @@ 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
|
||||
- [ ] 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
|
||||
|
||||
**T5S3 E-Paper Pro:**
|
||||
- [X] Core port: display, touch input, LoRa, battery, RTC
|
||||
- [X] Touch-navigable home screen with tappable tile grid
|
||||
- [X] Full virtual keyboard for text entry
|
||||
- [X] Lock screen with clock, battery, and unread count
|
||||
- [X] 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
|
||||
- [ ] Emoji sprites on home tiles
|
||||
- [ ] Portrait mode toggle via quadruple-click Boot button
|
||||
- [ ] Hibernate should auto-off backlight
|
||||
|
||||
## 📞 Get Support
|
||||
|
||||
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.
|
||||
@@ -346,10 +688,13 @@ 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 |
|
||||
|
||||
@@ -69,6 +69,7 @@ All commands follow a simple pattern: `get` to read, `set` to write.
|
||||
| `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:**
|
||||
|
||||
@@ -294,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 |
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -2286,6 +2290,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;
|
||||
@@ -2662,10 +2674,73 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
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
|
||||
// =====================================================================
|
||||
@@ -2685,6 +2760,11 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.println(" int.thresh <0|14+> Interference threshold dB (0=off, 14=typical)");
|
||||
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");
|
||||
@@ -2707,6 +2787,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)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#define FIRMWARE_VER_CODE 10
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "8 March 2026"
|
||||
#define FIRMWARE_BUILD_DATE "13 March 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
|
||||
@@ -35,4 +35,6 @@ struct NodePrefs { // persisted to file
|
||||
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
|
||||
};
|
||||
@@ -49,10 +49,6 @@
|
||||
static bool webReaderNeedsRefresh = false;
|
||||
static bool webReaderTextEntry = false; // True when URL/password entry active
|
||||
#endif
|
||||
// AGC reset - periodically re-assert RX boosted gain to prevent sensitivity drift
|
||||
#define AGC_RESET_INTERVAL_MS 500
|
||||
static unsigned long lastAGCReset = 0;
|
||||
|
||||
// Emoji picker state
|
||||
#include "EmojiPicker.h"
|
||||
static bool emojiPickerMode = false;
|
||||
@@ -90,8 +86,6 @@
|
||||
TouchInput touchInput(&Wire);
|
||||
#endif
|
||||
|
||||
CPUPowerManager cpuPower;
|
||||
|
||||
void initKeyboard();
|
||||
void handleKeyboardInput();
|
||||
void drawComposeScreen();
|
||||
@@ -343,6 +337,87 @@
|
||||
}
|
||||
#endif
|
||||
|
||||
// =============================================================================
|
||||
// T5S3 E-Paper Pro — GT911 Touch Input
|
||||
// =============================================================================
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include "TouchDrvGT911.hpp"
|
||||
#include <SD.h>
|
||||
#include "TextReaderScreen.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "ContactsScreen.h"
|
||||
#include "ChannelScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "DiscoveryScreen.h"
|
||||
|
||||
static TouchDrvGT911 gt911Touch;
|
||||
static bool gt911Ready = false;
|
||||
static bool sdCardReady = false; // T5S3 SD card state
|
||||
|
||||
// Touch state machine — supports tap, long press, and swipe
|
||||
static bool touchDown = false;
|
||||
static unsigned long touchDownTime = 0;
|
||||
static int16_t touchDownX = 0;
|
||||
static int16_t touchDownY = 0;
|
||||
static int16_t touchLastX = 0;
|
||||
static int16_t touchLastY = 0;
|
||||
static unsigned long lastTouchSeenMs = 0; // Last time getPoint() returned true
|
||||
#define TOUCH_LONG_PRESS_MS 500
|
||||
#define TOUCH_SWIPE_THRESHOLD 60 // Min pixels to count as a swipe (physical)
|
||||
#define TOUCH_LIFT_DEBOUNCE_MS 150 // No-touch duration before "finger lifted"
|
||||
#define TOUCH_MIN_INTERVAL_MS 300 // Min ms between accepted events
|
||||
static bool longPressHandled = false;
|
||||
static bool swipeHandled = false;
|
||||
static bool touchCooldown = false;
|
||||
static unsigned long lastTouchEventMs = 0;
|
||||
|
||||
// Read GT911 in landscape orientation (960×540)
|
||||
// GT911 reports portrait (540×960), rotate: x=raw_y, y=540-1-raw_x
|
||||
// Note: Do NOT gate on GT911_PIN_INT — it pulses briefly per event
|
||||
// and goes high between reports, causing drags to look like taps.
|
||||
// Polling getPoint() directly works for continuous touch tracking.
|
||||
static bool readTouchLandscape(int16_t* outX, int16_t* outY) {
|
||||
if (!gt911Ready) return false;
|
||||
int16_t raw_x, raw_y;
|
||||
if (gt911Touch.getPoint(&raw_x, &raw_y)) {
|
||||
*outX = raw_y;
|
||||
*outY = EPD_HEIGHT - 1 - raw_x;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read GT911 in portrait orientation (540×960, rotation 3)
|
||||
// Maps GT911 native coords to portrait logical space
|
||||
// Read GT911 in portrait orientation (540×960, canvas rotation 3)
|
||||
// Rotation 3 maps logical(lx,ly) → physical(ly, 539-lx).
|
||||
// Inverting: logical_x = raw_x, logical_y = raw_y (GT911 native = portrait).
|
||||
static bool readTouchPortrait(int16_t* outX, int16_t* outY) {
|
||||
if (!gt911Ready) return false;
|
||||
int16_t raw_x, raw_y;
|
||||
if (gt911Touch.getPoint(&raw_x, &raw_y)) {
|
||||
*outX = raw_x;
|
||||
*outY = raw_y;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unified touch reader — picks landscape or portrait based on display mode
|
||||
static bool readTouch(int16_t* outX, int16_t* outY) {
|
||||
if (display.isPortraitMode()) {
|
||||
return readTouchPortrait(outX, outY);
|
||||
}
|
||||
return readTouchLandscape(outX, outY);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Board-agnostic: CPU frequency scaling and AGC reset
|
||||
CPUPowerManager cpuPower;
|
||||
#define AGC_RESET_INTERVAL_MS 500
|
||||
static unsigned long lastAGCReset = 0;
|
||||
|
||||
// Believe it or not, this std C function is busted on some platforms!
|
||||
static uint32_t _atoi(const char* sp) {
|
||||
uint32_t n = 0;
|
||||
@@ -436,7 +511,9 @@ static uint32_t _atoi(const char* sp) {
|
||||
/* GLOBAL OBJECTS */
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include "UITask.h"
|
||||
#include "MapScreen.h" // After BLE — PNGdec headers conflict with BLE if included earlier
|
||||
#if HAS_GPS
|
||||
#include "MapScreen.h" // After BLE — PNGdec headers conflict with BLE if included earlier
|
||||
#endif
|
||||
UITask ui_task(&board, &serial_interface);
|
||||
#endif
|
||||
|
||||
@@ -450,6 +527,316 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
|
||||
|
||||
/* END GLOBAL OBJECTS */
|
||||
|
||||
// T5S3 touch mapping — must be after ui_task declaration
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// Map a single tap based on current screen context
|
||||
static char mapTouchTap(int16_t x, int16_t y) {
|
||||
// Convert physical screen coords to virtual (128×128) using current scale
|
||||
// Scale factors change between landscape (7.5, 4.22) and portrait (4.22, 7.5)
|
||||
float sx = display.isPortraitMode() ? ((float)EPD_HEIGHT / 128.0f) : ((float)EPD_WIDTH / 128.0f);
|
||||
float sy = display.isPortraitMode() ? ((float)EPD_WIDTH / 128.0f) : ((float)EPD_HEIGHT / 128.0f);
|
||||
int vx = (int)(x / sx);
|
||||
int vy = (int)(y / sy);
|
||||
|
||||
// --- Status bar tap (top ~18 virtual units) → go home from any non-home screen ---
|
||||
// Exception: text reader reading mode uses full screen for content (no header)
|
||||
if (vy < 18 && !ui_task.isOnHomeScreen()) {
|
||||
if (ui_task.isOnTextReader()) {
|
||||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||||
if (reader && reader->isReading()) {
|
||||
return 'd'; // reading mode: treat as next page
|
||||
}
|
||||
}
|
||||
ui_task.gotoHomeScreen();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Home screen FIRST page: tile taps (virtual coordinate hit test)
|
||||
if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) {
|
||||
const int tileW = 40, tileH = 28, gapX = 1, gapY = 1;
|
||||
const int gridW = tileW * 3 + gapX * 2;
|
||||
const int gridX = (128 - gridW) / 2; // =3
|
||||
int gridY = ui_task.getTileGridVY();
|
||||
|
||||
// Check if tap is within the tile grid area
|
||||
if (vx >= gridX && vx < gridX + gridW &&
|
||||
vy >= gridY && vy < gridY + 2 * (tileH + gapY)) {
|
||||
int col = (vx - gridX) / (tileW + gapX);
|
||||
if (col > 2) col = 2;
|
||||
int row = (vy - gridY) / (tileH + gapY);
|
||||
if (row > 1) row = 1;
|
||||
|
||||
if (row == 0 && col == 0) { ui_task.gotoChannelScreen(); return 0; }
|
||||
if (row == 0 && col == 1) { ui_task.gotoContactsScreen(); return 0; }
|
||||
if (row == 0 && col == 2) { ui_task.gotoSettingsScreen(); return 0; }
|
||||
if (row == 1 && col == 0) { ui_task.gotoTextReader(); return 0; }
|
||||
if (row == 1 && col == 1) { ui_task.gotoNotesScreen(); return 0; }
|
||||
#ifdef MECK_WEB_READER
|
||||
if (row == 1 && col == 2) { ui_task.gotoWebReader(); return 0; }
|
||||
#else
|
||||
if (row == 1 && col == 2) { ui_task.gotoDiscoveryScreen(); return 0; }
|
||||
#endif
|
||||
}
|
||||
// Tap outside tiles — left half backward, right half forward
|
||||
return (vx < 64) ? (char)KEY_PREV : (char)KEY_NEXT;
|
||||
}
|
||||
|
||||
// Home screen (non-tile pages): left half taps backward, right half forward
|
||||
if (ui_task.isOnHomeScreen()) {
|
||||
return (vx < 64) ? (char)KEY_PREV : (char)KEY_NEXT;
|
||||
}
|
||||
|
||||
// Reader (reading mode): tap = next page
|
||||
if (ui_task.isOnTextReader()) {
|
||||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||||
if (reader && reader->isReading()) {
|
||||
return 'd'; // next page
|
||||
}
|
||||
return KEY_ENTER; // file list: open selected
|
||||
}
|
||||
|
||||
// Notes editing: tap → open keyboard for typing
|
||||
if (ui_task.isOnNotesScreen()) {
|
||||
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
|
||||
if (notes && notes->isEditing()) {
|
||||
ui_task.showVirtualKeyboard(VKB_NOTES, "Edit Note", "", 137);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
// Web reader: context-dependent taps (VKB for text fields, navigation elsewhere)
|
||||
if (ui_task.isOnWebReader()) {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
if (wr) {
|
||||
if (wr->isReading()) {
|
||||
// Footer zone tap → open link VKB (if links exist)
|
||||
if (vy >= 113 && wr->getLinkCount() > 0) {
|
||||
ui_task.showVirtualKeyboard(VKB_WEB_LINK, "Link #", "", 3);
|
||||
return 0;
|
||||
}
|
||||
return 'd'; // Tap reading area → next page
|
||||
}
|
||||
|
||||
if (wr->isHome()) {
|
||||
int sel = wr->getHomeSelected();
|
||||
if (sel == 1) {
|
||||
// URL bar → open VKB for URL entry
|
||||
ui_task.showVirtualKeyboard(VKB_WEB_URL, "Enter URL",
|
||||
wr->getUrlText(), WEB_MAX_URL_LEN - 1);
|
||||
return 0;
|
||||
}
|
||||
if (sel == 2) {
|
||||
// Search → open VKB for DuckDuckGo search
|
||||
ui_task.showVirtualKeyboard(VKB_WEB_SEARCH, "Search DuckDuckGo", "", 127);
|
||||
return 0;
|
||||
}
|
||||
return KEY_ENTER; // IRC, bookmarks, history: select
|
||||
}
|
||||
|
||||
if (wr->isWifiSetup()) {
|
||||
if (wr->isPasswordEntry()) {
|
||||
// Open VKB for WiFi password entry
|
||||
ui_task.showVirtualKeyboard(VKB_WEB_WIFI_PASS, "WiFi Password", "", 63);
|
||||
return 0;
|
||||
}
|
||||
return KEY_ENTER; // SSID list: select, failed: retry
|
||||
}
|
||||
}
|
||||
return KEY_ENTER;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Channel screen: tap footer area → hop path, tap elsewhere → no action
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chScr && chScr->isShowingPathOverlay()) {
|
||||
return 'q'; // Dismiss overlay on any tap
|
||||
}
|
||||
// Footer zone: bottom ~15 virtual units (≈63 physical pixels)
|
||||
if (vy >= 113) {
|
||||
return 'v'; // Show path overlay
|
||||
}
|
||||
return 0; // Tap on message area — consumed, no action
|
||||
}
|
||||
|
||||
// All other screens: tap = select
|
||||
return KEY_ENTER;
|
||||
}
|
||||
|
||||
// Map a swipe direction to a key
|
||||
static char mapTouchSwipe(int16_t dx, int16_t dy) {
|
||||
bool horizontal = abs(dx) > abs(dy);
|
||||
|
||||
// Reader (reading mode): swipe left/right for page turn
|
||||
if (ui_task.isOnTextReader()) {
|
||||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||||
if (reader && reader->isReading()) {
|
||||
if (horizontal) {
|
||||
return (dx < 0) ? 'd' : 'a'; // swipe left=next, right=prev
|
||||
}
|
||||
// Vertical swipe in reader: also page turn (natural scroll)
|
||||
return (dy > 0) ? 'd' : 'a'; // swipe down=next, up=prev
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
// Web reader: page turn in reading mode, list scroll elsewhere
|
||||
if (ui_task.isOnWebReader()) {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
if (wr && wr->isReading()) {
|
||||
if (horizontal) {
|
||||
return (dx < 0) ? 'd' : 'a'; // swipe left=next page, right=prev
|
||||
}
|
||||
return (dy > 0) ? 'd' : 'a'; // swipe down=next, up=prev
|
||||
}
|
||||
// HOME / WIFI_SETUP / other: vertical swipe = scroll list
|
||||
if (!horizontal) {
|
||||
return (dy > 0) ? 's' : 'w';
|
||||
}
|
||||
return 0; // Ignore horizontal swipes on non-reading modes
|
||||
}
|
||||
#endif
|
||||
|
||||
// Home screen: swipe left = next page, swipe right = previous page
|
||||
if (ui_task.isOnHomeScreen()) {
|
||||
if (horizontal) {
|
||||
return (dx < 0) ? (char)KEY_NEXT : (char)KEY_PREV;
|
||||
}
|
||||
return (char)KEY_NEXT; // vertical swipe = next (default)
|
||||
}
|
||||
|
||||
// Settings: horizontal swipe → a/d for picker/number editing
|
||||
if (ui_task.isOnSettingsScreen() && horizontal) {
|
||||
return (dx < 0) ? 'd' : 'a'; // swipe left=next option, right=prev
|
||||
}
|
||||
|
||||
// Channel screen: horizontal swipe → a/d to switch channels
|
||||
if (ui_task.isOnChannelScreen() && horizontal) {
|
||||
return (dx < 0) ? 'd' : 'a'; // swipe left=next channel, right=prev
|
||||
}
|
||||
|
||||
// Contacts screen: horizontal swipe → a/d to change filter
|
||||
if (ui_task.isOnContactsScreen() && horizontal) {
|
||||
return (dx < 0) ? 'd' : 'a'; // swipe left=next filter, right=prev
|
||||
}
|
||||
|
||||
// All other screens: vertical swipe scrolls
|
||||
if (!horizontal) {
|
||||
return (dy > 0) ? 's' : 'w'; // swipe down=scroll down, up=scroll up
|
||||
}
|
||||
|
||||
return 0; // ignore horizontal swipes on non-applicable screens
|
||||
}
|
||||
|
||||
// Map a long press to a key
|
||||
static char mapTouchLongPress(int16_t x, int16_t y) {
|
||||
// Home screen: long press = activate current page action
|
||||
// (BLE toggle, send advert, hibernate, GPS toggle, etc.)
|
||||
if (ui_task.isOnHomeScreen()) {
|
||||
return (char)KEY_ENTER;
|
||||
}
|
||||
|
||||
// Reader reading: long press = close book
|
||||
if (ui_task.isOnTextReader()) {
|
||||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||||
if (reader && reader->isReading()) {
|
||||
return 'q';
|
||||
}
|
||||
return KEY_ENTER; // file list: open
|
||||
}
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
// Web reader: long press for back navigation
|
||||
if (ui_task.isOnWebReader()) {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
if (wr) {
|
||||
if (wr->isReading()) {
|
||||
return 'b'; // Back to previous page, or HOME if no history
|
||||
}
|
||||
if (wr->isHome()) {
|
||||
int sel = wr->getHomeSelected();
|
||||
int bmEnd = 3 + wr->getBookmarkCount();
|
||||
if (sel >= 3 && sel < bmEnd) {
|
||||
return '\b'; // Long press on bookmark → delete
|
||||
}
|
||||
return 'q'; // All others: exit web reader
|
||||
}
|
||||
if (wr->isWifiSetup()) {
|
||||
return 'q'; // Back from WiFi setup
|
||||
}
|
||||
if (wr->isIRCMode()) {
|
||||
return 'q'; // Back from IRC
|
||||
}
|
||||
}
|
||||
return 'q'; // Default: back out
|
||||
}
|
||||
#endif
|
||||
|
||||
// Channel screen: long press → compose to current channel
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
uint8_t chIdx = ui_task.getChannelScreenViewIdx();
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(chIdx, ch)) {
|
||||
char label[40];
|
||||
snprintf(label, sizeof(label), "To: %s", ch.name);
|
||||
ui_task.showVirtualKeyboard(VKB_CHANNEL_MSG, label, "", 137, chIdx);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Contacts screen: long press → DM for chat contacts, admin for repeaters
|
||||
if (ui_task.isOnContactsScreen()) {
|
||||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||||
if (cs) {
|
||||
int idx = cs->getSelectedContactIdx();
|
||||
uint8_t ctype = cs->getSelectedContactType();
|
||||
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
|
||||
char dname[32];
|
||||
cs->getSelectedContactName(dname, sizeof(dname));
|
||||
char label[40];
|
||||
snprintf(label, sizeof(label), "DM: %s", dname);
|
||||
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx);
|
||||
return 0;
|
||||
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
|
||||
ui_task.gotoRepeaterAdmin(idx);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return KEY_ENTER;
|
||||
}
|
||||
|
||||
// Discovery screen: long press = rescan
|
||||
if (ui_task.isOnDiscoveryScreen()) {
|
||||
return 'f';
|
||||
}
|
||||
|
||||
// Repeater admin: long press → open keyboard for password or CLI
|
||||
if (ui_task.isOnRepeaterAdmin()) {
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
|
||||
if (admin) {
|
||||
RepeaterAdminScreen::AdminState astate = admin->getState();
|
||||
if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
|
||||
ui_task.showVirtualKeyboard(VKB_ADMIN_PASSWORD, "Admin Password", "", 32);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notes screen: long press in editor → save and exit
|
||||
if (ui_task.isOnNotesScreen()) {
|
||||
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
|
||||
if (notes && notes->isEditing()) {
|
||||
notes->triggerSaveAndExit();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: enter/select (settings toggle, etc.)
|
||||
return KEY_ENTER;
|
||||
}
|
||||
#endif
|
||||
|
||||
void halt() {
|
||||
while (1) ;
|
||||
}
|
||||
@@ -648,6 +1035,37 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - SD card not available after 3 attempts");
|
||||
}
|
||||
}
|
||||
#elif defined(LilyGo_T5S3_EPaper_Pro) && defined(HAS_SDCARD)
|
||||
{
|
||||
// T5S3: SD card shares LoRa SPI bus (SCK=14, MOSI=13, MISO=21)
|
||||
// LoRa SPI already initialized by target.cpp. Create a local HSPI
|
||||
// reference for SD init (same hardware peripheral, different CS).
|
||||
static SPIClass sdSpi(HSPI);
|
||||
sdSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, SDCARD_CS);
|
||||
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
pinMode(P_LORA_NSS, OUTPUT);
|
||||
digitalWrite(P_LORA_NSS, HIGH);
|
||||
delay(100);
|
||||
|
||||
bool mounted = false;
|
||||
for (int attempt = 0; attempt < 3 && !mounted; attempt++) {
|
||||
if (attempt > 0) {
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
delay(250);
|
||||
Serial.printf("setup() - SD card retry %d/3\n", attempt + 1);
|
||||
}
|
||||
mounted = SD.begin(SDCARD_CS, sdSpi, 4000000);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
sdCardReady = true;
|
||||
Serial.println("setup() - SD card initialized");
|
||||
} else {
|
||||
Serial.println("setup() - SD card not available");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
MESH_DEBUG_PRINTLN("setup() - about to call store.begin()");
|
||||
@@ -749,8 +1167,8 @@ void setup() {
|
||||
initKeyboard();
|
||||
#endif
|
||||
|
||||
// Initialize touch input (CST328)
|
||||
#ifdef HAS_TOUCHSCREEN
|
||||
// Initialize touch input (CST328 on T-Deck Pro)
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN)
|
||||
if (touchInput.begin(CST328_PIN_INT)) {
|
||||
MESH_DEBUG_PRINTLN("setup() - Touch input initialized");
|
||||
} else {
|
||||
@@ -758,8 +1176,73 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SD card is already initialized (early init above).
|
||||
// Initialize GT911 touch (T5S3 E-Paper Pro)
|
||||
// Wire is already initialized by T5S3Board::begin(). The 4-arg begin() re-calls
|
||||
// Wire.begin() which logs "bus already initialized" — cosmetic only, not harmful.
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
gt911Touch.setPins(GT911_PIN_RST, GT911_PIN_INT);
|
||||
if (gt911Touch.begin(Wire, GT911_SLAVE_ADDRESS_L, GT911_PIN_SDA, GT911_PIN_SCL)) {
|
||||
gt911Ready = true;
|
||||
Serial.println("setup() - GT911 touch initialized");
|
||||
} else {
|
||||
Serial.println("setup() - GT911 touch FAILED");
|
||||
}
|
||||
#endif
|
||||
|
||||
// RTC diagnostic + boot-time serial clock sync (T5S3 has no GPS)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
{
|
||||
uint32_t rtcTime = rtc_clock.getCurrentTime();
|
||||
Serial.printf("setup() - RTC time: %lu (valid=%s)\n", rtcTime,
|
||||
rtcTime > 1700000000 ? "YES" : "NO");
|
||||
if (rtcTime < 1700000000) {
|
||||
// No valid time. If a USB host has the serial port open (Serial
|
||||
// evaluates true on ESP32-S3 native CDC), request an automatic
|
||||
// clock sync. The PlatformIO monitor filter "clock_sync" watches
|
||||
// for MECK_CLOCK_REQ and responds immediately with the host time.
|
||||
// Manual sync is also accepted: type "clock sync <epoch>" in any
|
||||
// serial terminal.
|
||||
if (Serial) {
|
||||
Serial.println("MECK_CLOCK_REQ");
|
||||
Serial.println(" (Waiting 3s for clock sync from host...)");
|
||||
|
||||
char syncBuf[64];
|
||||
int syncPos = 0;
|
||||
unsigned long syncDeadline = millis() + 3000;
|
||||
bool synced = false;
|
||||
|
||||
while (millis() < syncDeadline && !synced) {
|
||||
while (Serial.available() && syncPos < (int)sizeof(syncBuf) - 1) {
|
||||
char c = Serial.read();
|
||||
if (c == '\r' || c == '\n') {
|
||||
if (syncPos > 0) {
|
||||
syncBuf[syncPos] = '\0';
|
||||
if (memcmp(syncBuf, "clock sync ", 11) == 0) {
|
||||
uint32_t epoch = (uint32_t)strtoul(&syncBuf[11], nullptr, 10);
|
||||
if (epoch > 1704067200UL && epoch < 2082758400UL) {
|
||||
rtc_clock.setCurrentTime(epoch);
|
||||
Serial.printf(" > Clock synced to %lu\n", (unsigned long)epoch);
|
||||
synced = true;
|
||||
}
|
||||
}
|
||||
syncPos = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
syncBuf[syncPos++] = c;
|
||||
}
|
||||
if (!synced) delay(10);
|
||||
}
|
||||
if (!synced) {
|
||||
Serial.println(" > No clock sync received, continuing boot");
|
||||
Serial.println(" > Use 'clock sync <epoch>' any time to sync later");
|
||||
}
|
||||
} else {
|
||||
Serial.println("setup() - RTC not set, no serial host detected (skipping sync window)");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Now set up SD-dependent features: message history + text reader.
|
||||
// ---------------------------------------------------------------------------
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
|
||||
@@ -825,8 +1308,36 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// First-boot onboarding detection
|
||||
// T5S3 SD-dependent features
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && defined(HAS_SDCARD)
|
||||
if (sdCardReady) {
|
||||
// Channel message history
|
||||
ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chanScr) {
|
||||
chanScr->setSDReady(true);
|
||||
if (chanScr->loadFromSD()) {
|
||||
Serial.println("setup() - Message history loaded from SD");
|
||||
}
|
||||
}
|
||||
|
||||
// Text reader — set SD ready and pre-index books
|
||||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||||
if (reader) {
|
||||
reader->setSDReady(true);
|
||||
if (disp) {
|
||||
cpuPower.setBoost();
|
||||
reader->bootIndex(*disp);
|
||||
}
|
||||
}
|
||||
|
||||
// Notes screen
|
||||
NotesScreen* notesScr = (NotesScreen*)ui_task.getNotesScreen();
|
||||
if (notesScr) {
|
||||
notesScr->setSDReady(true);
|
||||
}
|
||||
Serial.println("setup() - SD features initialized");
|
||||
}
|
||||
#endif
|
||||
// Check if node name is still the default hex prefix (first 4 bytes of pub key)
|
||||
// If so, launch onboarding wizard to set name and radio preset
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -861,9 +1372,9 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// T-Deck Pro: BLE starts disabled for standalone-first operation
|
||||
// User can toggle it on from the Bluetooth home page (Enter or long-press)
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE)
|
||||
// BLE starts disabled for standalone-first operation
|
||||
// User can toggle it from the Bluetooth home page (Enter or long-press)
|
||||
#if (defined(LilyGo_TDeck_Pro) || defined(LilyGo_T5S3_EPaper_Pro)) && defined(BLE_PIN_CODE)
|
||||
serial_interface.disable();
|
||||
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
|
||||
#endif
|
||||
@@ -880,6 +1391,7 @@ void loop() {
|
||||
sensors.loop();
|
||||
|
||||
// Map screen: periodically update own GPS position and contact markers
|
||||
#if HAS_GPS
|
||||
if (ui_task.isOnMapScreen()) {
|
||||
static unsigned long lastMapUpdate = 0;
|
||||
if (millis() - lastMapUpdate > 30000) { // Every 30 seconds
|
||||
@@ -887,9 +1399,7 @@ void loop() {
|
||||
MapScreen* ms = (MapScreen*)ui_task.getMapScreen();
|
||||
if (ms) {
|
||||
// Update own GPS position when GPS is enabled
|
||||
#if HAS_GPS
|
||||
ms->updateGPSPosition(sensors.node_lat, sensors.node_lon);
|
||||
#endif
|
||||
|
||||
// Always refresh contact markers (new contacts arrive via radio)
|
||||
ms->clearMarkers();
|
||||
@@ -905,12 +1415,13 @@ void loop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// CPU frequency auto-timeout back to idle
|
||||
cpuPower.loop();
|
||||
|
||||
// Audiobook: service audio decode regardless of which screen is active
|
||||
#ifndef HAS_4G_MODEM
|
||||
#if defined(LilyGo_TDeck_Pro) && !defined(HAS_4G_MODEM)
|
||||
{
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
@@ -1119,6 +1630,136 @@ void loop() {
|
||||
handleKeyboardInput();
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T5S3 GT911 Touch Input — tap/swipe/long-press state machine
|
||||
// Gestures:
|
||||
// Tap = finger down + up with minimal movement → select/open
|
||||
// Swipe = finger drag > threshold → scroll/page turn
|
||||
// Long press = finger held > 500ms without moving → edit/enter
|
||||
// After processing an event, cooldown waits for finger lift before next event.
|
||||
// Touch is disabled while lock screen is active.
|
||||
// When virtual keyboard is active, taps route to keyboard.
|
||||
// ---------------------------------------------------------------------------
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
if (!ui_task.isLocked() && !ui_task.isVKBActive())
|
||||
{
|
||||
int16_t tx, ty;
|
||||
bool gotPoint = readTouch(&tx, &ty);
|
||||
unsigned long now = millis();
|
||||
|
||||
if (gotPoint) {
|
||||
lastTouchSeenMs = now; // Track when we last saw a valid touch report
|
||||
}
|
||||
|
||||
// Determine if finger is "present" — GT911 getPoint() only returns true
|
||||
// once per report cycle (~10ms), then returns false until the next report.
|
||||
// During a blocking e-ink refresh (~1s), many cycles are missed.
|
||||
// So "finger lifted" = no valid report for TOUCH_LIFT_DEBOUNCE_MS.
|
||||
bool fingerPresent = (now - lastTouchSeenMs) < TOUCH_LIFT_DEBOUNCE_MS;
|
||||
|
||||
// Rate limit — after processing an event, wait for finger lift + cooldown
|
||||
if (touchCooldown) {
|
||||
if (!fingerPresent && (now - lastTouchEventMs) >= TOUCH_MIN_INTERVAL_MS) {
|
||||
touchCooldown = false;
|
||||
touchDown = false;
|
||||
}
|
||||
}
|
||||
else if (gotPoint && !touchDown) {
|
||||
// Finger just touched down (first valid report)
|
||||
touchDown = true;
|
||||
touchDownTime = now;
|
||||
touchDownX = tx;
|
||||
touchDownY = ty;
|
||||
touchLastX = tx;
|
||||
touchLastY = ty;
|
||||
longPressHandled = false;
|
||||
swipeHandled = false;
|
||||
}
|
||||
else if (touchDown && fingerPresent) {
|
||||
// Finger still down — update position if we got a new point
|
||||
if (gotPoint) {
|
||||
touchLastX = tx;
|
||||
touchLastY = ty;
|
||||
}
|
||||
|
||||
int16_t dx = touchLastX - touchDownX;
|
||||
int16_t dy = touchLastY - touchDownY;
|
||||
int16_t dist = abs(dx) > abs(dy) ? abs(dx) : abs(dy);
|
||||
|
||||
// Swipe detection — fire once when threshold exceeded
|
||||
if (!swipeHandled && !longPressHandled && dist >= TOUCH_SWIPE_THRESHOLD) {
|
||||
swipeHandled = true;
|
||||
Serial.printf("[Touch] SWIPE dx=%d dy=%d\n", dx, dy);
|
||||
char c = mapTouchSwipe(dx, dy);
|
||||
if (c) {
|
||||
ui_task.injectKey(c);
|
||||
cpuPower.setBoost();
|
||||
}
|
||||
lastTouchEventMs = now;
|
||||
touchCooldown = true;
|
||||
}
|
||||
// Long press — only if finger hasn't moved much
|
||||
else if (!longPressHandled && !swipeHandled && dist < TOUCH_SWIPE_THRESHOLD &&
|
||||
(now - touchDownTime) >= TOUCH_LONG_PRESS_MS) {
|
||||
longPressHandled = true;
|
||||
Serial.printf("[Touch] LONG PRESS at (%d,%d)\n", touchDownX, touchDownY);
|
||||
char c = mapTouchLongPress(touchDownX, touchDownY);
|
||||
if (c) {
|
||||
ui_task.injectKey(c);
|
||||
cpuPower.setBoost();
|
||||
}
|
||||
lastTouchEventMs = now;
|
||||
touchCooldown = true;
|
||||
}
|
||||
}
|
||||
else if (touchDown && !fingerPresent) {
|
||||
// Finger lifted (no report for TOUCH_LIFT_DEBOUNCE_MS)
|
||||
touchDown = false;
|
||||
if (!longPressHandled && !swipeHandled) {
|
||||
Serial.printf("[Touch] TAP at (%d,%d)\n", touchDownX, touchDownY);
|
||||
char c = mapTouchTap(touchDownX, touchDownY);
|
||||
if (c) {
|
||||
ui_task.injectKey(c);
|
||||
}
|
||||
cpuPower.setBoost();
|
||||
lastTouchEventMs = now;
|
||||
touchCooldown = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Virtual keyboard touch routing.
|
||||
// Guard: require finger lift AND 2s after VKB opened before accepting taps.
|
||||
// The 2s covers the ~1s blocking e-ink refresh plus margin for finger lift.
|
||||
{
|
||||
static bool vkbNeedLift = true;
|
||||
|
||||
if (ui_task.isVKBActive()) {
|
||||
int16_t tx, ty;
|
||||
bool gotPt = readTouch(&tx, &ty);
|
||||
|
||||
if (!gotPt) {
|
||||
vkbNeedLift = false; // Finger lifted
|
||||
}
|
||||
|
||||
bool cooldownOk = (millis() - ui_task.vkbOpenedAt()) > 2000;
|
||||
|
||||
if (gotPt && !vkbNeedLift && cooldownOk) {
|
||||
float sx = display.isPortraitMode() ? ((float)EPD_HEIGHT / 128.0f) : ((float)EPD_WIDTH / 128.0f);
|
||||
float sy = display.isPortraitMode() ? ((float)EPD_WIDTH / 128.0f) : ((float)EPD_HEIGHT / 128.0f);
|
||||
int vx = (int)(tx / sx);
|
||||
int vy = (int)(ty / sy);
|
||||
if (ui_task.getVKB().handleTap(vx, vy)) {
|
||||
ui_task.forceRefresh();
|
||||
}
|
||||
vkbNeedLift = true; // Require lift before next tap
|
||||
}
|
||||
} else {
|
||||
vkbNeedLift = true; // Reset for next VKB open
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Poll touch input for phone dialer numpad
|
||||
// Hybrid debounce: finger-up detection + 150ms minimum between accepted taps.
|
||||
// The CST328 INT pin is pulse-based (not level), so getPoint() can return
|
||||
|
||||
@@ -639,6 +639,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 +652,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;
|
||||
@@ -664,9 +669,15 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
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 {
|
||||
display.setTextSize(0); // Tiny font for message body
|
||||
@@ -735,7 +746,11 @@ public:
|
||||
int availH = maxY - y;
|
||||
if (maxFillH > availH) maxFillH = availH;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, maxFillH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, contentW, maxFillH);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Time indicator with hop count - inline on same line as message start
|
||||
@@ -807,7 +822,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 +853,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 +871,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 +907,11 @@ public:
|
||||
if (maxFillH > availH) maxFillH = availH;
|
||||
if (usedH < maxFillH) {
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, maxFillH - usedH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, contentW, maxFillH - usedH);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -943,6 +966,16 @@ public:
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Swipe:Ch/Scroll");
|
||||
const char* midCh = "Tap:Path";
|
||||
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
|
||||
display.print(midCh);
|
||||
const char* rtCh = "Hold:Compose";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
|
||||
display.print(rtCh);
|
||||
#else
|
||||
// Left side: abbreviated controls
|
||||
if (_replySelectMode) {
|
||||
display.print("W/S:Sel V:Pth Q:X");
|
||||
@@ -955,8 +988,9 @@ public:
|
||||
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;
|
||||
|
||||
@@ -219,7 +219,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 +241,11 @@ public:
|
||||
// Highlight: fill LIGHT rect first, then draw DARK text on top
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -297,6 +305,13 @@ public:
|
||||
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: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");
|
||||
@@ -310,6 +325,7 @@ public:
|
||||
const char* right = "F:Dscvr";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
return 5000; // e-ink: next render after 5s
|
||||
}
|
||||
|
||||
@@ -79,7 +79,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 +100,11 @@ public:
|
||||
// Highlight selected row
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -158,6 +166,17 @@ public:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe:Scroll");
|
||||
|
||||
const char* mid = "Tap:Add";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
const char* right = "Hold:Rescan";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.print("Q:Back");
|
||||
|
||||
const char* mid = "Ent:Add";
|
||||
@@ -167,6 +186,7 @@ public:
|
||||
const char* right = "F:Rescan";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
// Faster refresh while actively scanning
|
||||
return active ? 1000 : 5000;
|
||||
|
||||
@@ -496,7 +496,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);
|
||||
@@ -511,7 +515,7 @@ private:
|
||||
|
||||
// File list with "+ New Note" at index 0
|
||||
display.setTextSize(0);
|
||||
int listLineH = 8;
|
||||
int listLineH = 9; // Match contacts/discovery for consistent selection highlight
|
||||
int startY = 14;
|
||||
int totalItems = 1 + (int)_fileList.size();
|
||||
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
|
||||
@@ -528,7 +532,11 @@ private:
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -558,9 +566,13 @@ 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:Nav");
|
||||
const char* right = "Tap:Open";
|
||||
#else
|
||||
display.print("Q:Back W/S:Nav");
|
||||
|
||||
const char* right = "Ent:Open";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
@@ -576,9 +588,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("Tap:Edit");
|
||||
const char* right = "Hold:Delete";
|
||||
#else
|
||||
display.print("Q:Bck Ent:Edit");
|
||||
|
||||
const char* right = "Sh+Del:Del";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
return;
|
||||
@@ -663,9 +679,15 @@ private:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe:Page");
|
||||
|
||||
const char* right = "Tap:Edit";
|
||||
#else
|
||||
display.print("Q:Bck Ent:Edit");
|
||||
|
||||
const char* right = "Sh+Del:Del";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
@@ -766,11 +788,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);
|
||||
@@ -817,9 +853,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);
|
||||
}
|
||||
@@ -852,9 +892,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);
|
||||
}
|
||||
@@ -1124,6 +1168,8 @@ 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;
|
||||
@@ -1145,7 +1191,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) ----
|
||||
|
||||
@@ -598,41 +598,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;
|
||||
}
|
||||
|
||||
@@ -1001,7 +1037,11 @@ private:
|
||||
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);
|
||||
@@ -1120,7 +1160,11 @@ private:
|
||||
bool selected, const char* label, bool warn) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else if (warn) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
@@ -1169,9 +1213,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 {
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
#include <MeshCore.h>
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Inline edit hint shown next to values being adjusted
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#define EDIT_ADJ_HINT "<Swipe>"
|
||||
#else
|
||||
#define EDIT_ADJ_HINT "<W/S>"
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "ModemManager.h"
|
||||
#endif
|
||||
@@ -57,6 +64,10 @@ enum SettingsRowType : uint8_t {
|
||||
ROW_TX_POWER, // TX power (1-20 dBm)
|
||||
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
|
||||
ROW_MSG_NOTIFY, // Keyboard flash on new msg toggle
|
||||
ROW_DARK_MODE, // Dark mode toggle (inverted display)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
ROW_PORTRAIT_MODE, // Portrait orientation toggle
|
||||
#endif
|
||||
ROW_PATH_HASH_SIZE, // Path hash size (1, 2, or 3 bytes per hop)
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
ROW_WIFI_SETUP, // WiFi SSID/password configuration
|
||||
@@ -166,6 +177,9 @@ private:
|
||||
char _wifiPassBuf[64];
|
||||
int _wifiPassLen;
|
||||
unsigned long _wifiFormLastChar; // For brief password reveal
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
bool _wifiNeedsVKB; // T5S3: signal UITask to open VKB for password
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -236,6 +250,10 @@ private:
|
||||
addRow(ROW_UTC_OFFSET);
|
||||
addRow(ROW_MSG_NOTIFY);
|
||||
addRow(ROW_PATH_HASH_SIZE);
|
||||
addRow(ROW_DARK_MODE);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
addRow(ROW_PORTRAIT_MODE);
|
||||
#endif
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
addRow(ROW_WIFI_SETUP);
|
||||
addRow(ROW_WIFI_TOGGLE);
|
||||
@@ -440,6 +458,9 @@ public:
|
||||
_wifiPassLen = 0;
|
||||
memset(_wifiPassBuf, 0, sizeof(_wifiPassBuf));
|
||||
_wifiFormLastChar = 0;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
_wifiNeedsVKB = false;
|
||||
#endif
|
||||
#endif
|
||||
rebuildRows();
|
||||
}
|
||||
@@ -512,6 +533,60 @@ public:
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3 VKB integration — UITask polls this to open the virtual keyboard
|
||||
// when settings enters WiFi password phase (no physical keyboard available).
|
||||
bool needsWifiVKB() const { return _wifiNeedsVKB; }
|
||||
void clearWifiNeedsVKB() { _wifiNeedsVKB = false; }
|
||||
|
||||
// Called by UITask::onVKBSubmit with the password text from VKB.
|
||||
// Fills the password buffer and triggers the WiFi connect sequence.
|
||||
void submitWifiPassword(const char* pass) {
|
||||
_wifiNeedsVKB = false;
|
||||
int len = strlen(pass);
|
||||
if (len > 63) len = 63;
|
||||
memcpy(_wifiPassBuf, pass, len);
|
||||
_wifiPassBuf[len] = '\0';
|
||||
_wifiPassLen = len;
|
||||
|
||||
// Trigger the same connect sequence as pressing Enter in password phase
|
||||
_wifiPhase = WIFI_PHASE_CONNECTING;
|
||||
|
||||
// Save credentials to SD (so web reader can reuse them)
|
||||
if (SD.exists("/web") || SD.mkdir("/web")) {
|
||||
File f = SD.open("/web/wifi.cfg", FILE_WRITE);
|
||||
if (f) {
|
||||
f.println(_wifiSSIDs[_wifiSSIDSelected]);
|
||||
f.println(_wifiPassBuf);
|
||||
f.close();
|
||||
}
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
}
|
||||
|
||||
WiFi.disconnect(false);
|
||||
WiFi.begin(_wifiSSIDs[_wifiSSIDSelected].c_str(), _wifiPassBuf);
|
||||
|
||||
// Brief blocking wait — fine for e-ink
|
||||
unsigned long timeout = millis() + 8000;
|
||||
while (WiFi.status() != WL_CONNECTED && millis() < timeout) {
|
||||
delay(100);
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("Settings VKB: WiFi connected to %s, IP: %s\n",
|
||||
_wifiSSIDs[_wifiSSIDSelected].c_str(),
|
||||
WiFi.localIP().toString().c_str());
|
||||
_editMode = EDIT_NONE;
|
||||
_wifiPhase = WIFI_PHASE_IDLE;
|
||||
if (_onboarding) _onboarding = false;
|
||||
} else {
|
||||
Serial.println("Settings VKB: WiFi connection failed");
|
||||
_wifiPhase = WIFI_PHASE_SELECT; // Back to SSID list to retry
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -586,7 +661,13 @@ public:
|
||||
// Selection highlight
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// FreeSans12pt: baseline at (y+5)*scale_y, ascent ~17px above.
|
||||
// Highlight needs to start above the baseline to cover ascenders.
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -634,7 +715,7 @@ public:
|
||||
|
||||
case ROW_BW:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "BW: %.1f <W/S>", _editFloat);
|
||||
snprintf(tmp, sizeof(tmp), "BW: %.1f " EDIT_ADJ_HINT, _editFloat);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "BW: %.1f kHz", _prefs->bw);
|
||||
}
|
||||
@@ -643,7 +724,7 @@ public:
|
||||
|
||||
case ROW_SF:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "SF: %d <W/S>", _editInt);
|
||||
snprintf(tmp, sizeof(tmp), "SF: %d " EDIT_ADJ_HINT, _editInt);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "SF: %d", _prefs->sf);
|
||||
}
|
||||
@@ -652,7 +733,7 @@ public:
|
||||
|
||||
case ROW_CR:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "CR: %d <W/S>", _editInt);
|
||||
snprintf(tmp, sizeof(tmp), "CR: %d " EDIT_ADJ_HINT, _editInt);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "CR: %d", _prefs->cr);
|
||||
}
|
||||
@@ -661,7 +742,7 @@ public:
|
||||
|
||||
case ROW_TX_POWER:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "TX: %d dBm <W/S>", _editInt);
|
||||
snprintf(tmp, sizeof(tmp), "TX: %d dBm " EDIT_ADJ_HINT, _editInt);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "TX: %d dBm", _prefs->tx_power_dbm);
|
||||
}
|
||||
@@ -670,7 +751,7 @@ public:
|
||||
|
||||
case ROW_UTC_OFFSET:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "UTC: %+d <W/S>", _editInt);
|
||||
snprintf(tmp, sizeof(tmp), "UTC: %+d " EDIT_ADJ_HINT, _editInt);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "UTC Offset: %+d", _prefs->utc_offset_hours);
|
||||
}
|
||||
@@ -685,13 +766,27 @@ public:
|
||||
|
||||
case ROW_PATH_HASH_SIZE:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "Path Hash Size: %d-byte <W/S>", _editInt);
|
||||
snprintf(tmp, sizeof(tmp), "Path Hash Size: %d-byte " EDIT_ADJ_HINT, _editInt);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Path Hash Size: %d-byte", _prefs->path_hash_mode + 1);
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_DARK_MODE:
|
||||
snprintf(tmp, sizeof(tmp), "Dark Mode: %s",
|
||||
_prefs->dark_mode ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
case ROW_PORTRAIT_MODE:
|
||||
snprintf(tmp, sizeof(tmp), "Portrait Mode: %s",
|
||||
_prefs->portrait_mode ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
case ROW_WIFI_SETUP:
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
@@ -942,7 +1037,11 @@ public:
|
||||
bool sel = (wi == _wifiSSIDSelected);
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(bx + 2, wy, bw - 4, 8);
|
||||
#else
|
||||
display.fillRect(bx + 2, wy + 5, bw - 4, 8);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -992,6 +1091,47 @@ public:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
if (_editMode == EDIT_NONE) {
|
||||
display.print("Swipe:Scroll");
|
||||
const char* r = "Tap:Toggle Hold:Edit";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
} else if (_editMode == EDIT_NUMBER) {
|
||||
display.print("Swipe:Adjust");
|
||||
const char* r = "Tap:OK Boot:Cancel";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
} else if (_editMode == EDIT_PICKER) {
|
||||
display.print("Swipe:Choose");
|
||||
const char* r = "Tap:OK Boot:Cancel";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
} else if (_editMode == EDIT_CONFIRM) {
|
||||
display.print("Boot:Cancel");
|
||||
const char* r = "Tap:Confirm";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
} else if (_editMode == EDIT_WIFI) {
|
||||
if (_wifiPhase == WIFI_PHASE_SELECT) {
|
||||
display.print("Swipe:Pick");
|
||||
const char* r = "Tap:Select Boot:Back";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
} else {
|
||||
display.print("Please wait...");
|
||||
}
|
||||
#endif
|
||||
} else if (_editMode == EDIT_TEXT) {
|
||||
display.print("Hold:Type");
|
||||
const char* r = "Tap:OK Boot:Cancel";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
} else {
|
||||
display.print("Editing...");
|
||||
}
|
||||
#else
|
||||
if (_editMode == EDIT_TEXT) {
|
||||
display.print("Type, Enter:Ok Q:Cancel");
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
@@ -1020,6 +1160,7 @@ public:
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
}
|
||||
#endif
|
||||
|
||||
return _editMode != EDIT_NONE ? 700 : 1000;
|
||||
}
|
||||
@@ -1081,6 +1222,9 @@ public:
|
||||
_wifiPassLen = 0;
|
||||
memset(_wifiPassBuf, 0, sizeof(_wifiPassBuf));
|
||||
_wifiFormLastChar = 0;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
_wifiNeedsVKB = true; // Signal UITask to open virtual keyboard
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'Q') {
|
||||
@@ -1439,6 +1583,20 @@ public:
|
||||
case ROW_PATH_HASH_SIZE:
|
||||
startEditInt(_prefs->path_hash_mode + 1); // display as 1-3
|
||||
break;
|
||||
case ROW_DARK_MODE:
|
||||
_prefs->dark_mode = _prefs->dark_mode ? 0 : 1;
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Dark mode = %s\n",
|
||||
_prefs->dark_mode ? "ON" : "OFF");
|
||||
break;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
case ROW_PORTRAIT_MODE:
|
||||
_prefs->portrait_mode = _prefs->portrait_mode ? 0 : 1;
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Portrait mode = %s\n",
|
||||
_prefs->portrait_mode ? "ON" : "OFF");
|
||||
break;
|
||||
#endif
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
case ROW_WIFI_SETUP: {
|
||||
// Launch WiFi scan → select → password → connect flow
|
||||
|
||||
@@ -15,7 +15,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 9 // v9: indexer buffer matches page buffer (fixes chunk boundary gaps)
|
||||
#define PREINDEX_PAGES 100
|
||||
#define READER_MAX_FILES 50
|
||||
#define READER_BUF_SIZE 4096
|
||||
@@ -97,6 +97,149 @@ 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;
|
||||
|
||||
int displayW = display->width() - 3; // 3-unit right margin (rounding safety for proportional fonts)
|
||||
char measBuf[300]; // temp buffer for pixel measurement
|
||||
int measLen = 0;
|
||||
int lastBreakPoint = -1;
|
||||
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.
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) {
|
||||
// Orphan continuation byte — treat as CP437 pass-through (same as renderer)
|
||||
if (measLen < 298) measBuf[measLen++] = c;
|
||||
charCount++;
|
||||
inWord = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain ASCII
|
||||
charCount++;
|
||||
if (measLen < 298) measBuf[measLen++] = c;
|
||||
|
||||
if (c == ' ' || c == '\t') {
|
||||
if (inWord) {
|
||||
// Measure pixel width at this word boundary
|
||||
measBuf[measLen] = '\0';
|
||||
int pw = display->getTextWidth(measBuf);
|
||||
if (pw >= displayW) {
|
||||
// Current word pushes past edge — break at previous word boundary
|
||||
if (lastBreakPoint > lineStart) {
|
||||
result.lineEnd = lastBreakPoint;
|
||||
result.nextStart = lastBreakPoint;
|
||||
while (result.nextStart < bufLen &&
|
||||
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
|
||||
result.nextStart++;
|
||||
} else {
|
||||
result.lineEnd = i;
|
||||
result.nextStart = i;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
lastBreakPoint = i;
|
||||
inWord = false;
|
||||
}
|
||||
} else if (c == '-') {
|
||||
if (inWord) {
|
||||
// Measure at hyphen break point
|
||||
measBuf[measLen] = '\0';
|
||||
int pw = display->getTextWidth(measBuf);
|
||||
if (pw >= displayW) {
|
||||
if (lastBreakPoint > lineStart) {
|
||||
result.lineEnd = lastBreakPoint;
|
||||
result.nextStart = lastBreakPoint;
|
||||
while (result.nextStart < bufLen &&
|
||||
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
|
||||
result.nextStart++;
|
||||
} else {
|
||||
result.lineEnd = i;
|
||||
result.nextStart = i;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
lastBreakPoint = i + 1;
|
||||
}
|
||||
inWord = true;
|
||||
} else {
|
||||
inWord = true;
|
||||
}
|
||||
|
||||
// Safety: hard char limit (handles spaceless lines, URLs, etc.)
|
||||
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)
|
||||
// ============================================================================
|
||||
@@ -104,7 +247,7 @@ inline int indexPagesWordWrap(File& file, long startPos,
|
||||
std::vector<long>& pagePositions,
|
||||
int linesPerPage, int charsPerLine,
|
||||
int maxPages) {
|
||||
const int BUF_SIZE = 2048;
|
||||
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
|
||||
char buffer[BUF_SIZE];
|
||||
|
||||
file.seek(startPos);
|
||||
@@ -148,6 +291,63 @@ inline int indexPagesWordWrap(File& file, long startPos,
|
||||
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) {
|
||||
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
|
||||
char buffer[BUF_SIZE];
|
||||
|
||||
// Ensure body font is active for pixel measurement
|
||||
display->setTextSize(0);
|
||||
|
||||
file.seek(startPos);
|
||||
int pagesAdded = 0;
|
||||
int lineCount = 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) {
|
||||
WrapResult wrap = findLineBreakPixel(buffer, bufLen, pos, display, maxChars);
|
||||
if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;
|
||||
|
||||
lineCount++;
|
||||
pos = wrap.nextStart;
|
||||
|
||||
if (lineCount >= linesPerPage) {
|
||||
long pageFilePos = chunkFileStart + pos;
|
||||
pagePositions.push_back(pageFilePos);
|
||||
pagesAdded++;
|
||||
lineCount = 0;
|
||||
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;
|
||||
}
|
||||
|
||||
display->setTextSize(1); // Restore
|
||||
return pagesAdded;
|
||||
}
|
||||
#endif // LilyGo_T5S3_EPaper_Pro
|
||||
|
||||
// ============================================================================
|
||||
// TextReaderScreen
|
||||
// ============================================================================
|
||||
@@ -699,12 +899,22 @@ private:
|
||||
if (_pagePositions.empty()) {
|
||||
// Cache had no pages (e.g. dummy entry) — full index from scratch
|
||||
_pagePositions.push_back(0);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
indexPagesWordWrapPixel(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, _display, 0);
|
||||
#else
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
#endif
|
||||
} else {
|
||||
long lastPos = cache->pagePositions.back();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
indexPagesWordWrapPixel(_file, lastPos, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, _display, 0);
|
||||
#else
|
||||
indexPagesWordWrap(_file, lastPos, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
// No cache — full index from scratch
|
||||
@@ -722,8 +932,13 @@ private:
|
||||
drawSplash("Indexing...", "Please wait", shortName);
|
||||
|
||||
_pagePositions.push_back(0);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
indexPagesWordWrapPixel(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, _display, 0);
|
||||
#else
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Save complete index
|
||||
@@ -863,9 +1078,13 @@ 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);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -918,13 +1137,19 @@ 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);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(0);
|
||||
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home");
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Back W/S:Nav");
|
||||
|
||||
const char* right = "Ent:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
}
|
||||
|
||||
void renderPage(DisplayDriver& display) {
|
||||
@@ -942,7 +1167,11 @@ private:
|
||||
// so we render everything in it.
|
||||
while (pos < _pageBufLen && lineCount < _linesPerPage && y <= maxY) {
|
||||
int oldPos = pos;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
WrapResult wrap = findLineBreakPixel(_pageBuf, _pageBufLen, pos, &display, _charsPerLine);
|
||||
#else
|
||||
WrapResult wrap = findLineBreak(_pageBuf, _pageBufLen, pos, _charsPerLine);
|
||||
#endif
|
||||
|
||||
// Safety: stop if findLineBreak made no progress (stuck at end of buffer)
|
||||
if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break;
|
||||
@@ -950,6 +1179,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 +1195,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 +1205,7 @@ private:
|
||||
if (glyph) {
|
||||
charStr[0] = (char)glyph;
|
||||
display.print(charStr);
|
||||
lineHasContent = true;
|
||||
}
|
||||
// If unmappable (glyph==0), just skip the character
|
||||
} else {
|
||||
@@ -981,11 +1213,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;
|
||||
@@ -1002,12 +1243,22 @@ private:
|
||||
char status[30];
|
||||
int pct = _totalPages > 1 ? (_currentPage * 100) / (_totalPages - 1) : 100;
|
||||
sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(0);
|
||||
display.setCursor(0, footerY);
|
||||
display.print(status);
|
||||
const char* right = "Swipe: Page Tap: Next Hold: Close";
|
||||
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";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
}
|
||||
|
||||
public:
|
||||
@@ -1021,6 +1272,20 @@ public:
|
||||
_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) {
|
||||
if (_initialized) return;
|
||||
@@ -1036,8 +1301,15 @@ public:
|
||||
if (tenCharsW > 0) {
|
||||
_charsPerLine = (display.width() * 10) / tenCharsW;
|
||||
}
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3 uses pixel-based line breaking (findLineBreakPixel) which measures
|
||||
// actual text width via getTextWidth(). _charsPerLine serves only as a
|
||||
// safety upper bound for lines without word breaks (URLs, etc.).
|
||||
_charsPerLine = 120;
|
||||
#else
|
||||
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,6 +1324,17 @@ public:
|
||||
_lineHeight = 5; // Safe fallback
|
||||
}
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3 uses FreeSans12pt/FreeSerif12pt for size 0 (yAdvance=29px).
|
||||
// Line height in virtual coords depends on orientation:
|
||||
// Landscape: 29px / scale_y(4.22) ≈ 7 + 1 spacing = 8
|
||||
// Portrait: 29px / scale_y(7.50) ≈ 4 + 1 spacing = 5
|
||||
{
|
||||
extern DISPLAY_CLASS display;
|
||||
_lineHeight = display.isPortraitMode() ? 5 : 8;
|
||||
}
|
||||
#endif
|
||||
|
||||
_headerHeight = 0; // No header in reading mode (maximize text area)
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _headerHeight - _footerHeight;
|
||||
@@ -1174,9 +1457,15 @@ public:
|
||||
cache.pagePositions.clear();
|
||||
cache.pagePositions.push_back(0);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
int added = indexPagesWordWrapPixel(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
_display, PREINDEX_PAGES - 1);
|
||||
#else
|
||||
int added = indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1);
|
||||
#endif
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
|
||||
@@ -1215,6 +1504,21 @@ 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);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
indexPagesWordWrapPixel(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, _display, 0);
|
||||
#else
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
#endif
|
||||
_totalPages = _pagePositions.size();
|
||||
if (_currentPage >= _totalPages) _currentPage = 0;
|
||||
_mode = READING;
|
||||
loadPageContent();
|
||||
} else {
|
||||
_mode = READING;
|
||||
loadPageContent();
|
||||
@@ -1335,9 +1639,15 @@ public:
|
||||
cache.lastReadPage = 0;
|
||||
cache.pagePositions.clear();
|
||||
cache.pagePositions.push_back(0);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
indexPagesWordWrapPixel(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
_display, PREINDEX_PAGES - 1);
|
||||
#else
|
||||
indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1);
|
||||
#endif
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
|
||||
|
||||
@@ -4,8 +4,16 @@
|
||||
#include "NotesScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "DiscoveryScreen.h"
|
||||
#include "MapScreen.h"
|
||||
#ifdef MECK_WEB_READER
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
#if HAS_GPS
|
||||
#include "MapScreen.h"
|
||||
#endif
|
||||
#include "target.h"
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include "HomeIcons.h"
|
||||
#endif
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
@@ -24,11 +32,17 @@
|
||||
#define LONG_PRESS_MILLIS 1200
|
||||
|
||||
#ifndef UI_RECENT_LIST_SIZE
|
||||
#define UI_RECENT_LIST_SIZE 4
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#define UI_RECENT_LIST_SIZE 8
|
||||
#else
|
||||
#define UI_RECENT_LIST_SIZE 4
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if UI_HAS_JOYSTICK
|
||||
#define PRESS_LABEL "press Enter"
|
||||
#elif defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#define PRESS_LABEL "long press"
|
||||
#else
|
||||
#define PRESS_LABEL "long press"
|
||||
#endif
|
||||
@@ -140,21 +154,34 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0);
|
||||
|
||||
// battery icon dimensions (smaller to match tiny percentage text)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3: text-only battery indicator — "Batt 99% 4.1v"
|
||||
char battStr[20];
|
||||
float volts = batteryMilliVolts / 1000.0f;
|
||||
snprintf(battStr, sizeof(battStr), "Batt %d%% %.1fv", batteryPercentage, volts);
|
||||
uint16_t textWidth = display.getTextWidth(battStr);
|
||||
int textX = display.width() - textWidth - 2;
|
||||
if (outIconX) *outIconX = textX;
|
||||
display.setCursor(textX, 0);
|
||||
display.print(battStr);
|
||||
display.setTextSize(1); // restore default text size
|
||||
#else
|
||||
// T-Deck Pro: icon + percentage text
|
||||
int iconWidth = 16;
|
||||
int iconHeight = 6;
|
||||
int iconY = 0;
|
||||
int textY = iconY - 3;
|
||||
|
||||
// measure percentage text width to position icon + text together at right edge
|
||||
display.setTextSize(0);
|
||||
char pctStr[5];
|
||||
sprintf(pctStr, "%d%%", batteryPercentage);
|
||||
uint16_t textWidth = display.getTextWidth(pctStr);
|
||||
|
||||
// layout: [icon 16px][cap 2px][gap 2px][text][margin 2px]
|
||||
// layout: [icon][cap 2px][gap 2px][text][margin 2px]
|
||||
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
|
||||
int iconX = display.width() - totalWidth;
|
||||
int iconY = 0; // vertically align with node name text
|
||||
|
||||
if (outIconX) *outIconX = iconX;
|
||||
|
||||
@@ -168,13 +195,12 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
||||
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
||||
|
||||
// draw percentage text after the battery cap, offset upward to center with icon
|
||||
// (setCursor adds +5 internally for baseline, so compensate for the tiny font)
|
||||
// draw percentage text after the battery cap
|
||||
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
|
||||
int textY = iconY - 3; // offset up to vertically center with icon
|
||||
display.setCursor(textX, textY);
|
||||
display.print(pctStr);
|
||||
display.setTextSize(1); // restore default text size
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
@@ -243,12 +269,22 @@ public:
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[80];
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
_task->setHomeShowingTiles(false); // Reset — only set true on FIRST page
|
||||
#endif
|
||||
// node name (tinyfont to avoid overlapping clock)
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
char filtered_name[sizeof(_node_prefs->node_name)];
|
||||
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
||||
display.setCursor(0, -3);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3: FreeSans12pt ascenders need more room than built-in font.
|
||||
// Shift header elements down by 4 virtual units (~17px physical).
|
||||
#define HOME_HDR_Y 1
|
||||
#else
|
||||
#define HOME_HDR_Y -3
|
||||
#endif
|
||||
display.setCursor(0, HOME_HDR_Y);
|
||||
display.print(filtered_name);
|
||||
|
||||
// battery voltage
|
||||
@@ -280,13 +316,17 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
uint16_t tw = display.getTextWidth(timeBuf);
|
||||
int clockX = (display.width() - tw) / 2;
|
||||
display.setCursor(clockX, -3); // align with battery text Y
|
||||
display.setCursor(clockX, HOME_HDR_Y); // align with node name Y
|
||||
display.print(timeBuf);
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
}
|
||||
// curr page indicator
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
int y = 14; // Closer to header
|
||||
#else
|
||||
int y = 14;
|
||||
#endif
|
||||
int x = display.width() / 2 - 5 * (HomePage::Count-1);
|
||||
for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) {
|
||||
if (i == _page) {
|
||||
@@ -297,28 +337,39 @@ public:
|
||||
}
|
||||
|
||||
if (_page == HomePage::FIRST) {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
_task->setHomeShowingTiles(true);
|
||||
#endif
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
int y = 18; // Tighter spacing — connectivity info fills gap below dots
|
||||
#else
|
||||
int y = 26; // Standalone: extra line below dots (no IP/Connected row)
|
||||
#endif
|
||||
#else
|
||||
int y = 20;
|
||||
#endif
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount());
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 18;
|
||||
y += 14; // Reduced from 18
|
||||
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
IPAddress ip = WiFi.localIP();
|
||||
if (ip != IPAddress(0,0,0,0)) {
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
|
||||
display.setTextSize(1);
|
||||
display.setTextSize(0); // Tiny font for IP
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 12;
|
||||
y += 8;
|
||||
}
|
||||
#endif
|
||||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.setTextSize(0); // Tiny font for Connected
|
||||
display.drawTextCentered(display.width() / 2, y, "< Connected >");
|
||||
y += 12;
|
||||
y += 8; // Reduced from 12
|
||||
#ifdef BLE_PIN_CODE
|
||||
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
@@ -330,6 +381,60 @@ public:
|
||||
}
|
||||
#endif
|
||||
|
||||
// ----- T5S3: Tappable tile grid (touch-friendly home screen) -----
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// 3×2 grid of tiles below MSG count
|
||||
// Virtual coords (128×128), scaled by DisplayDriver
|
||||
{
|
||||
struct Tile { const uint8_t* icon; const char* label; };
|
||||
const Tile tiles[2][3] = {
|
||||
{ {icon_envelope, "Messages"}, {icon_people, "Contacts"}, {icon_gear, "Settings"} },
|
||||
#ifdef MECK_WEB_READER
|
||||
{ {icon_book, "Reader"}, {icon_notepad, "Notes"}, {icon_search, "Browser"} }
|
||||
#else
|
||||
{ {icon_book, "Reader"}, {icon_notepad, "Notes"}, {icon_search, "Discover"} }
|
||||
#endif
|
||||
};
|
||||
|
||||
const int tileW = 40;
|
||||
const int tileH = 28;
|
||||
const int gapX = 1;
|
||||
const int gapY = 1;
|
||||
const int gridW = tileW * 3 + gapX * 2;
|
||||
const int gridX = (display.width() - gridW) / 2;
|
||||
const int gridY = y + 2;
|
||||
_task->setTileGridVY(gridY); // Store for touch hit testing
|
||||
|
||||
for (int row = 0; row < 2; row++) {
|
||||
for (int col = 0; col < 3; col++) {
|
||||
int tx = gridX + col * (tileW + gapX);
|
||||
int ty = gridY + row * (tileH + gapY);
|
||||
|
||||
// Tile border
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(tx, ty, tileW, tileH);
|
||||
|
||||
// Icon centered in tile
|
||||
int iconX = tx + (tileW - HOME_ICON_W) / 2;
|
||||
int iconY = ty + 4;
|
||||
display.drawXbm(iconX, iconY, tiles[row][col].icon, HOME_ICON_W, HOME_ICON_H);
|
||||
|
||||
// Label centered below icon
|
||||
display.setTextSize(0);
|
||||
display.drawTextCentered(tx + tileW / 2, ty + 18, tiles[row][col].label);
|
||||
}
|
||||
}
|
||||
|
||||
// Nav hint below grid
|
||||
y = gridY + 2 * tileH + gapY + 2;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0);
|
||||
display.drawTextCentered(display.width() / 2, y, "Tap tile to open");
|
||||
}
|
||||
display.setTextSize(1);
|
||||
|
||||
#else
|
||||
// ----- T-Deck Pro: Keyboard shortcut text menu -----
|
||||
// Menu shortcuts - tinyfont monospaced grid
|
||||
y += 6;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -340,7 +445,11 @@ public:
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
y += 10;
|
||||
#if HAS_GPS
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
#endif
|
||||
y += 10;
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
|
||||
@@ -361,6 +470,7 @@ public:
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
|
||||
display.setTextSize(1); // restore
|
||||
#endif
|
||||
} else if (_page == HomePage::RECENT) {
|
||||
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -408,23 +518,39 @@ public:
|
||||
#ifdef BLE_PIN_CODE
|
||||
} else if (_page == HomePage::BLUETOOTH) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawXbm((display.width() - 32) / 2, 28,
|
||||
#else
|
||||
display.drawXbm((display.width() - 32) / 2, 18,
|
||||
#endif
|
||||
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
|
||||
32, 32);
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(display.width() / 2, 64, "< Connected >");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, 53, "< Connected >");
|
||||
#endif
|
||||
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(display.width() / 2, 64, tmp);
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, 53, tmp);
|
||||
#endif
|
||||
}
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(display.width() / 2, 80, "toggle: " PRESS_LABEL);
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, 72, "toggle: " PRESS_LABEL);
|
||||
#endif
|
||||
#endif
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
} else if (_page == HomePage::WIFI_STATUS) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -465,8 +591,16 @@ public:
|
||||
#endif
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawXbm((display.width() - 32) / 2, 28, advert_icon, 32, 32);
|
||||
#else
|
||||
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
|
||||
#endif
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(display.width() / 2, 64, "advert: " PRESS_LABEL);
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
|
||||
#endif
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
} else if (_page == HomePage::GPS) {
|
||||
extern GPSStreamCounter gpsStream;
|
||||
@@ -691,10 +825,21 @@ public:
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
if (_shutdown_init) {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
board.setBacklight(false); // Turn off backlight on hibernate
|
||||
#endif
|
||||
display.drawTextCentered(display.width() / 2, 34, "hibernating...");
|
||||
} else {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawXbm((display.width() - 32) / 2, 28, power_icon, 32, 32);
|
||||
#else
|
||||
display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32);
|
||||
#endif
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(display.width() / 2, 64, "hibernate:" PRESS_LABEL);
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
return _editing_utc ? 700 : 5000; // match e-ink refresh cycle while editing UTC
|
||||
@@ -885,6 +1030,75 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================================================
|
||||
// Lock Screen — T5S3 only
|
||||
// Big clock, battery %, unread message count. Touch disabled while shown.
|
||||
// Long press boot button to lock/unlock. Touch disabled while locked.
|
||||
// ==========================================================================
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
class LockScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
NodePrefs* _node_prefs;
|
||||
|
||||
public:
|
||||
LockScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _node_prefs(node_prefs) {}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
|
||||
char timeBuf[6] = "--:--";
|
||||
if (now > 1700000000) {
|
||||
int32_t local = (int32_t)now + ((int32_t)_node_prefs->utc_offset_hours * 3600);
|
||||
int hrs = (local / 3600) % 24;
|
||||
if (hrs < 0) hrs += 24;
|
||||
int mins = (local / 60) % 60;
|
||||
if (mins < 0) mins += 60;
|
||||
sprintf(timeBuf, "%02d:%02d", hrs, mins);
|
||||
}
|
||||
|
||||
// ---- Huge clock: HH:MM on one line ----
|
||||
display.setTextSize(5); // Clock face size (FreeSansBold24pt × 5)
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, 55, timeBuf);
|
||||
|
||||
// ---- Battery + unread on one line ----
|
||||
display.setTextSize(1);
|
||||
{
|
||||
uint16_t mv = _task->getBattMilliVolts();
|
||||
int pct = 0;
|
||||
if (mv > 0) {
|
||||
pct = ((mv - 3000) * 100) / (4200 - 3000);
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
}
|
||||
|
||||
int unread = _task->getUnreadMsgCount();
|
||||
char infoBuf[32];
|
||||
if (unread > 0) {
|
||||
sprintf(infoBuf, "%d%% | %d unread", pct, unread);
|
||||
} else {
|
||||
sprintf(infoBuf, "%d%%", pct);
|
||||
}
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, 108, infoBuf);
|
||||
}
|
||||
|
||||
// ---- Unlock hint ----
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, 120, "Hold button to unlock");
|
||||
|
||||
return 30000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
#endif // LilyGo_T5S3_EPaper_Pro
|
||||
|
||||
void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
|
||||
_display = display;
|
||||
_sensors = sensors;
|
||||
@@ -948,11 +1162,29 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||||
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
lock_screen = new LockScreen(this, &rtc_clock, node_prefs);
|
||||
#endif
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
#ifdef HAS_4G_MODEM
|
||||
sms_screen = new SMSScreen(this);
|
||||
#endif
|
||||
#if HAS_GPS
|
||||
map_screen = new MapScreen(this);
|
||||
#else
|
||||
map_screen = nullptr;
|
||||
#endif
|
||||
|
||||
// Apply saved dark mode preference before first render
|
||||
if (_node_prefs->dark_mode) {
|
||||
::display.setDarkMode(true);
|
||||
}
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
if (_node_prefs->portrait_mode) {
|
||||
::display.setPortraitMode(true);
|
||||
}
|
||||
#endif
|
||||
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -1028,10 +1260,9 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
|
||||
}
|
||||
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
|
||||
// Messages are stored in channel history, accessible via 'M' key
|
||||
// Suppress alert entirely on admin screen - it needs focused interaction
|
||||
#if defined(LilyGo_TDeck_Pro) || defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// Don't interrupt user with popup - just show brief notification
|
||||
// Messages are stored in channel history, accessible via tile/key
|
||||
if (!isOnRepeaterAdmin()) {
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
@@ -1174,7 +1405,34 @@ void UITask::loop() {
|
||||
#elif defined(PIN_USER_BTN)
|
||||
int ev = user_btn.check();
|
||||
if (ev == BUTTON_EVENT_CLICK) {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3: single click = cycle pages on home, go back to home from elsewhere
|
||||
// Ignored while locked — long press required to unlock
|
||||
if (_locked) {
|
||||
c = 0;
|
||||
} else if (_vkbActive) {
|
||||
onVKBCancel();
|
||||
c = 0;
|
||||
} else if (curr == home) {
|
||||
c = checkDisplayOn(KEY_NEXT);
|
||||
} else {
|
||||
// Navigate back: reader reading→file list, file list→home, others→home
|
||||
if (isOnTextReader()) {
|
||||
TextReaderScreen* reader = (TextReaderScreen*)text_reader;
|
||||
if (reader && reader->isReading()) {
|
||||
c = checkDisplayOn('q'); // reading mode: close book → file list
|
||||
} else {
|
||||
gotoHomeScreen(); // file list: go home
|
||||
c = 0;
|
||||
}
|
||||
} else {
|
||||
gotoHomeScreen();
|
||||
c = 0; // consumed
|
||||
}
|
||||
}
|
||||
#else
|
||||
c = checkDisplayOn(KEY_NEXT);
|
||||
#endif
|
||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||
c = handleLongPress(KEY_ENTER);
|
||||
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
||||
@@ -1283,7 +1541,60 @@ if (curr) curr->poll();
|
||||
|
||||
if (_display != NULL && _display->isOn()) {
|
||||
if (millis() >= _next_refresh && curr) {
|
||||
// Sync dark mode with prefs (settings toggle takes effect here)
|
||||
if (_node_prefs && display.isDarkMode() != (_node_prefs->dark_mode != 0)) {
|
||||
display.setDarkMode(_node_prefs->dark_mode != 0);
|
||||
}
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
if (_node_prefs && display.isPortraitMode() != (_node_prefs->portrait_mode != 0)) {
|
||||
display.setPortraitMode(_node_prefs->portrait_mode != 0);
|
||||
// Text reader layout depends on orientation — force recalculation
|
||||
if (text_reader) {
|
||||
((TextReaderScreen*)text_reader)->invalidateLayout();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
_display->startFrame();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
if (_vkbActive) {
|
||||
display.setForcePartial(true); // No flash while typing
|
||||
_vkb.render(*_display);
|
||||
_next_refresh = millis() + 500; // Moderate refresh for cursor blink
|
||||
// Check if keyboard was submitted or cancelled during render cycle
|
||||
if (_vkb.status() == VKB_SUBMITTED) {
|
||||
onVKBSubmit();
|
||||
} else if (_vkb.status() == VKB_CANCELLED) {
|
||||
onVKBCancel();
|
||||
}
|
||||
} else {
|
||||
int delay_millis = curr->render(*_display);
|
||||
|
||||
// Check if settings screen needs VKB for WiFi password entry
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
if (isOnSettingsScreen() && !_vkbActive) {
|
||||
SettingsScreen* ss = (SettingsScreen*)settings_screen;
|
||||
if (ss->needsWifiVKB()) {
|
||||
ss->clearWifiNeedsVKB();
|
||||
showVirtualKeyboard(VKB_WIFI_PASSWORD, "WiFi Password", "", 63);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (millis() < _alert_expiry) {
|
||||
_display->setTextSize(1);
|
||||
int y = _display->height() / 3;
|
||||
int p = _display->height() / 32;
|
||||
_display->setColor(DisplayDriver::DARK);
|
||||
_display->fillRect(p, y, _display->width() - p*2, y);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->drawRect(p, y, _display->width() - p*2, y);
|
||||
_display->drawTextCentered(_display->width() / 2, y + p*3, _alert);
|
||||
_next_refresh = _alert_expiry;
|
||||
} else {
|
||||
_next_refresh = millis() + delay_millis;
|
||||
}
|
||||
}
|
||||
#else
|
||||
int delay_millis = curr->render(*_display);
|
||||
if (millis() < _alert_expiry) { // render alert popup
|
||||
_display->setTextSize(1);
|
||||
@@ -1298,6 +1609,7 @@ if (curr) curr->poll();
|
||||
} else {
|
||||
_next_refresh = millis() + delay_millis;
|
||||
}
|
||||
#endif
|
||||
_display->endFrame();
|
||||
}
|
||||
#if AUTO_OFF_MILLIS > 0
|
||||
@@ -1357,11 +1669,33 @@ char UITask::handleLongPress(char c) {
|
||||
the_mesh.enterCLIRescue();
|
||||
c = 0; // consume event
|
||||
}
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
else if (_vkbActive) {
|
||||
onVKBCancel(); // Long press while VKB → cancel
|
||||
c = 0;
|
||||
} else if (_locked) {
|
||||
unlockScreen();
|
||||
c = 0;
|
||||
} else {
|
||||
lockScreen();
|
||||
c = 0;
|
||||
}
|
||||
#endif
|
||||
return c;
|
||||
}
|
||||
|
||||
char UITask::handleDoubleClick(char c) {
|
||||
MESH_DEBUG_PRINTLN("UITask: double click triggered");
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// Double-click boot button → full brightness backlight toggle
|
||||
if (board.isBacklightOn()) {
|
||||
board.setBacklight(false);
|
||||
} else {
|
||||
board.setBacklightBrightness(153);
|
||||
board.setBacklight(true);
|
||||
}
|
||||
c = 0; // consume event — don't pass through as navigation
|
||||
#endif
|
||||
checkDisplayOn(c);
|
||||
return c;
|
||||
}
|
||||
@@ -1369,11 +1703,216 @@ char UITask::handleDoubleClick(char c) {
|
||||
char UITask::handleTripleClick(char c) {
|
||||
MESH_DEBUG_PRINTLN("UITask: triple click triggered");
|
||||
checkDisplayOn(c);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// Triple-click → half brightness backlight (comfortable reading)
|
||||
if (board.isBacklightOn()) {
|
||||
board.setBacklight(false); // If already on, turn off
|
||||
} else {
|
||||
board.setBacklightBrightness(4);
|
||||
board.setBacklight(true);
|
||||
}
|
||||
#else
|
||||
toggleBuzzer();
|
||||
#endif
|
||||
c = 0;
|
||||
return c;
|
||||
}
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
void UITask::lockScreen() {
|
||||
if (_locked) return;
|
||||
_locked = true;
|
||||
_screenBeforeLock = curr;
|
||||
setCurrScreen(lock_screen);
|
||||
board.setBacklight(false); // Save power
|
||||
_next_refresh = 0; // Draw lock screen immediately
|
||||
_auto_off = millis() + 60000; // 60s before display off while locked
|
||||
Serial.println("[UI] Screen locked");
|
||||
}
|
||||
|
||||
void UITask::unlockScreen() {
|
||||
if (!_locked) return;
|
||||
_locked = false;
|
||||
if (_screenBeforeLock) {
|
||||
setCurrScreen(_screenBeforeLock);
|
||||
} else {
|
||||
gotoHomeScreen();
|
||||
}
|
||||
_screenBeforeLock = nullptr;
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 0;
|
||||
Serial.println("[UI] Screen unlocked");
|
||||
}
|
||||
|
||||
void UITask::showVirtualKeyboard(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx) {
|
||||
_vkb.open(purpose, label, initial, maxLen, contextIdx);
|
||||
_vkbActive = true;
|
||||
_vkbOpenedAt = millis();
|
||||
_screenBeforeVKB = curr;
|
||||
_next_refresh = 0;
|
||||
_auto_off = millis() + 120000; // 2min timeout while typing
|
||||
Serial.printf("[UI] VKB opened: %s\n", label);
|
||||
}
|
||||
|
||||
void UITask::onVKBSubmit() {
|
||||
_vkbActive = false;
|
||||
const char* text = _vkb.getText();
|
||||
VKBPurpose purpose = _vkb.purpose();
|
||||
int idx = _vkb.contextIdx();
|
||||
|
||||
Serial.printf("[UI] VKB submit: purpose=%d idx=%d text='%s'\n", purpose, idx, text);
|
||||
|
||||
switch (purpose) {
|
||||
case VKB_CHANNEL_MSG: {
|
||||
if (strlen(text) == 0) break;
|
||||
|
||||
ChannelDetails channel;
|
||||
if (the_mesh.getChannel(idx, channel)) {
|
||||
uint32_t timestamp = rtc_clock.getCurrentTime();
|
||||
int textLen = strlen(text);
|
||||
if (the_mesh.sendGroupMessage(timestamp, channel.channel,
|
||||
the_mesh.getNodePrefs()->node_name,
|
||||
text, textLen)) {
|
||||
addSentChannelMessage(idx, the_mesh.getNodePrefs()->node_name, text);
|
||||
the_mesh.queueSentChannelMessage(idx, timestamp,
|
||||
the_mesh.getNodePrefs()->node_name, text);
|
||||
showAlert("Sent!", 1500);
|
||||
} else {
|
||||
showAlert("Send failed!", 1500);
|
||||
}
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_DM: {
|
||||
if (strlen(text) == 0) break;
|
||||
|
||||
if (the_mesh.uiSendDirectMessage((uint32_t)idx, text)) {
|
||||
showAlert("DM sent!", 1500);
|
||||
} else {
|
||||
showAlert("DM failed!", 1500);
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_ADMIN_PASSWORD: {
|
||||
// Feed each character to the admin screen, then Enter
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)getRepeaterAdminScreen();
|
||||
if (admin) {
|
||||
for (int i = 0; text[i]; i++) {
|
||||
admin->handleInput(text[i]);
|
||||
}
|
||||
admin->handleInput('\r');
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_ADMIN_CLI: {
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)getRepeaterAdminScreen();
|
||||
if (admin) {
|
||||
for (int i = 0; text[i]; i++) {
|
||||
admin->handleInput(text[i]);
|
||||
}
|
||||
admin->handleInput('\r');
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_SETTINGS_NAME: {
|
||||
if (strlen(text) > 0) {
|
||||
strncpy(_node_prefs->node_name, text, sizeof(_node_prefs->node_name) - 1);
|
||||
_node_prefs->node_name[sizeof(_node_prefs->node_name) - 1] = '\0';
|
||||
the_mesh.savePrefs();
|
||||
showAlert("Name saved", 1000);
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_NOTES: {
|
||||
NotesScreen* notes = (NotesScreen*)getNotesScreen();
|
||||
if (notes && strlen(text) > 0) {
|
||||
for (int i = 0; text[i]; i++) {
|
||||
notes->handleInput(text[i]);
|
||||
}
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
case VKB_WIFI_PASSWORD: {
|
||||
SettingsScreen* ss = (SettingsScreen*)settings_screen;
|
||||
ss->submitWifiPassword(text);
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
showAlert("WiFi connected!", 2000);
|
||||
} else {
|
||||
showAlert("WiFi failed", 2000);
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef MECK_WEB_READER
|
||||
case VKB_WEB_URL: {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)getWebReaderScreen();
|
||||
if (wr && strlen(text) > 0) {
|
||||
wr->setUrlText(text); // Copy text + set _urlEditing = true
|
||||
wr->handleInput('\r'); // Triggers auto-prefix + fetch
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_WEB_SEARCH: {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)getWebReaderScreen();
|
||||
if (wr && strlen(text) > 0) {
|
||||
wr->setSearchText(text); // Copy text + set _searchEditing = true
|
||||
wr->handleInput('\r'); // Triggers DDG search URL build + fetch
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_WEB_WIFI_PASS: {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)getWebReaderScreen();
|
||||
if (wr && strlen(text) > 0) {
|
||||
wr->setWifiPassText(text); // Copy password text
|
||||
wr->handleInput('\r'); // Triggers WiFi connect
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_WEB_LINK: {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)getWebReaderScreen();
|
||||
if (wr && strlen(text) > 0) {
|
||||
// Activate link input mode, feed digits, then submit
|
||||
wr->handleInput('l'); // Enter link selection mode
|
||||
for (int i = 0; text[i]; i++) {
|
||||
if (text[i] >= '0' && text[i] <= '9') {
|
||||
wr->handleInput(text[i]);
|
||||
}
|
||||
}
|
||||
wr->handleInput('\r'); // Confirm link number → navigate
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
_screenBeforeVKB = nullptr;
|
||||
_next_refresh = 0;
|
||||
display.setForcePartial(false); // Next frame does full refresh to clear VKB ghosts
|
||||
display.invalidateFrameCRC();
|
||||
}
|
||||
|
||||
void UITask::onVKBCancel() {
|
||||
_vkbActive = false;
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
_screenBeforeVKB = nullptr;
|
||||
_next_refresh = 0;
|
||||
display.setForcePartial(false); // Next frame does full refresh to clear VKB ghosts
|
||||
display.invalidateFrameCRC();
|
||||
Serial.println("[UI] VKB cancelled");
|
||||
}
|
||||
#endif
|
||||
|
||||
bool UITask::getGPSState() {
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
return _node_prefs != NULL && _node_prefs->gps_enabled;
|
||||
@@ -1643,6 +2182,7 @@ void UITask::gotoWebReader() {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_GPS
|
||||
void UITask::gotoMapScreen() {
|
||||
MapScreen* map = (MapScreen*)map_screen;
|
||||
if (_display != NULL) {
|
||||
@@ -1655,6 +2195,7 @@ void UITask::gotoMapScreen() {
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
#endif
|
||||
|
||||
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
#include "WebReaderScreen.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)
|
||||
|
||||
@@ -85,6 +89,18 @@ class UITask : public AbstractUITask {
|
||||
#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;
|
||||
|
||||
VirtualKeyboard _vkb;
|
||||
bool _vkbActive = false;
|
||||
UIScreen* _screenBeforeVKB = nullptr;
|
||||
unsigned long _vkbOpenedAt = 0;
|
||||
#endif
|
||||
|
||||
void userLedHandler();
|
||||
|
||||
@@ -121,7 +137,9 @@ public:
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
void gotoDiscoveryScreen(); // Navigate to node discovery scan
|
||||
#if HAS_GPS
|
||||
void gotoMapScreen(); // Navigate to map tile screen
|
||||
#endif
|
||||
#ifdef MECK_WEB_READER
|
||||
void gotoWebReader(); // Navigate to web reader (browser)
|
||||
#endif
|
||||
@@ -145,12 +163,28 @@ public:
|
||||
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; }
|
||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
|
||||
bool isOnMapScreen() const { return curr == map_screen; }
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
bool isLocked() const { return _locked; }
|
||||
void lockScreen();
|
||||
void unlockScreen();
|
||||
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();
|
||||
#endif
|
||||
#ifdef MECK_WEB_READER
|
||||
bool isOnWebReader() const { return curr == web_reader; }
|
||||
#endif
|
||||
|
||||
@@ -2668,7 +2668,11 @@ private:
|
||||
bool selected = (i == _selectedSSID);
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -2736,7 +2740,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 +2753,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) {
|
||||
@@ -2877,7 +2892,11 @@ private:
|
||||
if (HOME_VISIBLE(y, ircH)) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -2912,7 +2931,11 @@ private:
|
||||
if (HOME_VISIBLE(y, urlBarH)) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -2945,7 +2968,11 @@ private:
|
||||
if (HOME_VISIBLE(y, searchBarH)) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -2994,7 +3021,11 @@ private:
|
||||
int contentW = display.width() - (needsScroll ? scrollbarW + 1 : 0);
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, itemH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, contentW, itemH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -3042,7 +3073,11 @@ private:
|
||||
int contentW = display.width() - (needsScroll ? scrollbarW + 1 : 0);
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, itemH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, contentW, itemH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -3108,6 +3143,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 +3166,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 +3198,27 @@ private:
|
||||
display.setCursor(10, 20);
|
||||
display.print("Loading...");
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setTextSize(0);
|
||||
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) {
|
||||
@@ -3206,9 +3265,13 @@ 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");
|
||||
@@ -3223,7 +3286,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 +3299,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) {
|
||||
@@ -3360,6 +3431,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 +3447,7 @@ private:
|
||||
} else {
|
||||
hint = "B:Bk Q:X";
|
||||
}
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(hint) - 2, footerY);
|
||||
display.print(hint);
|
||||
|
||||
@@ -3895,7 +3974,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 +4048,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);
|
||||
}
|
||||
}
|
||||
@@ -4589,7 +4676,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 +4728,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) {
|
||||
@@ -4767,11 +4862,19 @@ 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
|
||||
@@ -5159,6 +5262,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) {
|
||||
|
||||
49
examples/companion_radio/ui-new/homeicons.h
Normal file
49
examples/companion_radio/ui-new/homeicons.h
Normal file
@@ -0,0 +1,49 @@
|
||||
#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,
|
||||
};
|
||||
337
examples/companion_radio/ui-new/virtualkeyboard.h
Normal file
337
examples/companion_radio/ui-new/virtualkeyboard.h
Normal file
@@ -0,0 +1,337 @@
|
||||
#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_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
|
||||
};
|
||||
|
||||
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; }
|
||||
|
||||
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
|
||||
65
merge_firmware.py
Normal file
65
merge_firmware.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
PlatformIO post-build script: merge bootloader + partitions + firmware
|
||||
into a single flashable binary.
|
||||
|
||||
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 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,
|
||||
]
|
||||
|
||||
print(f"\n[merge] Creating merged firmware for {env_name}...")
|
||||
print(f"[merge] {' '.join(cmd[-6:])}")
|
||||
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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; }
|
||||
};
|
||||
@@ -70,14 +70,24 @@ void GxEPDDisplay::turnOff() {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -105,11 +115,20 @@ void GxEPDDisplay::setTextSize(int sz) {
|
||||
|
||||
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
|
||||
70
variants/lilygo_t5s3_epaper_pro/CPUPowerManager.h
Normal file
70
variants/lilygo_t5s3_epaper_pro/CPUPowerManager.h
Normal file
@@ -0,0 +1,70 @@
|
||||
#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
|
||||
//
|
||||
// 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.
|
||||
|
||||
#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_BOOST_TIMEOUT_MS
|
||||
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
|
||||
#endif
|
||||
|
||||
class CPUPowerManager {
|
||||
public:
|
||||
CPUPowerManager() : _boosted(false), _boost_started(0) {}
|
||||
|
||||
void begin() {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = 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();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
bool isBoosted() const { return _boosted; }
|
||||
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
|
||||
|
||||
private:
|
||||
bool _boosted;
|
||||
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);
|
||||
}
|
||||
};
|
||||
305
variants/lilygo_t5s3_epaper_pro/T5S3Board.cpp
Normal file
305
variants/lilygo_t5s3_epaper_pro/T5S3Board.cpp
Normal file
@@ -0,0 +1,305 @@
|
||||
#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 ----
|
||||
// Identical procedure to TDeckBoard — sets 1500 mAh for T5S3's larger cell.
|
||||
// The BQ27220 ships with 3000 mAh default. This writes once on first boot
|
||||
// and persists in battery-backed RAM.
|
||||
|
||||
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) {
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity correct, FCC=%d mAh\n", fcc);
|
||||
if (fcc < designCapacity_mAh * 3 / 2) {
|
||||
return true; // FCC is sane, nothing to do
|
||||
}
|
||||
// FCC is stale from factory — fall through to reconfigure
|
||||
Serial.printf("BQ27220: FCC %d >> DC %d, reconfiguring\n", fcc, designCapacity_mAh);
|
||||
}
|
||||
|
||||
// Unseal
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Exit CFG_UPDATE with reinit
|
||||
bq27220_writeControl(0x0091);
|
||||
delay(200);
|
||||
|
||||
// Seal
|
||||
bq27220_writeControl(0x0030);
|
||||
delay(5);
|
||||
|
||||
// Force RESET to reinitialize FCC
|
||||
bq27220_writeControl(0x0041);
|
||||
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 */
|
||||
160
variants/lilygo_t5s3_epaper_pro/platformio.ini
Normal file
160
variants/lilygo_t5s3_epaper_pro/platformio.ini
Normal file
@@ -0,0 +1,160 @@
|
||||
; ===========================================================================
|
||||
; 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}
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; T5S3 standalone — touch UI (stub), verify display rendering
|
||||
; Uses FastEPD for parallel e-ink, Adafruit GFX for drawing
|
||||
; ---------------------------------------------------------------------------
|
||||
[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
|
||||
; Font family: comment/uncomment to toggle (delete .indexes on SD after switching)
|
||||
; -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
|
||||
; 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_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 TCP_PORT=5000
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D DISPLAY_CLASS=FastEPDDisplay
|
||||
-D USE_EINK
|
||||
; -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();
|
||||
188
variants/lilygo_t5s3_epaper_pro/variant.h
Normal file
188
variants/lilygo_t5s3_epaper_pro/variant.h
Normal file
@@ -0,0 +1,188 @@
|
||||
#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
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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,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
|
||||
@@ -165,7 +167,7 @@ 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
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
@@ -245,7 +247,7 @@ 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 v1.0.4G.SA"'
|
||||
|
||||
Reference in New Issue
Block a user