mirror of
https://github.com/pelgraine/Meck.git
synced 2026-05-11 12:00:10 +02:00
Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d601edd0ce | |||
| a378f4f1aa | |||
| 1c4d5a0daa | |||
| df6c977ee4 | |||
| 7e9e69dd67 | |||
| 6d7fd54b83 | |||
| d2ce070a3f | |||
| 44b68c40af | |||
| a4e8c31a16 | |||
| 8d69b69e1f | |||
| 0c032429eb | |||
| c578dcadc8 | |||
| ec42ac73a8 | |||
| aacf8c777f | |||
| 570776478c | |||
| 4c654c99c6 | |||
| f436f5ba50 | |||
| 0252204d73 | |||
| 595f0073f9 | |||
| 8aa0f0388e | |||
| b070af39cc | |||
| c939aa577b | |||
| abccfe154e | |||
| 23733ca555 | |||
| 9d45ac52eb | |||
| 424e152d4b | |||
| c687133b05 | |||
| c7d0449181 | |||
| 9ddb692806 | |||
| 0cab2ddfa7 | |||
| d07ad71d5d | |||
| b4983e48f0 | |||
| b991eb0fe7 | |||
| c15b30079c | |||
| 9d7cbd4866 | |||
| b9283af7fc | |||
| 39cd30890b | |||
| 902577ed10 | |||
| ce93cfa033 | |||
| 2be399f65a | |||
| 5679cda38e | |||
| 1ea883783c | |||
| bf8cf32bc2 | |||
| 465a29bb23 | |||
| 81eca29b69 | |||
| 342cf4e745 | |||
| c52a190ace | |||
| a7bc7a4733 | |||
| 47a0d2cc95 | |||
| 5dda0b686e | |||
| 60dcd6a89e | |||
| 19efb52521 | |||
| 81ef3ea3c5 | |||
| 6f07b7a372 | |||
| b0f74b101a | |||
| 06a064538e | |||
| 166a433353 | |||
| 735fefd203 | |||
| ed5cda4f44 | |||
| b208af83f6 | |||
| bad821ac4b | |||
| 8839012153 | |||
| 0958ef079e | |||
| 0bf2826110 | |||
| c2840a43aa | |||
| e8a8be521a | |||
| a627fbe0e9 | |||
| 17f8233402 | |||
| 1c9e9079f0 | |||
| 69dc62fa78 | |||
| f118a0949f | |||
| f78824cdc4 | |||
| f81de07830 | |||
| 3ae988c0bb | |||
| 5bed26cb72 | |||
| c28d22e6cc | |||
| 8e1f2a3a87 | |||
| 6d1447a45c | |||
| 77c92b3567 | |||
| 6db7b672ca | |||
| 046cce6f43 | |||
| c2c2d8cf21 | |||
| 148f8cea4f | |||
| cd69ea546f | |||
| 7780a0d76e | |||
| 33a3352692 | |||
| 4004acf15d | |||
| 0b9402b530 | |||
| e55799f8a5 | |||
| 0549efa627 | |||
| a52cf166cb | |||
| facffe9f07 | |||
| 148fb7f001 | |||
| 509411630b | |||
| a1ce8ca4d4 | |||
| b77059706b | |||
| a6f0052b89 | |||
| 120c0a739b | |||
| 816e41d63a | |||
| 68d10f088f | |||
| 2f0c8909b9 | |||
| c60255a44d | |||
| 9040873526 | |||
| a564957a82 | |||
| b55892431d | |||
| dc5331702d | |||
| 88a887eba2 | |||
| b1218223e6 | |||
| 0971cd6015 | |||
| 81eb558868 | |||
| 74b24f1222 | |||
| 182231deeb | |||
| 3372c4aa1d | |||
| 467773366b | |||
| 753d125384 | |||
| 8b78eac17f | |||
| 565c2a4c9b | |||
| 7ae9c47006 | |||
| 2a0497e5ba | |||
| 479673e90f | |||
| 9b15458927 | |||
| 85ccdf526e | |||
| c0dd59834c | |||
| 90a4f5f881 |
@@ -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
|
||||
@@ -57,12 +57,22 @@ All commands follow a simple pattern: `get` to read, `set` to write.
|
||||
| `get radio` | All radio params in one line |
|
||||
| `get utc` | UTC offset (hours) |
|
||||
| `get notify` | Keyboard flash notification (on/off) |
|
||||
| `get largefont` | Larger font mode (on/off) |
|
||||
| `get gps` | GPS status and interval |
|
||||
| `get pin` | BLE pairing PIN |
|
||||
| `get path.hash.mode` | Path hash size (0=1-byte, 1=2-byte, 2=3-byte) |
|
||||
| `get rxdelay` | Rx delay base (0=disabled) |
|
||||
| `get af` | Airtime factor |
|
||||
| `get multi.acks` | Redundant ACKs (0 or 1) |
|
||||
| `get int.thresh` | Interference threshold (0=disabled) |
|
||||
| `get tx.fail.reset` | TX fail reset threshold (0=disabled, default 3) |
|
||||
| `get rx.fail.reboot` | RX stuck reboot threshold (0=disabled, default 3) |
|
||||
| `get gps.baud` | GPS baud rate (0=compile-time default) |
|
||||
| `get channels` | List all channels with index numbers |
|
||||
| `get presets` | List all radio presets with parameters |
|
||||
| `get pubkey` | Device public key (hex) |
|
||||
| `get firmware` | Firmware version string |
|
||||
| `clock` | Current RTC time (UTC + epoch) |
|
||||
|
||||
**4G variant only:**
|
||||
|
||||
@@ -157,12 +167,115 @@ set notify on
|
||||
set notify off
|
||||
```
|
||||
|
||||
#### Larger Font Mode
|
||||
|
||||
Toggle larger text on channel messages, contacts, DM inbox, and repeater admin screens:
|
||||
|
||||
```
|
||||
set largefont on
|
||||
set largefont off
|
||||
```
|
||||
|
||||
#### BLE PIN
|
||||
|
||||
```
|
||||
set pin 123456
|
||||
```
|
||||
|
||||
#### Path Hash Mode
|
||||
|
||||
Controls the byte size of each repeater's identity stamp in forwarded flood packets. Larger hashes reduce collisions at the cost of fewer maximum hops.
|
||||
|
||||
```
|
||||
set path.hash.mode 1
|
||||
```
|
||||
|
||||
| Mode | Bytes/hop | Max hops | Notes |
|
||||
|------|-----------|----------|-------|
|
||||
| 0 | 1 | 64 | Legacy — prone to hash collisions in larger networks |
|
||||
| 1 | 2 | 32 | Recommended — effectively eliminates collisions |
|
||||
| 2 | 3 | 21 | Maximum precision, rarely needed |
|
||||
|
||||
Nodes with different modes can coexist — the mode only affects packets your node originates. The hash size is encoded in each packet's header, so receiving nodes adapt automatically.
|
||||
|
||||
### Mesh Tuning
|
||||
|
||||
These settings control how the device participates in the mesh network. They take effect immediately — no reboot required (except `gps.baud`).
|
||||
|
||||
#### Rx Delay (rxdelay)
|
||||
|
||||
Delays processing of flood packets based on signal quality. Stronger signals are processed first; weaker copies wait longer and are typically discarded as duplicates. Direct messages are always processed immediately.
|
||||
|
||||
```
|
||||
set rxdelay 3
|
||||
```
|
||||
|
||||
Range: 0–20 (0 = disabled, default). Higher values create larger timing differences between strong and weak signals. Values below 1.0 have no practical effect. See the [MeshSydney wiki](https://meshsydney.com/wiki) for detailed tuning profiles.
|
||||
|
||||
#### Airtime Factor (af)
|
||||
|
||||
Adjusts how long certain internal timing windows remain open. Does not change the LoRa radio parameters (SF, BW, CR) — those remain as configured.
|
||||
|
||||
```
|
||||
set af 1.0
|
||||
```
|
||||
|
||||
Range: 0–9 (default: 1.0). Keep this value consistent across nodes in your mesh for best coherence.
|
||||
|
||||
#### Multiple Acknowledgments (multi.acks)
|
||||
|
||||
Sends redundant ACK packets for direct messages. When enabled, two ACKs are sent (a multi-ack first, then the standard ACK), improving delivery confirmation reliability.
|
||||
|
||||
```
|
||||
set multi.acks 1
|
||||
```
|
||||
|
||||
Values: 0 (single ACK) or 1 (redundant ACKs, default).
|
||||
|
||||
#### Interference Threshold (int.thresh)
|
||||
|
||||
Enables channel activity scanning before transmitting. Not recommended unless your device is in a high RF interference environment — specifically where the noise floor is low but shows significant fluctuations indicating interference. Enabling this adds approximately 4 seconds of receive delay per packet.
|
||||
|
||||
```
|
||||
set int.thresh 14
|
||||
set int.thresh 0
|
||||
```
|
||||
|
||||
Values: 0 (disabled, default) or 14+ (14 is the typical setting). Values between 1–13 are not functional and will be rejected.
|
||||
|
||||
#### TX Fail Reset Threshold (tx.fail.reset)
|
||||
|
||||
Automatically resets the radio hardware after this many consecutive failed transmission attempts. This recovers from "zombie radio" states where the SX1262 stops responding to send commands.
|
||||
|
||||
```
|
||||
set tx.fail.reset 3
|
||||
set tx.fail.reset 0
|
||||
```
|
||||
|
||||
Values: 0 (disabled) or 1–10 (default: 3). After the threshold is reached, the radio is reset and the failed packet is re-queued.
|
||||
|
||||
#### RX Stuck Reboot Threshold (rx.fail.reboot)
|
||||
|
||||
Automatically reboots the device after this many consecutive RX-stuck recovery failures. An RX-stuck event occurs when the radio is not in receive mode for 8 seconds despite automatic recovery attempts.
|
||||
|
||||
```
|
||||
set rx.fail.reboot 3
|
||||
set rx.fail.reboot 0
|
||||
```
|
||||
|
||||
Values: 0 (disabled) or 1–10 (default: 3). A full device reboot is a last resort — this should only trigger in rare cases of persistent radio hardware malfunction.
|
||||
|
||||
#### GPS Baud Rate (gps.baud)
|
||||
|
||||
Override the GPS serial baud rate. The default (0) uses the compile-time value of 38400. **Requires a reboot to take effect** — the GPS serial port is only configured at startup.
|
||||
|
||||
```
|
||||
set gps.baud 9600
|
||||
set gps.baud 0
|
||||
```
|
||||
|
||||
Valid rates: 0 (default), 4800, 9600, 19200, 38400, 57600, 115200.
|
||||
|
||||
### Channel Management
|
||||
|
||||
#### List Channels
|
||||
@@ -216,6 +329,68 @@ To clear a custom APN and revert to auto-detection on next boot:
|
||||
set apn
|
||||
```
|
||||
|
||||
### Clock Sync
|
||||
|
||||
Set the device's real-time clock from a Unix timestamp. This is especially important for the T5S3 E-Paper Pro which has no GPS to auto-set the clock. These are standalone commands (not `get`/`set` prefixed) — matching the same `clock sync` command used on MeshCore repeaters.
|
||||
|
||||
#### View Current Time
|
||||
|
||||
```
|
||||
clock
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
> 2026-03-13 04:22:15 UTC (epoch: 1773554535)
|
||||
```
|
||||
|
||||
If the clock has never been set:
|
||||
|
||||
```
|
||||
> not set (epoch: 0)
|
||||
```
|
||||
|
||||
#### Sync Clock from Serial
|
||||
|
||||
```
|
||||
clock sync 1773554535
|
||||
```
|
||||
|
||||
The value must be a Unix epoch timestamp in the 2024–2036 range.
|
||||
|
||||
**Quick one-liner from your terminal (macOS / Linux / WSL):**
|
||||
|
||||
```
|
||||
echo "clock sync $(date +%s)" > /dev/ttyACM0
|
||||
```
|
||||
|
||||
Or paste directly into the Arduino IDE Serial Monitor:
|
||||
|
||||
```
|
||||
clock sync 1773554535
|
||||
```
|
||||
|
||||
**Tip:** On macOS/Linux, run `date +%s` to get the current epoch. On Windows PowerShell: `[int](Get-Date -UFormat %s)`.
|
||||
|
||||
#### Boot-Time Auto-Sync (T5S3)
|
||||
|
||||
When the T5S3 boots with no valid RTC time and detects a USB serial host is connected, it sends a `MECK_CLOCK_REQ` handshake over serial. If you're using PlatformIO's serial monitor (`pio device monitor`), the built-in `clock_sync` monitor filter responds automatically with the host computer's current time — no user action required. The sync appears transparently in the boot log:
|
||||
|
||||
```
|
||||
MECK_CLOCK_REQ
|
||||
(Waiting 3s for clock sync from host...)
|
||||
> Clock synced to 1773554535
|
||||
```
|
||||
|
||||
If no USB host is connected (e.g. running on battery), the sync window is skipped entirely with no boot delay.
|
||||
|
||||
**Manual fallback:** If you're using a serial terminal that doesn't have the filter (e.g. `screen`, PuTTY), you can paste a `clock sync` command during the 3-second window, or any time after boot:
|
||||
|
||||
```
|
||||
clock sync $(date +%s)
|
||||
```
|
||||
|
||||
### System Commands
|
||||
|
||||
| Command | Description |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This adds a text reader accessible via the **R** key from the home screen.
|
||||
This adds a text reader accessible via the **E** key from the home screen.
|
||||
|
||||
**Features:**
|
||||
- Browse `.txt` and `.epub` files from `/books/` folder on SD card
|
||||
@@ -13,17 +13,27 @@ This adds a text reader accessible via the **R** key from the home screen.
|
||||
- Index files cached to SD for instant re-opens
|
||||
- Bookmark indicator (`*`) on files with saved positions
|
||||
|
||||
**Key Mapping:**
|
||||
**Key Mapping (T-Deck Pro):**
|
||||
| Context | Key | Action |
|
||||
|---------|-----|--------|
|
||||
| Home screen | E | Open text reader |
|
||||
| File list | W/S | Navigate up/down |
|
||||
| File list | Enter | Open selected file |
|
||||
| File list | Tap / Enter | Open selected file |
|
||||
| File list | Q | Back to home screen |
|
||||
| Reading | W/A | Previous page |
|
||||
| Reading | S/D/Space/Enter | Next page |
|
||||
| Reading | S/D/Space | Next page |
|
||||
| Reading | Enter | Go to page number (type digits, Enter to confirm, Q to cancel) |
|
||||
| Reading | Q | Close book → file list |
|
||||
| Reading | C | Enter compose mode |
|
||||
|
||||
**Touch Gestures (T5S3):**
|
||||
| Context | Gesture | Action |
|
||||
|---------|---------|--------|
|
||||
| File list | Swipe up/down | Scroll file list |
|
||||
| File list | Tap | Open selected book |
|
||||
| Reading | Tap | Next page |
|
||||
| Reading | Swipe left/right | Next / previous page |
|
||||
| Reading | Tap footer | Go to page number (via virtual keyboard) |
|
||||
| Reading | Long press | Close book → file list |
|
||||
|
||||
---
|
||||
|
||||
@@ -113,4 +123,4 @@ The conversion is handled by three components:
|
||||
- Page content is pre-read from SD into a memory buffer during `handleInput()`, then rendered from buffer during `render()` — this avoids SPI bus conflicts during display refresh
|
||||
- Layout metrics (chars per line, lines per page) are calculated dynamically from the display driver's font metrics on first entry
|
||||
- EPUB conversion runs synchronously in `openBook()` — the e-ink splash screen keeps the user informed while the ESP32 processes the archive
|
||||
- ZIP extraction uses the ESP32-S3's hardware-optimised ROM `tinfl` inflate, avoiding external compression library dependencies and the linker conflicts they cause
|
||||
- ZIP extraction uses the ESP32-S3's hardware-optimised ROM `tinfl` inflate, avoiding external compression library dependencies and the linker conflicts they cause
|
||||
@@ -39,7 +39,7 @@
|
||||
"frameworks": ["arduino"],
|
||||
"name": "Heltec nrf (Adafruit BSP)",
|
||||
"upload": {
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_ram_size": 235520,
|
||||
"maximum_size": 815104,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52840_s140_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": [
|
||||
"-DARDUINO_NRF52840_TECHO_CARD",
|
||||
"-DNRF52840_XXAA",
|
||||
"-DNRF52_SERIES"
|
||||
],
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [["0x239A", "0x8029"]],
|
||||
"mcu": "nrf52840",
|
||||
"variant": "lilygo_techo_card",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_name": "s140",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B6"
|
||||
},
|
||||
"usb_product": "T-Echo Card"
|
||||
},
|
||||
"connectivity": ["bluetooth", "lora"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"openocd_target": "nrf52840"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "LilyGo T-Echo Card (nRF52840, SX1262, 4MB Flash)",
|
||||
"upload": {
|
||||
"flash_size": "796KB",
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"native_usb": true,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": ["nrfutil", "jlink", "cmsis-dap"],
|
||||
"require_upload_port": true,
|
||||
"speed": 115200,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true
|
||||
},
|
||||
"url": "https://github.com/Xinyuan-LilyGO/T-Echo-Card",
|
||||
"vendor": "LILYGO"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"memory_type": "qio_qspi",
|
||||
"partitions": "default_16MB.csv"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "esp32s3"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "LilyGo T-Deck Pro MAX (16MB Flash 8MB QSPI PSRAM)",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://www.lilygo.cc/products/t-deck-pro",
|
||||
"vendor": "LilyGo"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
6818ce5f77dd45bb90facf753ba81d81.s1.eu.hivemq.cloud
|
||||
8883
|
||||
meckremote
|
||||
yourpassword
|
||||
heltec-wifi-1
|
||||
@@ -0,0 +1,2 @@
|
||||
SSID
|
||||
Password
|
||||
@@ -2,7 +2,7 @@
|
||||
#include "DataStore.h"
|
||||
|
||||
#if defined(EXTRAFS) || defined(QSPIFLASH)
|
||||
#define MAX_BLOBRECS 100
|
||||
#define MAX_BLOBRECS 1000
|
||||
#else
|
||||
#define MAX_BLOBRECS 20
|
||||
#endif
|
||||
@@ -252,6 +252,50 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
|
||||
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
|
||||
|
||||
// v1.1+ Meck fields — may not exist in older prefs files
|
||||
if (file.read((uint8_t *)&_prefs.gps_baudrate, sizeof(_prefs.gps_baudrate)) != sizeof(_prefs.gps_baudrate)) {
|
||||
_prefs.gps_baudrate = 0; // default: use compile-time GPS_BAUDRATE
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold)) != sizeof(_prefs.interference_threshold)) {
|
||||
_prefs.interference_threshold = 0; // default: disabled
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)) != sizeof(_prefs.dark_mode)) {
|
||||
_prefs.dark_mode = 0; // default: light mode
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)) != sizeof(_prefs.portrait_mode)) {
|
||||
_prefs.portrait_mode = 0; // default: landscape
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)) != sizeof(_prefs.auto_lock_minutes)) {
|
||||
_prefs.auto_lock_minutes = 0; // default: disabled
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)) != sizeof(_prefs.hint_shown)) {
|
||||
_prefs.hint_shown = 0; // default: show boot hint
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)) != sizeof(_prefs.large_font)) {
|
||||
_prefs.large_font = 0; // default: tiny font
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)) != sizeof(_prefs.tx_fail_reset_threshold)) {
|
||||
_prefs.tx_fail_reset_threshold = 3; // default: 3
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)) != sizeof(_prefs.rx_fail_reboot_threshold)) {
|
||||
_prefs.rx_fail_reboot_threshold = 3; // default: 3
|
||||
}
|
||||
|
||||
// Clamp to valid ranges
|
||||
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
|
||||
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
|
||||
if (_prefs.hint_shown > 1) _prefs.hint_shown = 0;
|
||||
if (_prefs.large_font > 1) _prefs.large_font = 0;
|
||||
if (_prefs.tx_fail_reset_threshold > 10) _prefs.tx_fail_reset_threshold = 3;
|
||||
if (_prefs.rx_fail_reboot_threshold > 10) _prefs.rx_fail_reboot_threshold = 3;
|
||||
// auto_lock_minutes: only accept known options (0, 2, 5, 10, 15, 30)
|
||||
{
|
||||
uint8_t alm = _prefs.auto_lock_minutes;
|
||||
if (alm != 0 && alm != 2 && alm != 5 && alm != 10 && alm != 15 && alm != 30) {
|
||||
_prefs.auto_lock_minutes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
@@ -291,6 +335,15 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)); // 90
|
||||
file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 91
|
||||
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 92
|
||||
file.write((uint8_t *)&_prefs.gps_baudrate, sizeof(_prefs.gps_baudrate)); // 93
|
||||
file.write((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold)); // 97
|
||||
file.write((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)); // 98
|
||||
file.write((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)); // 99
|
||||
file.write((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)); // 100
|
||||
file.write((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)); // 101
|
||||
file.write((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)); // 102
|
||||
file.write((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)); // 103
|
||||
file.write((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)); // 104
|
||||
|
||||
file.close();
|
||||
}
|
||||
@@ -443,6 +496,112 @@ void DataStore::saveContacts(DataStoreHost* host) {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Chunked contact save — non-blocking across multiple loop iterations
|
||||
// =========================================================================
|
||||
|
||||
bool DataStore::beginSaveContacts(DataStoreHost* host) {
|
||||
if (_saveInProgress) return false; // Already saving
|
||||
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
// Defensive cleanup in case a previous save didn't reach finishSaveContacts()
|
||||
if (_saveFile) {
|
||||
_saveFile->close();
|
||||
delete _saveFile;
|
||||
_saveFile = nullptr;
|
||||
}
|
||||
_saveFile = new File(openWrite(fs, "/contacts3.tmp"));
|
||||
if (!_saveFile || !*_saveFile) {
|
||||
Serial.println("DataStore: chunked save FAILED — cannot open tmp file");
|
||||
if (_saveFile) { delete _saveFile; _saveFile = nullptr; }
|
||||
return false;
|
||||
}
|
||||
|
||||
_saveHost = host;
|
||||
_saveIdx = 0;
|
||||
_saveRecordsWritten = 0;
|
||||
_saveWriteOk = true;
|
||||
_saveInProgress = true;
|
||||
Serial.println("DataStore: chunked save started");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DataStore::saveContactsChunk(int batchSize) {
|
||||
if (!_saveInProgress || !_saveWriteOk) return false;
|
||||
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
int written = 0;
|
||||
|
||||
while (written < batchSize && _saveHost->getContactForSave(_saveIdx, c)) {
|
||||
bool success = (_saveFile->write(c.id.pub_key, 32) == 32);
|
||||
success = success && (_saveFile->write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (_saveFile->write(&c.type, 1) == 1);
|
||||
success = success && (_saveFile->write(&c.flags, 1) == 1);
|
||||
success = success && (_saveFile->write(&unused, 1) == 1);
|
||||
success = success && (_saveFile->write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (_saveFile->write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (_saveFile->write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (_saveFile->write(c.out_path, 64) == 64);
|
||||
success = success && (_saveFile->write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (_saveFile->write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (_saveFile->write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
|
||||
if (!success) {
|
||||
_saveWriteOk = false;
|
||||
Serial.printf("DataStore: chunked save write error at record %d\n", _saveIdx);
|
||||
return false; // Error — finishSaveContacts will clean up
|
||||
}
|
||||
|
||||
_saveRecordsWritten++;
|
||||
_saveIdx++;
|
||||
written++;
|
||||
}
|
||||
|
||||
// Check if there are more contacts to write
|
||||
ContactInfo peek;
|
||||
if (_saveHost->getContactForSave(_saveIdx, peek)) {
|
||||
return true; // More to write
|
||||
}
|
||||
return false; // Done
|
||||
}
|
||||
|
||||
void DataStore::finishSaveContacts() {
|
||||
if (!_saveInProgress) return;
|
||||
|
||||
if (_saveFile) {
|
||||
_saveFile->close();
|
||||
delete _saveFile;
|
||||
_saveFile = nullptr;
|
||||
}
|
||||
_saveInProgress = false;
|
||||
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
const char* finalPath = "/contacts3";
|
||||
const char* tmpPath = "/contacts3.tmp";
|
||||
|
||||
// Verify
|
||||
size_t expectedBytes = _saveRecordsWritten * 152;
|
||||
File verify = openRead(fs, tmpPath);
|
||||
size_t bytesWritten = verify ? verify.size() : 0;
|
||||
if (verify) verify.close();
|
||||
|
||||
if (!_saveWriteOk || bytesWritten != expectedBytes) {
|
||||
Serial.printf("DataStore: chunked save ABORTED — wrote %d bytes, expected %d (%d records)\n",
|
||||
(int)bytesWritten, (int)expectedBytes, _saveRecordsWritten);
|
||||
fs->remove(tmpPath);
|
||||
return;
|
||||
}
|
||||
|
||||
fs->remove(finalPath);
|
||||
if (fs->rename(tmpPath, finalPath)) {
|
||||
Serial.printf("DataStore: saved %d contacts (%d bytes, chunked)\n",
|
||||
_saveRecordsWritten, (int)bytesWritten);
|
||||
} else {
|
||||
Serial.println("DataStore: rename failed, tmp file preserved");
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::loadChannels(DataStoreHost* host) {
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
|
||||
@@ -543,16 +702,27 @@ struct BlobRec {
|
||||
};
|
||||
|
||||
void DataStore::checkAdvBlobFile() {
|
||||
if (!_getContactsChannelsFS()->exists("/adv_blobs")) {
|
||||
File file = openWrite(_getContactsChannelsFS(), "/adv_blobs");
|
||||
if (file) {
|
||||
BlobRec zeroes;
|
||||
memset(&zeroes, 0, sizeof(zeroes));
|
||||
for (int i = 0; i < MAX_BLOBRECS; i++) { // pre-allocate to fixed size
|
||||
file.write((uint8_t *) &zeroes, sizeof(zeroes));
|
||||
}
|
||||
file.close();
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
size_t expectedSize = (size_t)MAX_BLOBRECS * sizeof(BlobRec);
|
||||
|
||||
if (fs->exists("/adv_blobs")) {
|
||||
File existing = openRead(fs, "/adv_blobs");
|
||||
size_t actualSize = existing ? (size_t)existing.size() : 0;
|
||||
if (existing) existing.close();
|
||||
if (actualSize == expectedSize) return; // already correct size
|
||||
Serial.printf("[DataStore] adv_blobs wrong size (%u vs %u) — recreating\n",
|
||||
(unsigned)actualSize, (unsigned)expectedSize);
|
||||
fs->remove("/adv_blobs"); // delete undersized (or oversized) file
|
||||
}
|
||||
|
||||
File file = openWrite(fs, "/adv_blobs");
|
||||
if (file) {
|
||||
BlobRec zeroes;
|
||||
memset(&zeroes, 0, sizeof(zeroes));
|
||||
for (int i = 0; i < MAX_BLOBRECS; i++) {
|
||||
file.write((uint8_t *) &zeroes, sizeof(zeroes));
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,10 +895,14 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b
|
||||
|
||||
if (key_len > 8) key_len = 8; // just use first 8 bytes (prefix)
|
||||
mesh::Utils::toHex(fname, key, key_len);
|
||||
// Prefer SD card (_fsExtra) — unlimited file count vs SPIFFS ~100-200 file limit.
|
||||
// Fall back to SPIFFS (_fs) for devices without SD.
|
||||
FILESYSTEM* blobFs = (_fsExtra != nullptr) ? _fsExtra : _fs;
|
||||
|
||||
sprintf(path, "/bl/%s", fname);
|
||||
|
||||
if (_fs->exists(path)) {
|
||||
File f = openRead(_fs, path);
|
||||
if (blobFs->exists(path)) {
|
||||
File f = openRead(blobFs, path);
|
||||
if (f) {
|
||||
int len = f.read(dest_buf, 255); // currently MAX 255 byte blob len supported!!
|
||||
f.close();
|
||||
@@ -744,15 +918,20 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src
|
||||
|
||||
if (key_len > 8) key_len = 8; // just use first 8 bytes (prefix)
|
||||
mesh::Utils::toHex(fname, key, key_len);
|
||||
// Prefer SD card (_fsExtra) — unlimited file count vs SPIFFS ~100-200 file limit.
|
||||
FILESYSTEM* blobFs = (_fsExtra != nullptr) ? _fsExtra : _fs;
|
||||
|
||||
blobFs->mkdir("/bl"); // ensure directory exists on chosen filesystem
|
||||
|
||||
sprintf(path, "/bl/%s", fname);
|
||||
|
||||
File f = openWrite(_fs, path);
|
||||
File f = openWrite(blobFs, path);
|
||||
if (f) {
|
||||
int n = f.write(src_buf, len);
|
||||
f.close();
|
||||
if (n == len) return true; // success!
|
||||
|
||||
_fs->remove(path); // blob was only partially written!
|
||||
blobFs->remove(path); // blob was only partially written!
|
||||
}
|
||||
return false; // error
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ class DataStore {
|
||||
void checkAdvBlobFile();
|
||||
#endif
|
||||
|
||||
// Chunked save state
|
||||
// Stored as a pointer (allocated in beginSaveContacts, freed in
|
||||
// finishSaveContacts) because Adafruit_LittleFS::File has no default
|
||||
// constructor — we can't keep one as a default-initialized value member.
|
||||
File* _saveFile = nullptr;
|
||||
DataStoreHost* _saveHost = nullptr;
|
||||
uint32_t _saveIdx = 0;
|
||||
uint32_t _saveRecordsWritten = 0;
|
||||
bool _saveInProgress = false;
|
||||
bool _saveWriteOk = true;
|
||||
|
||||
public:
|
||||
DataStore(FILESYSTEM& fs, mesh::RTCClock& clock);
|
||||
DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock);
|
||||
@@ -37,6 +48,14 @@ public:
|
||||
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
|
||||
void loadContacts(DataStoreHost* host);
|
||||
void saveContacts(DataStoreHost* host);
|
||||
// Chunked save — splits contact write across multiple loop iterations
|
||||
// to prevent blocking the main loop for 500ms+ on large contact lists.
|
||||
// Call beginSaveContacts(), then saveContactsChunk() each loop until it
|
||||
// returns false (done), then finishSaveContacts() to verify and commit.
|
||||
bool beginSaveContacts(DataStoreHost* host);
|
||||
bool saveContactsChunk(int batchSize = 20); // returns true if more to write
|
||||
void finishSaveContacts();
|
||||
bool isSaveInProgress() const { return _saveInProgress; }
|
||||
void loadChannels(DataStoreHost* host);
|
||||
void saveChannels(DataStoreHost* host);
|
||||
void migrateToSecondaryFS();
|
||||
@@ -51,4 +70,4 @@ public:
|
||||
|
||||
private:
|
||||
FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;};
|
||||
};
|
||||
};
|
||||
@@ -4,10 +4,21 @@
|
||||
#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
|
||||
|
||||
// Fallback for variants that don't define GPS_BAUDRATE (HAS_GPS=0 boards like
|
||||
// Heltec Meshpocket). Used in CLI "get/set gps.baud" handlers as the default
|
||||
// when node prefs haven't been configured. Zero means "not applicable".
|
||||
#ifndef GPS_BAUDRATE
|
||||
#define GPS_BAUDRATE 0
|
||||
#endif
|
||||
|
||||
#define CMD_APP_START 1
|
||||
#define CMD_SEND_TXT_MSG 2
|
||||
#define CMD_SEND_CHANNEL_TXT_MSG 3
|
||||
@@ -162,7 +173,7 @@ void MyMesh::writeDisabledFrame() {
|
||||
_serial->writeFrame(buf, 1);
|
||||
}
|
||||
|
||||
void MyMesh::writeContactRespFrame(uint8_t code, const ContactInfo &contact) {
|
||||
size_t MyMesh::writeContactRespFrame(uint8_t code, const ContactInfo &contact) {
|
||||
int i = 0;
|
||||
out_frame[i++] = code;
|
||||
memcpy(&out_frame[i], contact.id.pub_key, PUB_KEY_SIZE);
|
||||
@@ -182,7 +193,7 @@ void MyMesh::writeContactRespFrame(uint8_t code, const ContactInfo &contact) {
|
||||
i += 4;
|
||||
memcpy(&out_frame[i], &contact.lastmod, 4);
|
||||
i += 4;
|
||||
_serial->writeFrame(out_frame, i);
|
||||
return _serial->writeFrame(out_frame, i);
|
||||
}
|
||||
|
||||
void MyMesh::updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len) {
|
||||
@@ -257,7 +268,17 @@ float MyMesh::getAirtimeBudgetFactor() const {
|
||||
}
|
||||
|
||||
int MyMesh::getInterferenceThreshold() const {
|
||||
return 0; // disabled for now, until currentRSSI() problem is resolved
|
||||
return _prefs.interference_threshold;
|
||||
}
|
||||
|
||||
uint8_t MyMesh::getTxFailResetThreshold() const {
|
||||
return _prefs.tx_fail_reset_threshold;
|
||||
}
|
||||
uint8_t MyMesh::getRxFailRebootThreshold() const {
|
||||
return _prefs.rx_fail_reboot_threshold;
|
||||
}
|
||||
void MyMesh::onRxUnrecoverable() {
|
||||
board.reboot();
|
||||
}
|
||||
|
||||
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
|
||||
@@ -301,6 +322,7 @@ bool MyMesh::isAutoAddEnabled() const {
|
||||
}
|
||||
|
||||
bool MyMesh::shouldAutoAddContactType(uint8_t contact_type) const {
|
||||
if (_forceNextImport) return true; // explicit user add from Last Heard / Discovery
|
||||
if ((_prefs.manual_add_contacts & 1) == 0) {
|
||||
return true;
|
||||
}
|
||||
@@ -346,6 +368,7 @@ void MyMesh::onContactsFull() {
|
||||
}
|
||||
|
||||
void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) {
|
||||
_forceNextImport = false; // clear force-add flag (set by forceImportContact)
|
||||
if (_serial->isConnected()) {
|
||||
if (is_new) {
|
||||
writeContactRespFrame(PUSH_CODE_NEW_ADVERT, contact);
|
||||
@@ -376,6 +399,7 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path
|
||||
|
||||
memcpy(p->pubkey_prefix, contact.id.pub_key, sizeof(p->pubkey_prefix));
|
||||
strcpy(p->name, contact.name);
|
||||
p->type = contact.type;
|
||||
p->recv_timestamp = getRTCClock()->getCurrentTime();
|
||||
p->path_len = mesh::Packet::copyPath(p->path, path, path_len);
|
||||
}
|
||||
@@ -423,6 +447,10 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) {
|
||||
return max_num;
|
||||
}
|
||||
|
||||
void MyMesh::scheduleLazyContactSave() {
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
}
|
||||
|
||||
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
|
||||
out_frame[0] = PUSH_CODE_PATH_UPDATED;
|
||||
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
|
||||
@@ -489,7 +517,24 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
||||
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
|
||||
if (should_display && _ui) {
|
||||
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
|
||||
// For signed messages (room server posts): the extra bytes contain the
|
||||
// original poster's pub_key prefix. Look up their name and format as
|
||||
// "PosterName: message" so the UI shows who actually wrote it.
|
||||
if (txt_type == TXT_TYPE_SIGNED_PLAIN && extra && extra_len >= 4) {
|
||||
ContactInfo* poster = lookupContactByPubKey(extra, extra_len);
|
||||
if (poster) {
|
||||
char formatted[MAX_PACKET_PAYLOAD];
|
||||
snprintf(formatted, sizeof(formatted), "%s: %s", poster->name, text);
|
||||
_ui->newMsg(path_len, from.name, formatted, offline_queue_len, msg_path, pkt->_snr);
|
||||
} else {
|
||||
// Poster not in contacts — show raw text (no name prefix)
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
}
|
||||
} else {
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
}
|
||||
|
||||
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
|
||||
}
|
||||
#endif
|
||||
@@ -534,12 +579,12 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
|
||||
recipient.name, delay_millis, _prefs.path_hash_mode, _prefs.path_hash_mode + 1);
|
||||
// TODO: dynamic send_scope, depending on recipient and current 'home' Region
|
||||
if (send_scope.isNull()) {
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
} else {
|
||||
uint16_t codes[2];
|
||||
codes[0] = send_scope.calcTransportCode(pkt);
|
||||
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
|
||||
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, codes, delay_millis, getPathHashSize());
|
||||
}
|
||||
}
|
||||
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
@@ -556,18 +601,25 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
|
||||
|
||||
// TODO: have per-channel send_scope
|
||||
if (send_scope.isNull()) {
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
} else {
|
||||
uint16_t codes[2];
|
||||
codes[0] = send_scope.calcTransportCode(pkt);
|
||||
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
|
||||
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, codes, delay_millis, getPathHashSize());
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
const char *text) {
|
||||
markConnectionActive(from); // in case this is from a server, and we have a connection
|
||||
|
||||
// Detect VE3 voice envelope and notify voice handler
|
||||
if (_voiceEnvHandler && text && strncmp(text, "VE3:", 4) == 0) {
|
||||
MESH_DEBUG_PRINTLN("Voice: VE3 envelope from %s: %s", from.name, text);
|
||||
_voiceEnvHandler(from.name, text);
|
||||
}
|
||||
|
||||
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text);
|
||||
}
|
||||
|
||||
@@ -710,6 +762,31 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiSendRawToContact(uint32_t contact_idx, const uint8_t* data, uint8_t len) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
|
||||
// Raw custom packets are direct-route only — cannot flood
|
||||
if (recipient->out_path_len == OUT_PATH_UNKNOWN) {
|
||||
MESH_DEBUG_PRINTLN("UI: Raw send to %s failed — no direct path", recipient->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
mesh::Packet* pkt = createRawData(data, len);
|
||||
if (!pkt) {
|
||||
MESH_DEBUG_PRINTLN("UI: Raw send to %s failed — packet pool empty", recipient->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
sendDirect(pkt, recipient->out_path, recipient->out_path_len);
|
||||
MESH_DEBUG_PRINTLN("UI: Raw sent %d bytes to %s (direct, path_len=0x%02X)",
|
||||
len, recipient->name, recipient->out_path_len);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) {
|
||||
@@ -728,6 +805,13 @@ bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password, uint3
|
||||
uint8_t save_path_len = recipient->out_path_len;
|
||||
recipient->out_path_len = OUT_PATH_UNKNOWN;
|
||||
|
||||
// For room servers: reset sync_since to zero so the server pushes ALL posts.
|
||||
// The device has no persistent DM storage, so every session needs full history.
|
||||
// sync_since naturally updates as messages arrive (BaseChatMesh::onPeerDataRecv).
|
||||
if (recipient->type == ADV_TYPE_ROOM) {
|
||||
recipient->sync_since = 0;
|
||||
}
|
||||
|
||||
Serial.printf("[uiLogin] Sending login to '%s' (idx=%d, path was 0x%02X, now 0x%02X, hash_mode=%d)\n",
|
||||
recipient->name, contact_idx, save_path_len, recipient->out_path_len, _prefs.path_hash_mode);
|
||||
|
||||
@@ -798,6 +882,43 @@ bool MyMesh::uiSendTelemetryRequest(uint32_t contact_idx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::setCustomPath(int contactIdx, const uint8_t* path, uint8_t pathLen, bool lock) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contactIdx, contact)) return false;
|
||||
|
||||
ContactInfo* c = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!c) return false;
|
||||
|
||||
c->out_path_len = pathLen;
|
||||
int byteLen = mesh::Packet::getPathByteLenFor(pathLen);
|
||||
if (byteLen > MAX_PATH_SIZE) byteLen = MAX_PATH_SIZE;
|
||||
memcpy(c->out_path, path, byteLen);
|
||||
c->lastmod = getRTCClock()->getCurrentTime();
|
||||
|
||||
if (lock) {
|
||||
c->flags |= CONTACT_FLAG_CUSTOM_PATH;
|
||||
}
|
||||
|
||||
MESH_DEBUG_PRINTLN("setCustomPath: contact %s, pathLen=0x%02X (%d hops, %dB/hop), lock=%d",
|
||||
c->name, pathLen, pathLen & 0x3F, ((pathLen >> 6) & 3) + 1, lock);
|
||||
return true;
|
||||
}
|
||||
|
||||
void MyMesh::clearCustomPath(int contactIdx) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contactIdx, contact)) return;
|
||||
|
||||
ContactInfo* c = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!c) return;
|
||||
|
||||
c->out_path_len = OUT_PATH_UNKNOWN;
|
||||
memset(c->out_path, 0, MAX_PATH_SIZE);
|
||||
c->flags &= ~CONTACT_FLAG_CUSTOM_PATH;
|
||||
c->lastmod = getRTCClock()->getCurrentTime();
|
||||
|
||||
MESH_DEBUG_PRINTLN("clearCustomPath: contact %s — reverted to auto-discovery", c->name);
|
||||
}
|
||||
|
||||
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
|
||||
uint8_t len, uint8_t *reply) {
|
||||
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
|
||||
@@ -968,6 +1089,13 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
|
||||
}
|
||||
}
|
||||
// let base class handle received path and data
|
||||
// BUT: if this contact has a custom (manually set) path lock, don't let
|
||||
// auto-discovery overwrite it. Skip the base class call entirely — ACKs
|
||||
// embedded in path responses will still be delivered via separate ACK packets.
|
||||
if (contact.flags & CONTACT_FLAG_CUSTOM_PATH) {
|
||||
MESH_DEBUG_PRINTLN("onContactPathRecv: skipping path update for custom-path contact %s", contact.name);
|
||||
return false;
|
||||
}
|
||||
return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len);
|
||||
}
|
||||
|
||||
@@ -1052,6 +1180,32 @@ void MyMesh::onRawDataRecv(mesh::Packet *packet) {
|
||||
MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log ALL incoming raw packets for diagnosis
|
||||
Serial.printf("onRawDataRecv: len=%d, magic=0x%02X, route=%s\n",
|
||||
packet->payload_len,
|
||||
packet->payload_len > 0 ? packet->payload[0] : 0,
|
||||
packet->isRouteDirect() ? "direct" : "flood");
|
||||
|
||||
// Voice-over-LoRa (dz0ny VE3 protocol): intercept voice packets and fetch requests
|
||||
// before forwarding to BLE companion. In standalone mode (no BLE), this is the
|
||||
// only way to handle them. In BLE mode, we still intercept so on-device voice works.
|
||||
if (packet->payload_len > 1 && _voiceHandler) {
|
||||
uint8_t magic = packet->payload[0];
|
||||
if (magic == 0x56 || magic == 0x72) { // Voice data (V) or fetch request (r)
|
||||
Serial.printf("onRawDataRecv: voice %s, payload_len=%d, first6=[%02X %02X %02X %02X %02X %02X]\n",
|
||||
magic == 0x56 ? "PKT" : "FETCH", packet->payload_len,
|
||||
packet->payload[0],
|
||||
packet->payload_len > 1 ? packet->payload[1] : 0,
|
||||
packet->payload_len > 2 ? packet->payload[2] : 0,
|
||||
packet->payload_len > 3 ? packet->payload[3] : 0,
|
||||
packet->payload_len > 4 ? packet->payload[4] : 0,
|
||||
packet->payload_len > 5 ? packet->payload[5] : 0);
|
||||
_voiceHandler(magic, packet->payload, packet->payload_len);
|
||||
// Don't return — still forward to BLE companion if connected
|
||||
}
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
out_frame[i++] = PUSH_CODE_RAW_DATA;
|
||||
out_frame[i++] = (int8_t)(_radio->getLastSNR() * 4);
|
||||
@@ -1120,7 +1274,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
next_ack_idx = 0;
|
||||
sign_data = NULL;
|
||||
dirty_contacts_expiry = 0;
|
||||
memset(advert_paths, 0, sizeof(advert_paths));
|
||||
advert_paths = nullptr; // PSRAM-allocated in begin()
|
||||
memset(send_scope.key, 0, sizeof(send_scope.key));
|
||||
memset(_sent_track, 0, sizeof(_sent_track));
|
||||
_sent_track_idx = 0;
|
||||
@@ -1133,6 +1287,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
_prefs.airtime_factor = 1.0; // one half
|
||||
_prefs.multi_acks = 1; // redundant ACKs on by default
|
||||
strcpy(_prefs.node_name, "NONAME");
|
||||
_prefs.freq = LORA_FREQ;
|
||||
_prefs.sf = LORA_SF;
|
||||
@@ -1146,16 +1301,34 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
}
|
||||
|
||||
void MyMesh::begin(bool has_display) {
|
||||
#if defined(ESP32)
|
||||
// ESP32 variants have PSRAM — allocate the large advert path table there
|
||||
advert_paths = (AdvertPath*)ps_calloc(ADVERT_PATH_TABLE_SIZE, sizeof(AdvertPath));
|
||||
#else
|
||||
// nRF52 / other non-PSRAM platforms — fall back to regular heap. Table size
|
||||
// is smaller on these platforms (see ADVERT_PATH_TABLE_SIZE in MyMesh.h) to
|
||||
// avoid blowing the limited SRAM budget.
|
||||
advert_paths = (AdvertPath*)calloc(ADVERT_PATH_TABLE_SIZE, sizeof(AdvertPath));
|
||||
#endif
|
||||
BaseChatMesh::begin();
|
||||
|
||||
if (!_store->loadMainIdentity(self_id)) {
|
||||
Serial.println("[ID] loadMainIdentity FAILED — generating new identity");
|
||||
self_id = radio_new_identity(); // create new random identity
|
||||
int count = 0;
|
||||
while (count < 10 && (self_id.pub_key[0] == 0x00 || self_id.pub_key[0] == 0xFF)) { // reserved id hashes
|
||||
self_id = radio_new_identity();
|
||||
count++;
|
||||
}
|
||||
_store->saveMainIdentity(self_id);
|
||||
bool ok = _store->saveMainIdentity(self_id);
|
||||
Serial.printf("[ID] saveMainIdentity returned %d\n", ok ? 1 : 0);
|
||||
} else {
|
||||
Serial.println("[ID] loadMainIdentity OK — using persisted identity");
|
||||
}
|
||||
{
|
||||
char hex[10];
|
||||
mesh::Utils::toHex(hex, self_id.pub_key, 4);
|
||||
Serial.printf("[ID] pub_key[0..3] = %s\n", hex);
|
||||
}
|
||||
|
||||
// if name is provided as a build flag, use that as default node name instead
|
||||
@@ -1183,6 +1356,30 @@ void MyMesh::begin(bool has_display) {
|
||||
_prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1
|
||||
_prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours
|
||||
_prefs.utc_offset_hours = constrain(_prefs.utc_offset_hours, -12, 14); // Valid timezone range
|
||||
// gps_baudrate: 0 means use compile-time default; validate known rates
|
||||
if (_prefs.gps_baudrate != 0 && _prefs.gps_baudrate != 4800 &&
|
||||
_prefs.gps_baudrate != 9600 && _prefs.gps_baudrate != 19200 &&
|
||||
_prefs.gps_baudrate != 38400 && _prefs.gps_baudrate != 57600 &&
|
||||
_prefs.gps_baudrate != 115200) {
|
||||
Serial.printf("PREFS: invalid gps_baudrate=%lu — reset to 0 (default)\n",
|
||||
(unsigned long)_prefs.gps_baudrate);
|
||||
_prefs.gps_baudrate = 0; // reset to default if invalid
|
||||
}
|
||||
// interference_threshold: 0 = disabled, minimum functional value is 14, max sane ~30
|
||||
if (_prefs.interference_threshold > 0 && _prefs.interference_threshold < 14) {
|
||||
_prefs.interference_threshold = 0;
|
||||
}
|
||||
if (_prefs.interference_threshold > 50) {
|
||||
Serial.printf("PREFS: invalid interference_threshold=%d — reset to 0 (disabled)\n",
|
||||
_prefs.interference_threshold);
|
||||
_prefs.interference_threshold = 0; // garbage from prefs upgrade — disable
|
||||
}
|
||||
// Clamp remaining v1.0 fields that may contain garbage after upgrade from older firmware
|
||||
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
|
||||
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
|
||||
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
|
||||
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
|
||||
if (_prefs.hint_shown > 1) _prefs.hint_shown = 0;
|
||||
|
||||
#ifdef BLE_PIN_CODE // 123456 by default
|
||||
if (_prefs.ble_pin == 0) {
|
||||
@@ -1230,6 +1427,7 @@ void MyMesh::startInterface(BaseSerialInterface &serial) {
|
||||
}
|
||||
|
||||
void MyMesh::handleCmdFrame(size_t len) {
|
||||
Serial.printf("[CMD] rx opcode=0x%02X len=%d\n", cmd_frame[0], (int)len);
|
||||
if (cmd_frame[0] == CMD_DEVICE_QEURY && len >= 2) { // sent when app establishes connection
|
||||
app_target_ver = cmd_frame[1]; // which version of protocol does app understand
|
||||
|
||||
@@ -1432,7 +1630,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
if (pkt) {
|
||||
if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop)
|
||||
unsigned long delay_millis = 0;
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
} else {
|
||||
sendZeroHop(pkt);
|
||||
}
|
||||
@@ -1532,12 +1730,19 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
}
|
||||
}
|
||||
} else if (cmd_frame[0] == CMD_IMPORT_CONTACT && len > 2 + 32 + 64) {
|
||||
Serial.printf("[IMP] CMD_IMPORT_CONTACT received, len=%d\n", len);
|
||||
if (importContact(&cmd_frame[1], len - 1)) {
|
||||
Serial.println("[IMP] importContact OK, scheduling dirty flush");
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
writeOKFrame();
|
||||
} else {
|
||||
Serial.println("[IMP] importContact REJECTED by BaseChatMesh");
|
||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
||||
}
|
||||
} else if (cmd_frame[0] == CMD_IMPORT_CONTACT) {
|
||||
Serial.printf("[IMP] CMD_IMPORT_CONTACT dropped — len=%d too short (need >%d)\n",
|
||||
len, 2 + 32 + 64);
|
||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
||||
} else if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE) {
|
||||
int out_len;
|
||||
if ((out_len = getFromOfflineQueue(out_frame)) > 0) {
|
||||
@@ -1555,6 +1760,13 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
uint8_t ch_idx = is_v3_ch ? out_frame[4] : out_frame[1];
|
||||
_ui->markChannelReadFromBLE(ch_idx);
|
||||
}
|
||||
|
||||
// Mark DM slot read when companion app syncs a contact (DM/room) message
|
||||
bool is_v3_dm = (out_frame[0] == RESP_CODE_CONTACT_MSG_RECV_V3);
|
||||
bool is_old_dm = (out_frame[0] == RESP_CODE_CONTACT_MSG_RECV);
|
||||
if (is_v3_dm || is_old_dm) {
|
||||
_ui->markChannelReadFromBLE(0xFF);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
@@ -1667,23 +1879,29 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
writeDisabledFrame();
|
||||
#endif
|
||||
} else if (cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY && len >= 65) {
|
||||
Serial.printf("[PK] CMD_IMPORT_PRIVATE_KEY received, len=%d\n", (int)len);
|
||||
#if ENABLE_PRIVATE_KEY_IMPORT
|
||||
if (!mesh::LocalIdentity::validatePrivateKey(&cmd_frame[1])) {
|
||||
Serial.println("[PK] validatePrivateKey FAILED — key bytes rejected");
|
||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid key
|
||||
} else {
|
||||
Serial.println("[PK] validatePrivateKey OK — attempting save");
|
||||
mesh::LocalIdentity identity;
|
||||
identity.readFrom(&cmd_frame[1], 64);
|
||||
if (_store->saveMainIdentity(identity)) {
|
||||
Serial.println("[PK] saveMainIdentity OK");
|
||||
self_id = identity;
|
||||
writeOKFrame();
|
||||
// re-load contacts, to invalidate ecdh shared_secrets
|
||||
resetContacts();
|
||||
_store->loadContacts(this);
|
||||
} else {
|
||||
Serial.println("[PK] saveMainIdentity FAILED");
|
||||
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
||||
}
|
||||
}
|
||||
#else
|
||||
Serial.println("[PK] ENABLE_PRIVATE_KEY_IMPORT not defined — responding DISABLED");
|
||||
writeDisabledFrame();
|
||||
#endif
|
||||
} else if (cmd_frame[0] == CMD_SEND_RAW_DATA && len >= 6) {
|
||||
@@ -1878,17 +2096,21 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND);
|
||||
}
|
||||
} else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32) {
|
||||
Serial.printf("[CH] CMD_SET_CHANNEL 256-bit secret not supported (len=%d)\n", len);
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // not supported (yet)
|
||||
} else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) {
|
||||
uint8_t channel_idx = cmd_frame[1];
|
||||
Serial.printf("[CH] CMD_SET_CHANNEL idx=%d len=%d\n", channel_idx, len);
|
||||
ChannelDetails channel;
|
||||
StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32);
|
||||
memset(channel.channel.secret, 0, sizeof(channel.channel.secret));
|
||||
memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported
|
||||
if (setChannel(channel_idx, channel)) {
|
||||
Serial.println("[CH] setChannel OK, calling saveChannels");
|
||||
saveChannels();
|
||||
writeOKFrame();
|
||||
} else {
|
||||
Serial.printf("[CH] setChannel REJECTED (bad idx=%d)\n", channel_idx);
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
||||
}
|
||||
} else if (cmd_frame[0] == CMD_SIGN_START) {
|
||||
@@ -2118,6 +2340,8 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
_serial->writeFrame(out_frame, i);
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
Serial.printf("[CMD] UNKNOWN opcode=0x%02X len=%d — responded UNSUPPORTED\n",
|
||||
cmd_frame[0], (int)len);
|
||||
MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]);
|
||||
}
|
||||
}
|
||||
@@ -2130,20 +2354,26 @@ void MyMesh::enterCLIRescue() {
|
||||
|
||||
void MyMesh::checkCLIRescueCmd() {
|
||||
int len = strlen(cli_command);
|
||||
bool line_complete = false;
|
||||
while (Serial.available() && len < sizeof(cli_command)-1) {
|
||||
char c = Serial.read();
|
||||
if (c != '\n') {
|
||||
cli_command[len++] = c;
|
||||
cli_command[len] = 0;
|
||||
if (c == '\r' || c == '\n') {
|
||||
if (len > 0) {
|
||||
line_complete = true;
|
||||
Serial.println(); // echo newline
|
||||
}
|
||||
break; // stop reading — remaining LF (from CR+LF) is consumed next loop
|
||||
}
|
||||
cli_command[len++] = c;
|
||||
cli_command[len] = 0;
|
||||
Serial.print(c); // echo
|
||||
}
|
||||
if (len == sizeof(cli_command)-1) { // command buffer full
|
||||
cli_command[sizeof(cli_command)-1] = '\r';
|
||||
if (len == sizeof(cli_command)-1) { // buffer full — force processing
|
||||
line_complete = true;
|
||||
}
|
||||
|
||||
if (len > 0 && cli_command[len - 1] == '\r') { // received complete line
|
||||
cli_command[len - 1] = 0; // replace newline with C string null terminator
|
||||
if (line_complete && len > 0) {
|
||||
cli_command[len] = 0; // ensure null terminated
|
||||
|
||||
// =====================================================================
|
||||
// GET commands — read settings
|
||||
@@ -2174,6 +2404,25 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
_prefs.gps_enabled ? "on" : "off", _prefs.gps_interval);
|
||||
} else if (strcmp(key, "pin") == 0) {
|
||||
Serial.printf(" > %06d\n", _prefs.ble_pin);
|
||||
|
||||
// --- Mesh tuning parameters ---
|
||||
} else if (strcmp(key, "rxdelay") == 0) {
|
||||
Serial.printf(" > %.1f\n", _prefs.rx_delay_base);
|
||||
} else if (strcmp(key, "af") == 0) {
|
||||
Serial.printf(" > %.1f\n", _prefs.airtime_factor);
|
||||
} else if (strcmp(key, "multi.acks") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.multi_acks);
|
||||
} else if (strcmp(key, "int.thresh") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.interference_threshold);
|
||||
} else if (strcmp(key, "tx.fail.threshold") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.tx_fail_reset_threshold);
|
||||
} else if (strcmp(key, "rx.fail.threshold") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.rx_fail_reboot_threshold);
|
||||
} else if (strcmp(key, "gps.baud") == 0) {
|
||||
uint32_t effective = _prefs.gps_baudrate ? _prefs.gps_baudrate : GPS_BAUDRATE;
|
||||
Serial.printf(" > %lu (effective: %lu)\n",
|
||||
(unsigned long)_prefs.gps_baudrate, (unsigned long)effective);
|
||||
|
||||
} else if (strcmp(key, "radio") == 0) {
|
||||
Serial.printf(" > freq=%.3f bw=%.1f sf=%d cr=%d tx=%d\n",
|
||||
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
|
||||
@@ -2225,6 +2474,16 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.printf(" gps: %s (interval: %ds)\n",
|
||||
_prefs.gps_enabled ? "on" : "off", _prefs.gps_interval);
|
||||
Serial.printf(" pin: %06d\n", _prefs.ble_pin);
|
||||
Serial.printf(" rxdelay: %.1f\n", _prefs.rx_delay_base);
|
||||
Serial.printf(" af: %.1f\n", _prefs.airtime_factor);
|
||||
Serial.printf(" multi.acks: %d\n", _prefs.multi_acks);
|
||||
Serial.printf(" int.thresh: %d\n", _prefs.interference_threshold);
|
||||
Serial.printf(" tx.fail: %d\n", _prefs.tx_fail_reset_threshold);
|
||||
Serial.printf(" rx.fail: %d\n", _prefs.rx_fail_reboot_threshold);
|
||||
{
|
||||
uint32_t eff_baud = _prefs.gps_baudrate ? _prefs.gps_baudrate : GPS_BAUDRATE;
|
||||
Serial.printf(" gps.baud: %lu\n", (unsigned long)eff_baud);
|
||||
}
|
||||
#ifdef HAS_4G_MODEM
|
||||
Serial.printf(" modem: %s\n", ModemManager::loadEnabledConfig() ? "on" : "off");
|
||||
Serial.printf(" apn: %s\n", modemManager.getAPN());
|
||||
@@ -2245,6 +2504,14 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
char hex[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(hex, self_id.pub_key, PUB_KEY_SIZE);
|
||||
Serial.printf(" pubkey: %s\n", hex);
|
||||
{
|
||||
uint32_t clk = getRTCClock()->getCurrentTime();
|
||||
if (clk > 1704067200UL) {
|
||||
Serial.printf(" clock: %lu (valid)\n", (unsigned long)clk);
|
||||
} else {
|
||||
Serial.printf(" clock: not set\n");
|
||||
}
|
||||
}
|
||||
// List channels
|
||||
Serial.println(" channels:");
|
||||
bool chFound = false;
|
||||
@@ -2558,10 +2825,160 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.println(" > modem disabled");
|
||||
#endif
|
||||
|
||||
// --- Mesh tuning parameters ---
|
||||
} else if (memcmp(config, "rxdelay ", 8) == 0) {
|
||||
float val = atof(&config[8]);
|
||||
if (val >= 0.0f && val <= 20.0f) {
|
||||
_prefs.rx_delay_base = val;
|
||||
savePrefs();
|
||||
Serial.printf(" > rxdelay = %.1f\n", _prefs.rx_delay_base);
|
||||
} else {
|
||||
Serial.println(" Error: rxdelay out of range (0-20)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "af ", 3) == 0) {
|
||||
float val = atof(&config[3]);
|
||||
if (val >= 0.0f && val <= 9.0f) {
|
||||
_prefs.airtime_factor = val;
|
||||
savePrefs();
|
||||
Serial.printf(" > af = %.1f\n", _prefs.airtime_factor);
|
||||
} else {
|
||||
Serial.println(" Error: af out of range (0-9)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "multi.acks ", 11) == 0) {
|
||||
int val = atoi(&config[11]);
|
||||
if (val == 0 || val == 1) {
|
||||
_prefs.multi_acks = (uint8_t)val;
|
||||
savePrefs();
|
||||
Serial.printf(" > multi.acks = %d\n", _prefs.multi_acks);
|
||||
} else {
|
||||
Serial.println(" Error: use 0 or 1");
|
||||
}
|
||||
|
||||
// Interference threshold — not recommended unless the device is in a high
|
||||
// RF interference environment (low noise floor with significant fluctuations).
|
||||
// Enabling adds ~4s receive delay per packet for channel activity scanning.
|
||||
} else if (memcmp(config, "int.thresh ", 11) == 0) {
|
||||
int val = atoi(&config[11]);
|
||||
if (val == 0) {
|
||||
_prefs.interference_threshold = 0;
|
||||
savePrefs();
|
||||
Serial.println(" > int.thresh = 0 (disabled)");
|
||||
} else if (val >= 14 && val <= 255) {
|
||||
_prefs.interference_threshold = (uint8_t)val;
|
||||
savePrefs();
|
||||
Serial.printf(" > int.thresh = %d (enabled — adds ~4s rx delay)\n",
|
||||
_prefs.interference_threshold);
|
||||
Serial.println(" Note: only recommended for high RF interference environments");
|
||||
} else {
|
||||
Serial.println(" Error: use 0 (disabled) or 14+ (typical: 14)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "tx.fail.threshold ", 18) == 0) {
|
||||
int val = atoi(&config[18]);
|
||||
if (val < 0) val = 0;
|
||||
if (val > 10) val = 10;
|
||||
_prefs.tx_fail_reset_threshold = (uint8_t)val;
|
||||
savePrefs();
|
||||
if (val == 0) {
|
||||
Serial.println(" > tx fail reset disabled");
|
||||
} else {
|
||||
Serial.printf(" > tx fail reset after %d failures\n", val);
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "rx.fail.threshold ", 18) == 0) {
|
||||
int val = atoi(&config[18]);
|
||||
if (val < 0) val = 0;
|
||||
if (val > 10) val = 10;
|
||||
_prefs.rx_fail_reboot_threshold = (uint8_t)val;
|
||||
savePrefs();
|
||||
if (val == 0) {
|
||||
Serial.println(" > rx fail reboot disabled");
|
||||
} else {
|
||||
Serial.printf(" > reboot after %d rx recovery failures\n", val);
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "gps.baud ", 9) == 0) {
|
||||
uint32_t val = (uint32_t)atol(&config[9]);
|
||||
if (val == 0 || val == 4800 || val == 9600 || val == 19200 ||
|
||||
val == 38400 || val == 57600 || val == 115200) {
|
||||
_prefs.gps_baudrate = val;
|
||||
savePrefs();
|
||||
uint32_t effective = val ? val : GPS_BAUDRATE;
|
||||
Serial.printf(" > gps.baud = %lu (effective: %lu, reboot to apply)\n",
|
||||
(unsigned long)val, (unsigned long)effective);
|
||||
} else {
|
||||
Serial.println(" Error: use 0 (default), 4800, 9600, 19200, 38400, 57600, or 115200");
|
||||
}
|
||||
|
||||
// Backlight control (T5S3 E-Paper Pro only)
|
||||
} else if (memcmp(config, "backlight ", 10) == 0) {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const char* val = &config[10];
|
||||
if (strcmp(val, "on") == 0) {
|
||||
board.setBacklight(true);
|
||||
Serial.println(" > backlight ON");
|
||||
} else if (strcmp(val, "off") == 0) {
|
||||
board.setBacklight(false);
|
||||
Serial.println(" > backlight OFF");
|
||||
} else {
|
||||
int brightness = atoi(val);
|
||||
if (brightness >= 0 && brightness <= 255) {
|
||||
board.setBacklightBrightness((uint8_t)brightness);
|
||||
board.setBacklight(brightness > 0);
|
||||
Serial.printf(" > backlight brightness = %d\n", brightness);
|
||||
} else {
|
||||
Serial.println(" Error: use 'on', 'off', or 0-255");
|
||||
}
|
||||
}
|
||||
#else
|
||||
Serial.println(" Error: backlight not available on this device");
|
||||
#endif
|
||||
|
||||
} else {
|
||||
Serial.printf(" Error: unknown setting '%s' (try 'help')\n", config);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// CLOCK commands (standalone — matches repeater admin convention)
|
||||
// =====================================================================
|
||||
} else if (memcmp(cli_command, "clock sync ", 11) == 0) {
|
||||
uint32_t epoch = (uint32_t)strtoul(&cli_command[11], nullptr, 10);
|
||||
if (epoch > 1704067200UL && epoch < 2082758400UL) {
|
||||
getRTCClock()->setCurrentTime(epoch);
|
||||
Serial.printf(" > clock synced to %lu\n", (unsigned long)epoch);
|
||||
} else {
|
||||
Serial.println(" Error: invalid epoch (must be 2024-2036 range)");
|
||||
Serial.println(" Hint: on macOS/Linux run: date +%s");
|
||||
}
|
||||
} else if (strcmp(cli_command, "clock sync") == 0) {
|
||||
// Bare "clock sync" without a value — show usage
|
||||
Serial.println(" Usage: clock sync <unix_epoch>");
|
||||
Serial.println(" Hint: clock sync $(date +%s)");
|
||||
} else if (strcmp(cli_command, "clock") == 0) {
|
||||
uint32_t t = getRTCClock()->getCurrentTime();
|
||||
if (t > 1704067200UL) {
|
||||
// Break epoch into human-readable UTC
|
||||
uint32_t ep = t;
|
||||
int s = ep % 60; ep /= 60;
|
||||
int mi = ep % 60; ep /= 60;
|
||||
int h = ep % 24; ep /= 24;
|
||||
int yr = 1970;
|
||||
while (true) { int d = ((yr%4==0&&yr%100!=0)||yr%400==0)?366:365; if(ep<(uint32_t)d) break; ep-=d; yr++; }
|
||||
int mo = 1;
|
||||
while (true) {
|
||||
static const uint8_t dm[]={31,28,31,30,31,30,31,31,30,31,30,31};
|
||||
int d = (mo==2&&((yr%4==0&&yr%100!=0)||yr%400==0))?29:dm[mo-1];
|
||||
if(ep<(uint32_t)d) break; ep-=d; mo++;
|
||||
}
|
||||
int dy = ep + 1;
|
||||
Serial.printf(" > %04d-%02d-%02d %02d:%02d:%02d UTC (epoch: %lu)\n",
|
||||
yr, mo, dy, h, mi, s, (unsigned long)t);
|
||||
} else {
|
||||
Serial.printf(" > not set (epoch: %lu)\n", (unsigned long)t);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// HELP command
|
||||
// =====================================================================
|
||||
@@ -2574,6 +2991,20 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.println(" name, freq, bw, sf, cr, tx, utc, notify, pin");
|
||||
Serial.println(" path.hash.mode Path hash size (0=1B, 1=2B, 2=3B)");
|
||||
Serial.println("");
|
||||
Serial.println(" Mesh tuning:");
|
||||
Serial.println(" rxdelay <0-20> Rx delay base (0=disabled)");
|
||||
Serial.println(" af <0-9> Airtime factor");
|
||||
Serial.println(" multi.acks <0|1> Redundant ACKs (default: 1)");
|
||||
Serial.println(" int.thresh <0|14+> Interference threshold dB (0=off, 14=typical)");
|
||||
Serial.println(" tx.fail.threshold <0-10> TX fail radio reset (0=off, default 3)");
|
||||
Serial.println(" rx.fail.threshold <0-10> RX stuck reboot (0=off, default 3)");
|
||||
Serial.println(" gps.baud <rate> GPS baud (0=default, reboot to apply)");
|
||||
Serial.println("");
|
||||
Serial.println(" Clock:");
|
||||
Serial.println(" clock Show current RTC time (UTC)");
|
||||
Serial.println(" clock sync <epoch> Set RTC from Unix timestamp");
|
||||
Serial.println(" Hint: clock sync $(date +%s)");
|
||||
Serial.println("");
|
||||
Serial.println(" Compound commands:");
|
||||
Serial.println(" get all Dump all settings");
|
||||
Serial.println(" get radio Show all radio params");
|
||||
@@ -2596,6 +3027,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)
|
||||
@@ -2753,20 +3189,29 @@ void MyMesh::checkSerialInterface() {
|
||||
} else if (_iter_started // check if our ContactsIterator is 'running'
|
||||
&& !_serial->isWriteBusy() // don't spam the Serial Interface too quickly!
|
||||
) {
|
||||
// Batch-fill: queue multiple contacts per loop iteration so the BLE
|
||||
// send queue stays saturated during sync. writeFrame() returns 0
|
||||
// when the queue is full, which naturally throttles us.
|
||||
ContactInfo contact;
|
||||
if (_iter.hasNext(this, contact)) {
|
||||
if (contact.lastmod > _iter_filter_since) { // apply the 'since' filter
|
||||
writeContactRespFrame(RESP_CODE_CONTACT, contact);
|
||||
if (contact.lastmod > _most_recent_lastmod) {
|
||||
_most_recent_lastmod = contact.lastmod; // save for the RESP_CODE_END_OF_CONTACTS frame
|
||||
bool done = false;
|
||||
int queued = 0;
|
||||
while (!done && queued < 8) { // up to 8 per iteration to avoid starving loop()
|
||||
if (_iter.hasNext(this, contact)) {
|
||||
if (contact.lastmod > _iter_filter_since) { // apply the 'since' filter
|
||||
if (writeContactRespFrame(RESP_CODE_CONTACT, contact) == 0) break; // queue full
|
||||
queued++;
|
||||
if (contact.lastmod > _most_recent_lastmod) {
|
||||
_most_recent_lastmod = contact.lastmod;
|
||||
}
|
||||
}
|
||||
} else { // EOF
|
||||
out_frame[0] = RESP_CODE_END_OF_CONTACTS;
|
||||
memcpy(&out_frame[1], &_most_recent_lastmod,
|
||||
4); // include the most recent lastmod, so app can update their 'since'
|
||||
_serial->writeFrame(out_frame, 5);
|
||||
_iter_started = false;
|
||||
done = true;
|
||||
}
|
||||
} else { // EOF
|
||||
out_frame[0] = RESP_CODE_END_OF_CONTACTS;
|
||||
memcpy(&out_frame[1], &_most_recent_lastmod,
|
||||
4); // include the most recent lastmod, so app can update their 'since'
|
||||
_serial->writeFrame(out_frame, 5);
|
||||
_iter_started = false;
|
||||
}
|
||||
//} else if (!_serial->isWriteBusy()) {
|
||||
// checkConnections(); // TODO - deprecate the 'Connections' stuff
|
||||
@@ -2786,10 +3231,36 @@ void MyMesh::loop() {
|
||||
|
||||
// is there are pending dirty contacts write needed?
|
||||
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
|
||||
saveContacts();
|
||||
dirty_contacts_expiry = 0;
|
||||
if (_deferSaves) {
|
||||
// Voice session receiving — push save forward to avoid SPI contention
|
||||
dirty_contacts_expiry = futureMillis(2000);
|
||||
} else {
|
||||
#ifdef HELTEC_MESH_POCKET
|
||||
// Meshpocket: use upstream-style synchronous save. Max 500 contacts =
|
||||
// ~74KB, finishes in well under a second on InternalFS. Avoids the
|
||||
// chunked trio's File* lifetime pitfalls entirely.
|
||||
Serial.println("[DS] saveContacts (synchronous) triggered by dirty flag");
|
||||
_store->saveContacts(this);
|
||||
dirty_contacts_expiry = 0;
|
||||
#else
|
||||
if (!_store->isSaveInProgress()) {
|
||||
_store->beginSaveContacts(this);
|
||||
dirty_contacts_expiry = 0;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#ifndef HELTEC_MESH_POCKET
|
||||
// Drive chunked contact save — write a batch each loop iteration.
|
||||
// Only used for non-Meshpocket builds (ESP32 PSRAM heavyweight).
|
||||
if (_store->isSaveInProgress() && !_deferSaves) {
|
||||
if (!_store->saveContactsChunk(20)) { // 20 contacts per chunk (~3KB, ~30ms)
|
||||
_store->finishSaveContacts(); // Done or error — verify and commit
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Discovery scan timeout
|
||||
if (_discoveryActive && millisHasNowPassed(_discoveryTimeout)) {
|
||||
_discoveryActive = false;
|
||||
@@ -2848,6 +3319,13 @@ void MyMesh::stopDiscovery() {
|
||||
_discoveryActive = false;
|
||||
}
|
||||
|
||||
bool MyMesh::forceImportContact(const uint8_t* blob, uint8_t len) {
|
||||
_forceNextImport = true;
|
||||
bool ok = importContact(blob, len);
|
||||
if (!ok) _forceNextImport = false; // clear if importContact failed (no loopback queued)
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool MyMesh::addDiscoveredToContacts(int idx) {
|
||||
if (idx < 0 || idx >= _discoveredCount) return false;
|
||||
if (_discovered[idx].already_in_contacts) return true; // already there
|
||||
@@ -2856,7 +3334,7 @@ bool MyMesh::addDiscoveredToContacts(int idx) {
|
||||
uint8_t buf[256];
|
||||
int plen = getBlobByKey(_discovered[idx].contact.id.pub_key, PUB_KEY_SIZE, buf);
|
||||
if (plen > 0) {
|
||||
bool ok = importContact(buf, (uint8_t)plen);
|
||||
bool ok = forceImportContact(buf, (uint8_t)plen);
|
||||
if (ok) {
|
||||
_discovered[idx].already_in_contacts = true;
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
@@ -2866,4 +3344,20 @@ bool MyMesh::addDiscoveredToContacts(int idx) {
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("Discovery: no cached advert blob for contact '%s'", _discovered[idx].contact.name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#ifdef HELTEC_MESH_POCKET
|
||||
// =============================================================================
|
||||
// Power saving — adapted from MeshCore PR #2286 (IoTThinks)
|
||||
// Returns true if the radio has outbound packets queued (any priority, any
|
||||
// scheduling window). main.cpp loop() uses this to decide whether it's safe
|
||||
// to drop into board.sleep(0) until the next interrupt.
|
||||
//
|
||||
// Upstream uses _mgr->getOutboundTotal() which doesn't exist in this tree —
|
||||
// the equivalent call in Meck is getOutboundCount(0xFFFFFFFF) which passes
|
||||
// max uint32 as `now` so scheduled_for < now is always true. Already used
|
||||
// elsewhere in this file (see line ~2221 in the queue-stats block).
|
||||
// =============================================================================
|
||||
bool MyMesh::hasPendingWork() const {
|
||||
return _mgr->getOutboundCount(0xFFFFFFFF) > 0;
|
||||
}
|
||||
#endif
|
||||
@@ -5,14 +5,14 @@
|
||||
#include "AbstractUITask.h"
|
||||
|
||||
/*------------ Frame Protocol --------------*/
|
||||
#define FIRMWARE_VER_CODE 10
|
||||
#define FIRMWARE_VER_CODE 11
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "7 March 2026"
|
||||
#define FIRMWARE_BUILD_DATE "16 April 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.9.9"
|
||||
#define FIRMWARE_VERSION "Meck v1.7"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
@@ -70,6 +70,11 @@
|
||||
#include <helpers/BaseChatMesh.h>
|
||||
#include <helpers/TransportKeyStore.h>
|
||||
|
||||
// Custom path lock flag — bit 7 of ContactInfo.flags
|
||||
// When set, onContactPathRecv skips auto-updating this contact's out_path.
|
||||
// Bits 0-6 remain available (bit 0 = favourite, bits 1-3 = telemetry perms).
|
||||
#define CONTACT_FLAG_CUSTOM_PATH 0x80
|
||||
|
||||
/* -------------------------------------------------------------------------------------- */
|
||||
|
||||
#define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS
|
||||
@@ -77,8 +82,9 @@
|
||||
#define REQ_TYPE_GET_TELEMETRY_DATA 0x03
|
||||
|
||||
struct AdvertPath {
|
||||
uint8_t pubkey_prefix[7];
|
||||
uint8_t pubkey_prefix[8];
|
||||
uint8_t path_len;
|
||||
uint8_t type; // ADV_TYPE_* (Chat/Repeater/Room/Sensor)
|
||||
char name[32];
|
||||
uint32_t recv_timestamp;
|
||||
uint8_t path[MAX_PATH_SIZE];
|
||||
@@ -119,6 +125,15 @@ public:
|
||||
int getDiscoveredCount() const { return _discoveredCount; }
|
||||
const DiscoveredNode& getDiscovered(int idx) const { return _discovered[idx]; }
|
||||
bool addDiscoveredToContacts(int idx); // promote a discovered node into contacts
|
||||
|
||||
// Last Heard — public wrappers for contact add/remove from UI
|
||||
void scheduleLazyContactSave();
|
||||
int getContactBlob(const uint8_t key[], int key_len, uint8_t dest_buf[]) {
|
||||
return getBlobByKey(key, key_len, dest_buf);
|
||||
}
|
||||
// Force-add a contact from a raw advert blob, bypassing auto-add settings.
|
||||
// Used by Last Heard and Discovery when the user explicitly selects a node to add.
|
||||
bool forceImportContact(const uint8_t* blob, uint8_t len);
|
||||
|
||||
// Queue a sent channel message for BLE app sync
|
||||
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
|
||||
@@ -126,16 +141,51 @@ public:
|
||||
// Send a direct message from the UI (no BLE dependency)
|
||||
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
|
||||
|
||||
// Send raw binary data to a contact (PAYLOAD_TYPE_RAW_CUSTOM, direct route only)
|
||||
// Used for dz0ny VE3 voice protocol: voice packets (0x56) and fetch requests (0x72)
|
||||
bool uiSendRawToContact(uint32_t contact_idx, const uint8_t* data, uint8_t len);
|
||||
|
||||
// Voice-over-LoRa: callback for incoming raw voice packets (dz0ny VE3 protocol)
|
||||
// magic 0x56 = voice data packet, 0x72 = fetch request
|
||||
typedef void (*VoiceRawHandler)(uint8_t magic, const uint8_t* payload, uint8_t len);
|
||||
void setVoiceHandler(VoiceRawHandler h) { _voiceHandler = h; }
|
||||
|
||||
// Voice-over-LoRa: callback for incoming VE3 envelope in a DM
|
||||
// Called with sender name and the VE3 text (e.g. "VE3:a:1:3:2")
|
||||
typedef void (*VoiceEnvelopeHandler)(const char* senderName, const char* ve3Text);
|
||||
void setVoiceEnvelopeHandler(VoiceEnvelopeHandler h) { _voiceEnvHandler = h; }
|
||||
|
||||
// Defer contact saves while voice packets are being received
|
||||
// (SD writes block SPI bus shared with LoRa radio)
|
||||
void setDeferSaves(bool defer) { _deferSaves = defer; }
|
||||
bool isDeferSaves() const { return _deferSaves; }
|
||||
|
||||
// Repeater admin - UI-initiated operations
|
||||
bool uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms);
|
||||
bool uiSendCliCommand(uint32_t contact_idx, const char* command);
|
||||
bool uiSendTelemetryRequest(uint32_t contact_idx);
|
||||
int getAdminContactIdx() const { return _admin_contact_idx; }
|
||||
|
||||
// Custom path editor — set or clear a manually configured path for a contact
|
||||
// When locked, automatic path discovery will not overwrite this contact's path.
|
||||
bool setCustomPath(int contactIdx, const uint8_t* path, uint8_t pathLen, bool lock);
|
||||
void clearCustomPath(int contactIdx);
|
||||
|
||||
#ifdef HELTEC_MESH_POCKET
|
||||
// Power saving: check if there is pending work (outbound packets queued, etc.)
|
||||
// Used by main.cpp loop to decide whether board.sleep() is safe.
|
||||
// Adapted from MeshCore PR #2286 (IoTThinks) — substitutes getOutboundCount(0xFFFFFFFF)
|
||||
// for upstream's getOutboundTotal() which doesn't exist in this tree.
|
||||
bool hasPendingWork() const;
|
||||
#endif
|
||||
|
||||
|
||||
protected:
|
||||
float getAirtimeBudgetFactor() const override;
|
||||
int getInterferenceThreshold() const override;
|
||||
uint8_t getTxFailResetThreshold() const override;
|
||||
uint8_t getRxFailRebootThreshold() const override;
|
||||
void onRxUnrecoverable() override;
|
||||
int calcRxDelay(float score, uint32_t air_time) const override;
|
||||
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
|
||||
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
|
||||
@@ -143,6 +193,7 @@ protected:
|
||||
uint8_t getAutoAddMaxHops() const override;
|
||||
bool filterRecvFloodPacket(mesh::Packet* packet) override;
|
||||
|
||||
uint8_t getPathHashSize() const override { return _prefs.path_hash_mode + 1; }
|
||||
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
|
||||
@@ -203,7 +254,7 @@ private:
|
||||
void writeOKFrame();
|
||||
void writeErrFrame(uint8_t err_code);
|
||||
void writeDisabledFrame();
|
||||
void writeContactRespFrame(uint8_t code, const ContactInfo &contact);
|
||||
size_t writeContactRespFrame(uint8_t code, const ContactInfo &contact);
|
||||
void updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len);
|
||||
void addToOfflineQueue(const uint8_t frame[], int len);
|
||||
int getFromOfflineQueue(uint8_t frame[]);
|
||||
@@ -219,6 +270,10 @@ private:
|
||||
|
||||
DataStore* _store;
|
||||
NodePrefs _prefs;
|
||||
VoiceRawHandler _voiceHandler = nullptr;
|
||||
VoiceEnvelopeHandler _voiceEnvHandler = nullptr;
|
||||
mutable bool _forceNextImport = false;
|
||||
bool _deferSaves = false;
|
||||
uint32_t pending_login;
|
||||
uint32_t pending_status;
|
||||
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
|
||||
@@ -262,8 +317,17 @@ private:
|
||||
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
|
||||
int next_ack_idx;
|
||||
|
||||
#define ADVERT_PATH_TABLE_SIZE 16
|
||||
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
|
||||
// Advert path table: stores paths we've heard back to us for sorting/recency.
|
||||
// ESP32 variants (T-Deck Pro, T5S3, Heltec V4) have PSRAM, so can afford the
|
||||
// large 1000-entry table (~50KB). nRF52 companion builds (Heltec Meshpocket,
|
||||
// T-Echo Card) have no PSRAM and only 256KB total SRAM shared with BLE, so
|
||||
// use a much smaller table sized for realistic handheld usage.
|
||||
#if defined(ESP32)
|
||||
#define ADVERT_PATH_TABLE_SIZE 1000
|
||||
#else
|
||||
#define ADVERT_PATH_TABLE_SIZE 50
|
||||
#endif
|
||||
AdvertPath* advert_paths; // PSRAM-allocated (ESP32) or heap-allocated (nRF52) in begin()
|
||||
|
||||
// Sent message repeat tracking
|
||||
#define SENT_TRACK_SIZE 4
|
||||
|
||||
@@ -33,4 +33,47 @@ struct NodePrefs { // persisted to file
|
||||
uint8_t ringtone_enabled; // Ringtone on incoming call (0=off, 1=on) — 4G only
|
||||
uint8_t path_hash_mode; // 0=1-byte (legacy), 1=2-byte, 2=3-byte path hashes
|
||||
uint8_t autoadd_max_hops; // 0=no limit, N=up to N-1 hops (max 64)
|
||||
uint32_t gps_baudrate; // GPS baud rate (0 = use compile-time GPS_BAUDRATE default)
|
||||
uint8_t interference_threshold; // Interference threshold in dB (0=disabled, 14+=enabled)
|
||||
uint8_t dark_mode; // 0=off (white bg), 1=on (black bg)
|
||||
uint8_t portrait_mode; // 0=landscape, 1=portrait — T5S3 only
|
||||
uint8_t auto_lock_minutes; // 0=disabled, 2/5/10/15/30=auto-lock after idle
|
||||
uint8_t hint_shown; // 0=show nav hint on boot, 1=already shown (dismiss permanently)
|
||||
uint8_t large_font; // 0=tiny (built-in 6x8), 1=larger (FreeSans9pt) — T-Deck Pro only
|
||||
uint8_t tx_fail_reset_threshold; // 0=disabled, 1-10, default 3
|
||||
uint8_t rx_fail_reboot_threshold; // 0=disabled, 1-10, default 3
|
||||
|
||||
// --- Font helpers (inline, no overhead) ---
|
||||
// Returns the DisplayDriver text-size index for "small/body" text.
|
||||
// T-Deck Pro: 0 = built-in 6×8, 1 = FreeSans9pt.
|
||||
// T5S3: both 0 and 1 are 12pt fonts (regular vs bold) with identical line
|
||||
// height, so large_font has no layout effect there.
|
||||
inline uint8_t smallTextSize() const {
|
||||
return large_font ? 1 : 0;
|
||||
}
|
||||
|
||||
// Returns the virtual-coordinate line height matching smallTextSize().
|
||||
// T-Deck Pro size 0 → 9 (6×8 + 1px gap), size 1 → 11 (9pt ascent+descent).
|
||||
// T5S3 size 0/1 → same 12pt height → always 9 in virtual coords.
|
||||
inline int smallLineH() const {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
return 9;
|
||||
#else
|
||||
return large_font ? 11 : 9;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Returns the Y offset for selection highlight fillRect (T-Deck Pro only).
|
||||
// Size 0 (built-in font): cursor positions at top-left, +5 offset in
|
||||
// setCursor places text below → fillRect at y+5 aligns with text.
|
||||
// Size 1 (FreeSans9pt): cursor positions at baseline, ascenders render
|
||||
// upward → fillRect must start above baseline to cover ascenders.
|
||||
// T5S3: always 0 (both sizes use baseline fonts with highlight at y).
|
||||
inline int smallHighlightOff() const {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
return 0;
|
||||
#else
|
||||
return large_font ? -2 : 5;
|
||||
#endif
|
||||
}
|
||||
};
|
||||
+2746
-107
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,8 @@
|
||||
// JPEG decoder for cover art — JPEGDEC by bitbank2
|
||||
#include <JPEGDEC.h>
|
||||
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
|
||||
@@ -151,6 +153,7 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Audio* _audio;
|
||||
Mode _mode;
|
||||
bool _sdReady;
|
||||
@@ -1193,10 +1196,10 @@ private:
|
||||
}
|
||||
|
||||
// Switch to tiny font for file list (6x8 built-in)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs ? _prefs->smallTextSize() : 0);
|
||||
|
||||
// Calculate visible items — tiny font uses ~8 virtual units per line
|
||||
int itemHeight = 8;
|
||||
// Calculate visible items
|
||||
int itemHeight = (_prefs ? _prefs->smallLineH() : 9) - 1;
|
||||
int listTop = 13;
|
||||
int listBottom = display.height() - 14; // Reserve footer space
|
||||
int visibleItems = (listBottom - listTop) / itemHeight;
|
||||
@@ -1208,7 +1211,7 @@ private:
|
||||
_scrollOffset = _selectedFile - visibleItems + 1;
|
||||
}
|
||||
|
||||
// Approx chars that fit in tiny font (~36 on 128 virtual width)
|
||||
// Approx chars for suffix/type tag sizing (still needed for type tag assembly)
|
||||
const int charsPerLine = 36;
|
||||
|
||||
// Draw file list
|
||||
@@ -1218,9 +1221,7 @@ private:
|
||||
|
||||
if (fileIdx == _selectedFile) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
// setCursor adds +5 to y internally, but fillRect does not.
|
||||
// Offset fillRect by +5 to align highlight bar with text.
|
||||
display.fillRect(0, y + 5, display.width(), itemHeight - 1);
|
||||
display.fillRect(0, y + (_prefs ? _prefs->smallHighlightOff() : 5), display.width(), itemHeight - 1);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -1231,29 +1232,15 @@ private:
|
||||
char fullLine[96];
|
||||
|
||||
if (fe.isDir) {
|
||||
// Directory entry: show as "/ FolderName" or just ".."
|
||||
if (fe.name == "..") {
|
||||
snprintf(fullLine, sizeof(fullLine), ".. (up)");
|
||||
} else {
|
||||
snprintf(fullLine, sizeof(fullLine), "/%s", fe.name.c_str());
|
||||
// Truncate if needed
|
||||
if ((int)strlen(fullLine) > charsPerLine - 1) {
|
||||
fullLine[charsPerLine - 4] = '.';
|
||||
fullLine[charsPerLine - 3] = '.';
|
||||
fullLine[charsPerLine - 2] = '.';
|
||||
fullLine[charsPerLine - 1] = '\0';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Audio file: "Title - Author [TYPE]"
|
||||
char lineBuf[80];
|
||||
|
||||
// Reserve space for type tag and bookmark indicator
|
||||
int suffixLen = fe.fileType.length() + 3; // " [M4B]" or " [MP3]"
|
||||
int bmkLen = fe.hasBookmark ? 2 : 0; // " >"
|
||||
int availChars = charsPerLine - suffixLen - bmkLen;
|
||||
if (availChars < 10) availChars = 10;
|
||||
|
||||
if (fe.displayAuthor.length() > 0) {
|
||||
snprintf(lineBuf, sizeof(lineBuf), "%s - %s",
|
||||
fe.displayTitle.c_str(), fe.displayAuthor.c_str());
|
||||
@@ -1261,24 +1248,13 @@ private:
|
||||
snprintf(lineBuf, sizeof(lineBuf), "%s", fe.displayTitle.c_str());
|
||||
}
|
||||
|
||||
// Truncate with ellipsis if needed
|
||||
if ((int)strlen(lineBuf) > availChars) {
|
||||
if (availChars > 3) {
|
||||
lineBuf[availChars - 3] = '.';
|
||||
lineBuf[availChars - 2] = '.';
|
||||
lineBuf[availChars - 1] = '.';
|
||||
lineBuf[availChars] = '\0';
|
||||
} else {
|
||||
lineBuf[availChars] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// Append file type tag
|
||||
snprintf(fullLine, sizeof(fullLine), "%s [%s]", lineBuf, fe.fileType.c_str());
|
||||
}
|
||||
|
||||
display.setCursor(2, y);
|
||||
display.print(fullLine);
|
||||
// Pixel-aware ellipsis — reserve space for bookmark indicator
|
||||
int reserveRight = (!fe.isDir && fe.hasBookmark) ? 10 : 2;
|
||||
display.drawTextEllipsized(2, y, display.width() - reserveRight, fullLine);
|
||||
|
||||
// Bookmark indicator (right-aligned, files only)
|
||||
if (!fe.isDir && fe.hasBookmark) {
|
||||
@@ -1464,8 +1440,8 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
AudiobookPlayerScreen(UITask* task, Audio* audio)
|
||||
: _task(task), _audio(audio), _mode(FILE_LIST),
|
||||
AudiobookPlayerScreen(UITask* task, Audio* audio, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _audio(audio), _mode(FILE_LIST),
|
||||
_sdReady(false), _i2sInitialized(false), _dacPowered(false),
|
||||
_displayRef(nullptr),
|
||||
_selectedFile(0), _scrollOffset(0),
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// CardKBKeyboard — M5Stack CardKB (or compatible) I2C keyboard driver
|
||||
//
|
||||
// Polls 0x5F on the shared I2C bus via QWIIC connector.
|
||||
// Maps CardKB special key codes to Meck key constants.
|
||||
//
|
||||
// Usage:
|
||||
// CardKBKeyboard cardkb;
|
||||
// if (cardkb.begin()) { /* detected */ }
|
||||
// char key = cardkb.readKey(); // returns 0 if no key
|
||||
// =============================================================================
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && defined(MECK_CARDKB)
|
||||
#ifndef CARDKB_KEYBOARD_H
|
||||
#define CARDKB_KEYBOARD_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include "variant.h" // For I2C_SDA, I2C_SCL (bus recovery)
|
||||
|
||||
// I2C address (defined in variant.h, fallback here)
|
||||
#ifndef CARDKB_I2C_ADDR
|
||||
#define CARDKB_I2C_ADDR 0x5F
|
||||
#endif
|
||||
|
||||
// CardKB special key codes (from M5Stack documentation)
|
||||
#define CARDKB_KEY_UP 0xB5
|
||||
#define CARDKB_KEY_DOWN 0xB6
|
||||
#define CARDKB_KEY_LEFT 0xB4
|
||||
#define CARDKB_KEY_RIGHT 0xB7
|
||||
#define CARDKB_KEY_TAB 0x09
|
||||
#define CARDKB_KEY_ESC 0x1B
|
||||
#define CARDKB_KEY_BS 0x08
|
||||
#define CARDKB_KEY_ENTER 0x0D
|
||||
#define CARDKB_KEY_DEL 0x7F
|
||||
#define CARDKB_KEY_FN 0x00 // Fn modifier (swallowed by CardKB internally)
|
||||
|
||||
class CardKBKeyboard {
|
||||
public:
|
||||
CardKBKeyboard() : _detected(false) {}
|
||||
|
||||
// Probe for CardKB on the I2C bus. Call after Wire.begin().
|
||||
bool begin() {
|
||||
Wire.beginTransmission(CARDKB_I2C_ADDR);
|
||||
_detected = (Wire.endTransmission() == 0);
|
||||
if (_detected) {
|
||||
Serial.println("[CardKB] Detected at 0x5F");
|
||||
}
|
||||
return _detected;
|
||||
}
|
||||
|
||||
// Re-probe (e.g. for hot-plug detection every few seconds)
|
||||
bool probe() {
|
||||
Wire.beginTransmission(CARDKB_I2C_ADDR);
|
||||
_detected = (Wire.endTransmission() == 0);
|
||||
return _detected;
|
||||
}
|
||||
|
||||
bool isDetected() const { return _detected; }
|
||||
|
||||
// Poll for a keypress. Returns 0 if no key available.
|
||||
// Returns raw ASCII for printable chars, or Meck KEY_* constants for nav keys.
|
||||
// Throttled to avoid flooding I2C bus — polls at most every 50ms.
|
||||
// On read failure, backs off 500ms and re-inits Wire to recover bus state.
|
||||
char readKey() {
|
||||
if (!_detected) return 0;
|
||||
|
||||
unsigned long now = millis();
|
||||
if (now - _lastPoll < _pollInterval) return 0;
|
||||
_lastPoll = now;
|
||||
|
||||
Wire.requestFrom((uint8_t)CARDKB_I2C_ADDR, (uint8_t)1);
|
||||
if (!Wire.available()) {
|
||||
_errorCount++;
|
||||
if (_errorCount >= 3) {
|
||||
// I2C bus may be stuck — re-init to recover
|
||||
Wire.begin(I2C_SDA, I2C_SCL);
|
||||
Wire.setClock(100000);
|
||||
_pollInterval = 500; // Back off for 500ms
|
||||
_errorCount = 0;
|
||||
Serial.println("[CardKB] I2C error recovery — bus re-init");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
_errorCount = 0;
|
||||
_pollInterval = 50; // Normal polling rate
|
||||
|
||||
uint8_t raw = Wire.read();
|
||||
if (raw == 0) return 0;
|
||||
|
||||
// Map CardKB special keys to Meck constants
|
||||
switch (raw) {
|
||||
case CARDKB_KEY_UP: return 0xF2; // KEY_PREV
|
||||
case CARDKB_KEY_DOWN: return 0xF1; // KEY_NEXT
|
||||
case CARDKB_KEY_LEFT: return 0xF3; // KEY_LEFT
|
||||
case CARDKB_KEY_RIGHT: return 0xF4; // KEY_RIGHT
|
||||
case CARDKB_KEY_ENTER: return '\r';
|
||||
case CARDKB_KEY_BS: return '\b';
|
||||
case CARDKB_KEY_DEL: return '\b'; // Treat delete same as backspace
|
||||
case CARDKB_KEY_ESC: return 0x1B; // ESC — handled by caller
|
||||
case CARDKB_KEY_TAB: return 0x09; // Tab — available for future use
|
||||
default:
|
||||
// Printable ASCII — pass through unchanged
|
||||
if (raw >= 0x20 && raw <= 0x7E) {
|
||||
return (char)raw;
|
||||
}
|
||||
// Unknown code — ignore
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
bool _detected;
|
||||
unsigned long _lastPoll = 0;
|
||||
unsigned long _pollInterval = 50; // ms between polls (increases on error)
|
||||
uint8_t _errorCount = 0;
|
||||
};
|
||||
|
||||
#endif // CARDKB_KEYBOARD_H
|
||||
#endif // LilyGo_T5S3_EPaper_Pro && MECK_CARDKB
|
||||
@@ -59,11 +59,19 @@ public:
|
||||
uint8_t path_len;
|
||||
uint8_t channel_idx; // Which channel this message belongs to
|
||||
int8_t snr; // Receive SNR × 4 (0 if locally sent or unknown)
|
||||
uint32_t dm_peer_hash; // DM peer name hash (for conversation filtering)
|
||||
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes
|
||||
char text[CHANNEL_MSG_TEXT_LEN];
|
||||
bool valid;
|
||||
};
|
||||
|
||||
// Simple hash for DM peer matching
|
||||
static uint32_t peerHash(const char* s) {
|
||||
uint32_t h = 5381;
|
||||
while (*s) { h = ((h << 5) + h) ^ (uint8_t)*s++; }
|
||||
return h;
|
||||
}
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
@@ -84,6 +92,31 @@ private:
|
||||
int _replySelectPos; // Index into chronological channelMsgs[] (0=oldest)
|
||||
int _replyChannelMsgCount; // Cached count from last render (for input bounds)
|
||||
|
||||
// DM tab (channel_idx == 0xFF) two-level view:
|
||||
// Inbox mode: list of contacts you have DMs from
|
||||
// Conversation mode: messages filtered to one contact
|
||||
bool _dmInboxMode; // true = showing inbox list, false = conversation
|
||||
int _dmInboxScroll; // Scroll position in inbox list
|
||||
char _dmFilterName[32]; // Selected contact name for conversation view
|
||||
int _dmContactIdx; // Contact index for conversation (-1 if unknown)
|
||||
uint8_t _dmContactPerms; // Last login permissions for this contact (0=none/guest)
|
||||
const uint8_t* _dmUnreadPtr; // Pointer to per-contact DM unread array (from UITask)
|
||||
|
||||
// Helper: does a message belong to the current view?
|
||||
bool msgMatchesView(const ChannelMessage& msg) const {
|
||||
if (!msg.valid) return false;
|
||||
if (_viewChannelIdx != 0xFF) {
|
||||
return msg.channel_idx == _viewChannelIdx;
|
||||
}
|
||||
// DM tab in conversation mode: filter by peer hash
|
||||
if (!_dmInboxMode && _dmFilterName[0] != '\0') {
|
||||
if (msg.channel_idx != 0xFF) return false;
|
||||
return msg.dm_peer_hash == peerHash(_dmFilterName);
|
||||
}
|
||||
// Inbox mode or no filter — match all DMs
|
||||
return msg.channel_idx == 0xFF;
|
||||
}
|
||||
|
||||
// Per-channel unread message counts (standalone mode)
|
||||
// Index 0..MAX_GROUP_CHANNELS-1 for channel messages
|
||||
// Index MAX_GROUP_CHANNELS for DMs (channel_idx == 0xFF)
|
||||
@@ -93,10 +126,13 @@ public:
|
||||
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
|
||||
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathScrollPos(0), _pathHopsVisible(20),
|
||||
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0) {
|
||||
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0),
|
||||
_dmInboxMode(true), _dmInboxScroll(0), _dmContactIdx(-1), _dmContactPerms(0), _dmUnreadPtr(nullptr) {
|
||||
_dmFilterName[0] = '\0';
|
||||
// Initialize all messages as invalid
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
_messages[i].valid = false;
|
||||
_messages[i].dm_peer_hash = 0;
|
||||
memset(_messages[i].path, 0, MSG_PATH_MAX);
|
||||
}
|
||||
// Initialize unread counts
|
||||
@@ -106,8 +142,9 @@ public:
|
||||
void setSDReady(bool ready) { _sdReady = ready; }
|
||||
|
||||
// Add a new message to the history
|
||||
// peer_name: for DMs, the contact this message belongs to (sender for received, recipient for sent)
|
||||
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
|
||||
const uint8_t* path_bytes = nullptr, int8_t snr = 0) {
|
||||
const uint8_t* path_bytes = nullptr, int8_t snr = 0, const char* peer_name = nullptr) {
|
||||
// Move to next slot in circular buffer
|
||||
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
|
||||
|
||||
@@ -118,6 +155,13 @@ public:
|
||||
msg->snr = snr;
|
||||
msg->valid = true;
|
||||
|
||||
// Set DM peer hash for conversation filtering
|
||||
if (channel_idx == 0xFF) {
|
||||
msg->dm_peer_hash = peerHash(peer_name ? peer_name : sender);
|
||||
} else {
|
||||
msg->dm_peer_hash = 0;
|
||||
}
|
||||
|
||||
// Store path hop hashes
|
||||
memset(msg->path, 0, MSG_PATH_MAX);
|
||||
if (path_bytes && path_len > 0 && path_len != 0xFF) {
|
||||
@@ -158,7 +202,7 @@ public:
|
||||
int getMessageCountForChannel() const {
|
||||
int count = 0;
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
if (_messages[i].valid && _messages[i].channel_idx == _viewChannelIdx) {
|
||||
if (msgMatchesView(_messages[i])) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
@@ -173,11 +217,47 @@ public:
|
||||
_scrollPos = 0;
|
||||
_showPathOverlay = false;
|
||||
_pathScrollPos = 0;
|
||||
// Reset DM inbox state when entering DM tab
|
||||
if (idx == 0xFF) {
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
_dmContactIdx = -1;
|
||||
_dmContactPerms = 0;
|
||||
}
|
||||
markChannelRead(idx);
|
||||
}
|
||||
bool isDMTab() const { return _viewChannelIdx == 0xFF; }
|
||||
bool isDMInboxMode() const { return _viewChannelIdx == 0xFF && _dmInboxMode; }
|
||||
bool isDMConversation() const { return _viewChannelIdx == 0xFF && !_dmInboxMode; }
|
||||
const char* getDMFilterName() const { return _dmFilterName; }
|
||||
|
||||
// Open a specific contact's DM conversation directly (skipping inbox)
|
||||
void openConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0) {
|
||||
strncpy(_dmFilterName, contactName, sizeof(_dmFilterName) - 1);
|
||||
_dmFilterName[sizeof(_dmFilterName) - 1] = '\0';
|
||||
_dmInboxMode = false;
|
||||
_dmContactIdx = contactIdx;
|
||||
_dmContactPerms = perms;
|
||||
_scrollPos = 0;
|
||||
}
|
||||
|
||||
int getDMContactIdx() const { return _dmContactIdx; }
|
||||
uint8_t getDMContactPerms() const { return _dmContactPerms; }
|
||||
void setDMContactPerms(uint8_t p) { _dmContactPerms = p; }
|
||||
bool isShowingPathOverlay() const { return _showPathOverlay; }
|
||||
void dismissPathOverlay() { _showPathOverlay = false; _pathScrollPos = 0; }
|
||||
|
||||
// Set pointer to per-contact DM unread array (called by UITask after allocation)
|
||||
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnreadPtr = ptr; }
|
||||
|
||||
// Subtract a specific amount from the DM unread slot (used by per-contact clearing)
|
||||
void subtractDMUnread(int count) {
|
||||
int slot = MAX_GROUP_CHANNELS; // DM slot
|
||||
_unread[slot] -= count;
|
||||
if (_unread[slot] < 0) _unread[slot] = 0;
|
||||
}
|
||||
|
||||
// --- Reply select mode (R key → pick a message → Enter to @mention reply) ---
|
||||
bool isReplySelectMode() const { return _replySelectMode; }
|
||||
void exitReplySelect() { _replySelectMode = false; _replySelectPos = -1; }
|
||||
@@ -206,7 +286,7 @@ public:
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
|
||||
if (_messages[idx].valid && msgMatchesView(_messages[idx])) {
|
||||
rsMsgs[count++] = idx;
|
||||
}
|
||||
}
|
||||
@@ -230,7 +310,7 @@ public:
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
|
||||
if (_messages[idx].valid && msgMatchesView(_messages[idx])) {
|
||||
rsMsgs[count++] = idx;
|
||||
}
|
||||
}
|
||||
@@ -277,7 +357,7 @@ public:
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx
|
||||
if (msgMatchesView(_messages[idx])
|
||||
&& _messages[idx].path_len != 0) {
|
||||
return &_messages[idx];
|
||||
}
|
||||
@@ -449,7 +529,15 @@ public:
|
||||
|
||||
// Get channel name
|
||||
ChannelDetails channel;
|
||||
if (the_mesh.getChannel(_viewChannelIdx, channel)) {
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
if (_dmInboxMode) {
|
||||
display.print("Direct Messages");
|
||||
} else {
|
||||
char hdr[40];
|
||||
snprintf(hdr, sizeof(hdr), "DM: %s", _dmFilterName);
|
||||
display.print(hdr);
|
||||
}
|
||||
} else if (the_mesh.getChannel(_viewChannelIdx, channel)) {
|
||||
display.print(channel.name);
|
||||
} else {
|
||||
sprintf(tmp, "Channel %d", _viewChannelIdx);
|
||||
@@ -464,11 +552,201 @@ public:
|
||||
|
||||
// Divider line
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === DM Inbox mode: show list of contacts with DMs ===
|
||||
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
|
||||
#define DM_INBOX_MAX 20
|
||||
struct DMInboxEntry {
|
||||
uint32_t hash;
|
||||
char name[32];
|
||||
int msgCount;
|
||||
int unreadCount;
|
||||
uint32_t newestTs;
|
||||
};
|
||||
DMInboxEntry inbox[DM_INBOX_MAX];
|
||||
int inboxCount = 0;
|
||||
|
||||
// Scan all DMs and group by peer hash
|
||||
for (int i = 0; i < _msgCount && i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
|
||||
if (_messages[idx].dm_peer_hash == 0) continue;
|
||||
|
||||
uint32_t h = _messages[idx].dm_peer_hash;
|
||||
|
||||
// Find existing entry by hash
|
||||
int found = -1;
|
||||
for (int j = 0; j < inboxCount; j++) {
|
||||
if (inbox[j].hash == h) { found = j; break; }
|
||||
}
|
||||
if (found < 0 && inboxCount < DM_INBOX_MAX) {
|
||||
found = inboxCount++;
|
||||
inbox[found].hash = h;
|
||||
inbox[found].name[0] = '\0';
|
||||
inbox[found].msgCount = 0;
|
||||
inbox[found].unreadCount = 0;
|
||||
inbox[found].newestTs = 0;
|
||||
|
||||
// Look up name from contacts by matching peer hash
|
||||
uint32_t numC = the_mesh.getNumContacts();
|
||||
ContactInfo ci;
|
||||
for (uint32_t c = 0; c < numC; c++) {
|
||||
if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == h) {
|
||||
strncpy(inbox[found].name, ci.name, 31);
|
||||
inbox[found].name[31] = '\0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback: extract from text if contact not found
|
||||
if (inbox[found].name[0] == '\0') {
|
||||
extractSenderName(_messages[idx].text, inbox[found].name, sizeof(inbox[found].name));
|
||||
}
|
||||
}
|
||||
if (found >= 0) {
|
||||
inbox[found].msgCount++;
|
||||
if (_messages[idx].timestamp > inbox[found].newestTs)
|
||||
inbox[found].newestTs = _messages[idx].timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Look up unread counts from per-contact array
|
||||
if (_dmUnreadPtr) {
|
||||
for (int e = 0; e < inboxCount; e++) {
|
||||
uint32_t numC = the_mesh.getNumContacts();
|
||||
ContactInfo ci;
|
||||
for (uint32_t c = 0; c < numC; c++) {
|
||||
if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == inbox[e].hash) {
|
||||
inbox[e].unreadCount = _dmUnreadPtr[c];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by newest timestamp descending (insertion sort)
|
||||
for (int i = 1; i < inboxCount; i++) {
|
||||
DMInboxEntry tmp2 = inbox[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && inbox[j].newestTs < tmp2.newestTs) {
|
||||
inbox[j + 1] = inbox[j];
|
||||
j--;
|
||||
}
|
||||
inbox[j + 1] = tmp2;
|
||||
}
|
||||
|
||||
// Render inbox list
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerH = 14;
|
||||
int footerH = 14;
|
||||
int maxY = display.height() - footerH;
|
||||
int y = headerH;
|
||||
int maxVisible = (maxY - headerH) / lineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
|
||||
// Clamp scroll
|
||||
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount > 0 ? inboxCount - 1 : 0;
|
||||
|
||||
if (inboxCount == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
display.print("No direct messages");
|
||||
display.setCursor(0, y + lineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("DMs from contacts appear here");
|
||||
#else
|
||||
display.print("A/D: Switch channel");
|
||||
#endif
|
||||
} else {
|
||||
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
|
||||
inboxCount - maxVisible));
|
||||
int endIdx = min(inboxCount, startIdx + maxVisible);
|
||||
|
||||
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
|
||||
bool selected = (i == _dmInboxScroll);
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineH);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Prefix: > for selected, unread indicator
|
||||
char prefix[6];
|
||||
if (inbox[i].unreadCount > 0) {
|
||||
snprintf(prefix, sizeof(prefix), "%s*%d", selected ? ">" : " ", inbox[i].unreadCount);
|
||||
} else {
|
||||
snprintf(prefix, sizeof(prefix), "%s ", selected ? ">" : " ");
|
||||
}
|
||||
display.print(prefix);
|
||||
|
||||
// Name (truncated)
|
||||
char filteredName[32];
|
||||
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
|
||||
|
||||
// Right side: message count + age
|
||||
char ageStr[8];
|
||||
uint32_t age = _rtc->getCurrentTime() - inbox[i].newestTs;
|
||||
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
|
||||
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
|
||||
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
|
||||
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
|
||||
|
||||
char rightStr[16];
|
||||
snprintf(rightStr, sizeof(rightStr), "(%d) %s", inbox[i].msgCount, ageStr);
|
||||
int rightW = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
int nameX = display.getTextWidth(prefix) + 2;
|
||||
int nameMaxW = display.width() - nameX - rightW - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
display.setCursor(display.width() - rightW, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineH;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Swipe:Nav");
|
||||
const char* rtInbox = "Hold:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
|
||||
display.print(rtInbox);
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bck A/D:Ch");
|
||||
const char* rtInbox = "Ent:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
|
||||
display.print(rtInbox);
|
||||
#endif
|
||||
|
||||
#ifdef USE_EINK
|
||||
return 5000;
|
||||
#else
|
||||
return 1000;
|
||||
#endif
|
||||
}
|
||||
|
||||
// --- Path detail overlay ---
|
||||
if (_showPathOverlay) {
|
||||
display.setTextSize(0);
|
||||
int lineH = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
int y = 14;
|
||||
|
||||
ChannelMessage* msg = getNewestReceivedMsg();
|
||||
@@ -639,6 +917,10 @@ public:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Back");
|
||||
const char* copyHint = "Tap:Dismiss";
|
||||
#else
|
||||
display.print("Q:Back");
|
||||
// Show scroll hint if path is scrollable
|
||||
if (msg && (msg->path_len & 63) > _pathHopsVisible && msg->path_len != 0xFF) {
|
||||
@@ -648,10 +930,11 @@ public:
|
||||
display.print(scrollHint);
|
||||
}
|
||||
const char* copyHint = "Ent:Copy";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(copyHint) - 2, footerY);
|
||||
display.print(copyHint);
|
||||
|
||||
#if AUTO_OFF_MILLIS == 0
|
||||
#ifdef USE_EINK
|
||||
return 5000;
|
||||
#else
|
||||
return 1000;
|
||||
@@ -659,18 +942,160 @@ public:
|
||||
}
|
||||
|
||||
if (channelMsgCount == 0) {
|
||||
display.setTextSize(0); // Tiny font for body text
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // Tiny font for body text
|
||||
display.setCursor(0, 20);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("No messages yet");
|
||||
display.setCursor(0, 30);
|
||||
display.print("A/D: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("C: Compose message");
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
char noMsg[48];
|
||||
snprintf(noMsg, sizeof(noMsg), "No messages from %s", _dmFilterName);
|
||||
display.print(noMsg);
|
||||
display.setCursor(0, 30);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Hold: Compose reply");
|
||||
#else
|
||||
display.print("Q: Back to inbox");
|
||||
display.setCursor(0, 40);
|
||||
display.print("Ent: Compose reply");
|
||||
#endif
|
||||
} else {
|
||||
display.print("No messages yet");
|
||||
display.setCursor(0, 30);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("Long press: Compose");
|
||||
#else
|
||||
display.print("A/D: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("C: Compose message");
|
||||
#endif
|
||||
}
|
||||
display.setTextSize(1); // Restore for footer
|
||||
} else if (_viewChannelIdx == 0xFF && _dmInboxMode) {
|
||||
// =================================================================
|
||||
// DM Inbox: list of contacts/rooms you have DM history with
|
||||
// =================================================================
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
int y = headerHeight;
|
||||
|
||||
// Scan all DM messages and collect unique senders
|
||||
#define DM_INBOX_MAX 16
|
||||
struct InboxEntry {
|
||||
char name[24];
|
||||
int count;
|
||||
uint32_t newest_ts;
|
||||
};
|
||||
static InboxEntry inbox[DM_INBOX_MAX];
|
||||
int inboxCount = 0;
|
||||
|
||||
for (int i = 0; i < _msgCount; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
|
||||
|
||||
char sender[24];
|
||||
if (!extractSenderName(_messages[idx].text, sender, sizeof(sender))) continue;
|
||||
|
||||
// Find or add sender in inbox
|
||||
bool found = false;
|
||||
for (int j = 0; j < inboxCount; j++) {
|
||||
if (strcmp(inbox[j].name, sender) == 0) {
|
||||
inbox[j].count++;
|
||||
if (_messages[idx].timestamp > inbox[j].newest_ts)
|
||||
inbox[j].newest_ts = _messages[idx].timestamp;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found && inboxCount < DM_INBOX_MAX) {
|
||||
strncpy(inbox[inboxCount].name, sender, 23);
|
||||
inbox[inboxCount].name[23] = '\0';
|
||||
inbox[inboxCount].count = 1;
|
||||
inbox[inboxCount].newest_ts = _messages[idx].timestamp;
|
||||
inboxCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by newest timestamp descending (most recent first)
|
||||
for (int i = 1; i < inboxCount; i++) {
|
||||
InboxEntry tmp2 = inbox[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && inbox[j].newest_ts < tmp2.newest_ts) {
|
||||
inbox[j + 1] = inbox[j];
|
||||
j--;
|
||||
}
|
||||
inbox[j + 1] = tmp2;
|
||||
}
|
||||
|
||||
if (inboxCount == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
display.print("No conversations");
|
||||
} else {
|
||||
// Clamp scroll
|
||||
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount - 1;
|
||||
if (_dmInboxScroll < 0) _dmInboxScroll = 0;
|
||||
|
||||
int maxVisible = (maxY - headerHeight) / lineHeight;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
|
||||
inboxCount - maxVisible));
|
||||
int endIdx = min(inboxCount, startIdx + maxVisible);
|
||||
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
|
||||
bool selected = (i == _dmInboxScroll);
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
display.print(selected ? ">" : " ");
|
||||
|
||||
// Name (ellipsized)
|
||||
char filteredName[24];
|
||||
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
|
||||
|
||||
// Right side: message count + age
|
||||
char ageStr[8];
|
||||
uint32_t age = now - inbox[i].newest_ts;
|
||||
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
|
||||
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
|
||||
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
|
||||
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
|
||||
|
||||
char rightStr[16];
|
||||
snprintf(rightStr, sizeof(rightStr), "[%d] %s", inbox[i].count, ageStr);
|
||||
int rightW = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
int nameX = display.getTextWidth(">") + 2;
|
||||
int nameMaxW = display.width() - nameX - rightW - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
display.setCursor(display.width() - rightW, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0); // Tiny font for message body
|
||||
int lineHeight = 9; // 8px font + 1px spacing
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // Tiny font for message body
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px spacing
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int scrollBarW = 4; // Width of scroll indicator on right edge
|
||||
@@ -690,7 +1115,7 @@ public:
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
|
||||
if (msgMatchesView(_messages[idx])) {
|
||||
channelMsgs[numChannelMsgs++] = idx;
|
||||
}
|
||||
}
|
||||
@@ -735,7 +1160,11 @@ public:
|
||||
int availH = maxY - y;
|
||||
if (maxFillH > availH) maxFillH = availH;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, contentW, maxFillH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, maxFillH);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), contentW, maxFillH);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Time indicator with hop count - inline on same line as message start
|
||||
@@ -807,7 +1236,9 @@ public:
|
||||
if (wb == ' ' || isEmojiEscape(wb)) break;
|
||||
charStr[0] = (char)wb;
|
||||
dblStr[0] = dblStr[1] = (char)wb;
|
||||
wordW += display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
int charAdv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
if (charAdv < 1) charAdv = 1;
|
||||
wordW += charAdv;
|
||||
}
|
||||
if (px + wordW > lineW) {
|
||||
px = 0;
|
||||
@@ -836,6 +1267,7 @@ public:
|
||||
charStr[0] = ' ';
|
||||
dblStr[0] = dblStr[1] = ' ';
|
||||
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
if (adv < 1) adv = 1; // Minimum advance (rounding fix for proportional fonts)
|
||||
if (px + adv > lineW) {
|
||||
px = 0;
|
||||
linesForThisMsg++;
|
||||
@@ -853,6 +1285,7 @@ public:
|
||||
charStr[0] = (char)b;
|
||||
dblStr[0] = dblStr[1] = (char)b;
|
||||
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
if (adv < 1) adv = 1; // Minimum advance (rounding fix for proportional fonts)
|
||||
if (px + adv > lineW) {
|
||||
px = 0;
|
||||
linesForThisMsg++;
|
||||
@@ -888,7 +1321,11 @@ public:
|
||||
if (maxFillH > availH) maxFillH = availH;
|
||||
if (usedH < maxFillH) {
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(0, y + 5, contentW, maxFillH - usedH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, maxFillH - usedH);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), contentW, maxFillH - usedH);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -943,20 +1380,47 @@ public:
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
display.print("Swipe:Scroll");
|
||||
const char* rtCh = "Hold:Reply";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
|
||||
display.print(rtCh);
|
||||
} else {
|
||||
display.print("Swipe:Ch/Scroll");
|
||||
const char* midCh = "Tap:Path";
|
||||
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
|
||||
display.print(midCh);
|
||||
const char* rtCh = "Hold:Compose";
|
||||
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
|
||||
display.print(rtCh);
|
||||
}
|
||||
#else
|
||||
// Left side: abbreviated controls
|
||||
if (_replySelectMode) {
|
||||
display.print("W/S:Sel V:Pth Q:X");
|
||||
const char* rightText = "Ent:Reply";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
} else if (_viewChannelIdx == 0xFF) {
|
||||
if (_dmContactPerms > 0) {
|
||||
display.print("Q:Exit L:Admin");
|
||||
} else {
|
||||
display.print("Q:Exit");
|
||||
}
|
||||
const char* rightText = "Ent:Reply";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
} else {
|
||||
display.print("Q:Bck A/D:Ch R:Rply");
|
||||
const char* rightText = "Ent:New";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if AUTO_OFF_MILLIS == 0 // e-ink
|
||||
#ifdef USE_EINK
|
||||
return 5000;
|
||||
#else
|
||||
return 1000;
|
||||
@@ -1046,10 +1510,92 @@ public:
|
||||
return true; // Consume all other keys in reply select
|
||||
}
|
||||
|
||||
// --- DM Inbox mode (two-level DM view) ---
|
||||
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
|
||||
// W - scroll up in inbox
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_dmInboxScroll > 0) { _dmInboxScroll--; return true; }
|
||||
return false;
|
||||
}
|
||||
// S - scroll down in inbox
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
_dmInboxScroll++; // Clamped during render
|
||||
return true;
|
||||
}
|
||||
// Enter - open conversation for selected entry
|
||||
if (c == '\r' || c == 13) {
|
||||
// Rebuild inbox by hash to find the selected entry
|
||||
uint32_t seenHash[DM_INBOX_MAX];
|
||||
int cur = 0;
|
||||
for (int i = 0; i < _msgCount; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
|
||||
if (_messages[idx].dm_peer_hash == 0) continue;
|
||||
|
||||
uint32_t h = _messages[idx].dm_peer_hash;
|
||||
bool dup = false;
|
||||
for (int k = 0; k < cur; k++) {
|
||||
if (seenHash[k] == h) { dup = true; break; }
|
||||
}
|
||||
if (dup) continue;
|
||||
if (cur < DM_INBOX_MAX) seenHash[cur] = h;
|
||||
|
||||
if (cur == _dmInboxScroll) {
|
||||
// Found the selected entry — look up name from contacts
|
||||
_dmFilterName[0] = '\0';
|
||||
_dmContactIdx = -1;
|
||||
_dmContactPerms = 0;
|
||||
uint32_t numC = the_mesh.getNumContacts();
|
||||
ContactInfo ci;
|
||||
for (uint32_t c2 = 0; c2 < numC; c2++) {
|
||||
if (the_mesh.getContactByIdx(c2, ci) && peerHash(ci.name) == h) {
|
||||
strncpy(_dmFilterName, ci.name, sizeof(_dmFilterName) - 1);
|
||||
_dmFilterName[sizeof(_dmFilterName) - 1] = '\0';
|
||||
_dmContactIdx = (int)c2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback to text extraction if contact not found
|
||||
if (_dmFilterName[0] == '\0') {
|
||||
extractSenderName(_messages[idx].text, _dmFilterName, sizeof(_dmFilterName));
|
||||
}
|
||||
_dmInboxMode = false;
|
||||
_scrollPos = 0;
|
||||
return true;
|
||||
}
|
||||
cur++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Q - let main.cpp handle (back to home)
|
||||
if (c == 'q' || c == 'Q' || c == '\b') {
|
||||
return false;
|
||||
}
|
||||
// A/D pass through to channel switching below
|
||||
if (c == 'a' || c == 'A' || c == 'd' || c == 'D') {
|
||||
// Fall through to channel switching
|
||||
} else {
|
||||
return true; // Consume other keys
|
||||
}
|
||||
}
|
||||
|
||||
// --- DM Conversation mode: Q goes back to inbox ---
|
||||
if (_viewChannelIdx == 0xFF && !_dmInboxMode) {
|
||||
if (c == 'q' || c == 'Q' || c == '\b') {
|
||||
_dmInboxMode = true;
|
||||
_dmFilterName[0] = '\0';
|
||||
_scrollPos = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
int channelMsgCount = getMessageCountForChannel();
|
||||
|
||||
// R - enter reply select mode
|
||||
// R - enter reply select mode (group channels only — DM tab uses Enter to reply)
|
||||
if (c == 'r' || c == 'R') {
|
||||
if (_viewChannelIdx == 0xFF) return false; // Not applicable on DM tab
|
||||
if (channelMsgCount > 0) {
|
||||
_replySelectMode = true;
|
||||
// Start with newest message selected
|
||||
@@ -1086,14 +1632,12 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// A - previous channel
|
||||
// A - previous channel (includes DM tab at 0xFF)
|
||||
if (c == 'a' || c == 'A') {
|
||||
_replySelectMode = false;
|
||||
_replySelectPos = -1;
|
||||
if (_viewChannelIdx > 0) {
|
||||
_viewChannelIdx--;
|
||||
} else {
|
||||
// Wrap to last valid channel
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
// DM tab → go to last valid group channel
|
||||
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
@@ -1101,22 +1645,64 @@ public:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (_viewChannelIdx > 0) {
|
||||
// Skip backwards over any empty/gap slots
|
||||
uint8_t prev = _viewChannelIdx - 1;
|
||||
bool found = false;
|
||||
while (true) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(prev, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = prev;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (prev == 0) break;
|
||||
prev--;
|
||||
}
|
||||
if (!found) {
|
||||
// No valid channel below → wrap to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
}
|
||||
} else {
|
||||
// Channel 0 → wrap to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
}
|
||||
_scrollPos = 0;
|
||||
markChannelRead(_viewChannelIdx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// D - next channel
|
||||
// D - next channel (includes DM tab at 0xFF)
|
||||
if (c == 'd' || c == 'D') {
|
||||
_replySelectMode = false;
|
||||
_replySelectPos = -1;
|
||||
ChannelDetails ch;
|
||||
uint8_t nextIdx = _viewChannelIdx + 1;
|
||||
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = nextIdx;
|
||||
} else {
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
// DM tab → wrap to channel 0
|
||||
_viewChannelIdx = 0;
|
||||
} else {
|
||||
// Skip forward over any empty/gap slots
|
||||
bool found = false;
|
||||
for (uint8_t next = _viewChannelIdx + 1; next < MAX_GROUP_CHANNELS; next++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(next, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = next;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// Past last channel → go to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
}
|
||||
}
|
||||
_scrollPos = 0;
|
||||
markChannelRead(_viewChannelIdx);
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <MeshCore.h>
|
||||
|
||||
// Forward declarations
|
||||
// Timestamps before this (Jan 1 2026 UTC) are treated as invalid/unsynced
|
||||
#define EPOCH_2026 1735689600UL
|
||||
|
||||
// Forward declarations — MyMesh.h (which defines AdvertPath) is always
|
||||
// included by the translation unit before this header.
|
||||
class UITask;
|
||||
class MyMesh;
|
||||
extern MyMesh the_mesh;
|
||||
@@ -33,13 +37,22 @@ private:
|
||||
// We rebuild this on filter change or when entering the screen
|
||||
// Arrays allocated in PSRAM when available (supports 1000+ contacts)
|
||||
uint16_t* _filteredIdx; // indices into contact table
|
||||
uint32_t* _filteredTs; // cached last_advert_timestamp for sorting
|
||||
uint32_t* _filteredTs; // cached lastmod for sorting
|
||||
int _filteredCount; // how many contacts match current filter
|
||||
AdvertPath _hopBuf[40]; // recently heard advert paths for hop-count display
|
||||
int _hopBufCount;
|
||||
bool _cacheValid;
|
||||
|
||||
// How many rows fit on screen (computed during render)
|
||||
int _rowsPerPage;
|
||||
|
||||
// Pointer to per-contact DM unread array (owned by UITask, set via setter)
|
||||
const uint8_t* _dmUnread = nullptr;
|
||||
|
||||
// --- Select mode state ---
|
||||
bool _selectMode;
|
||||
uint8_t* _selectedBits; // Bitfield: 1 bit per MAX_CONTACTS raw index
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
static const char* filterLabel(FilterMode f) {
|
||||
@@ -54,12 +67,12 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
static char typeChar(uint8_t adv_type) {
|
||||
static const char* typeStr(uint8_t adv_type) {
|
||||
switch (adv_type) {
|
||||
case ADV_TYPE_CHAT: return 'C';
|
||||
case ADV_TYPE_REPEATER: return 'R';
|
||||
case ADV_TYPE_ROOM: return 'S'; // Server
|
||||
default: return '?';
|
||||
case ADV_TYPE_CHAT: return "C";
|
||||
case ADV_TYPE_REPEATER: return "R";
|
||||
case ADV_TYPE_ROOM: return "RS";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +98,13 @@ private:
|
||||
if (the_mesh.getContactByIdx(i, contact)) {
|
||||
if (matchesFilter(contact.type, contact.flags)) {
|
||||
_filteredIdx[_filteredCount] = (uint16_t)i;
|
||||
_filteredTs[_filteredCount] = contact.last_advert_timestamp;
|
||||
// Use lastmod (our receive time) for sort/age; pre-2026 or zero → 0 sinks to bottom
|
||||
_filteredTs[_filteredCount] = (contact.lastmod >= EPOCH_2026) ? contact.lastmod : 0;
|
||||
_filteredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by last_advert_timestamp descending (most recently seen first)
|
||||
// Sort by lastmod descending (most recently heard first; pre-2026/unsynced sink to bottom)
|
||||
// Insertion sort — fine for up to ~1000 entries on ESP32
|
||||
for (int i = 1; i < _filteredCount; i++) {
|
||||
uint16_t tmpIdx = _filteredIdx[i];
|
||||
@@ -105,46 +119,64 @@ private:
|
||||
_filteredTs[j + 1] = tmpTs;
|
||||
}
|
||||
_cacheValid = true;
|
||||
// Refresh hop-count cache from the 12 most recently heard adverts
|
||||
_hopBufCount = the_mesh.getRecentlyHeard(_hopBuf, 40);
|
||||
// Clamp scroll position
|
||||
if (_scrollPos >= _filteredCount) {
|
||||
_scrollPos = (_filteredCount > 0) ? _filteredCount - 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Format seconds-ago as compact string: "3s" "5m" "2h" "4d" "??"
|
||||
// Format seconds-ago as compact string: "3s" "5m" "2h" "4d" "--"
|
||||
static void formatAge(char* buf, size_t bufLen, uint32_t now, uint32_t timestamp) {
|
||||
if (timestamp == 0) {
|
||||
if (timestamp == 0 || timestamp < EPOCH_2026 || now < timestamp) {
|
||||
strncpy(buf, "--", bufLen);
|
||||
return;
|
||||
}
|
||||
int secs = (int)(now - timestamp);
|
||||
if (secs < 0) secs = 0;
|
||||
uint32_t secs = now - timestamp;
|
||||
if (secs < 60) {
|
||||
snprintf(buf, bufLen, "%ds", secs);
|
||||
snprintf(buf, bufLen, "%ds", (int)secs);
|
||||
} else if (secs < 3600) {
|
||||
snprintf(buf, bufLen, "%dm", secs / 60);
|
||||
snprintf(buf, bufLen, "%dm", (int)(secs / 60));
|
||||
} else if (secs < 86400) {
|
||||
snprintf(buf, bufLen, "%dh", secs / 3600);
|
||||
snprintf(buf, bufLen, "%dh", (int)(secs / 3600));
|
||||
} else {
|
||||
snprintf(buf, bufLen, "%dd", secs / 86400);
|
||||
snprintf(buf, bufLen, "%dd", (int)(secs / 86400));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bitfield helpers ---
|
||||
bool isSelectedRaw(int rawIdx) const {
|
||||
if (rawIdx < 0 || rawIdx >= MAX_CONTACTS) return false;
|
||||
return (_selectedBits[rawIdx / 8] & (1 << (rawIdx % 8))) != 0;
|
||||
}
|
||||
void setSelectedRaw(int rawIdx, bool sel) {
|
||||
if (rawIdx < 0 || rawIdx >= MAX_CONTACTS) return;
|
||||
if (sel) _selectedBits[rawIdx / 8] |= (1 << (rawIdx % 8));
|
||||
else _selectedBits[rawIdx / 8] &= ~(1 << (rawIdx % 8));
|
||||
}
|
||||
|
||||
public:
|
||||
ContactsScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL),
|
||||
_filteredCount(0), _cacheValid(false), _rowsPerPage(5) {
|
||||
_filteredCount(0), _cacheValid(false), _rowsPerPage(5),
|
||||
_selectMode(false), _hopBufCount(0) {
|
||||
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
|
||||
_filteredIdx = (uint16_t*)ps_calloc(MAX_CONTACTS, sizeof(uint16_t));
|
||||
_filteredTs = (uint32_t*)ps_calloc(MAX_CONTACTS, sizeof(uint32_t));
|
||||
_selectedBits = (uint8_t*)ps_calloc((MAX_CONTACTS + 7) / 8, 1);
|
||||
#else
|
||||
_filteredIdx = new uint16_t[MAX_CONTACTS]();
|
||||
_filteredTs = new uint32_t[MAX_CONTACTS]();
|
||||
_selectedBits = new uint8_t[(MAX_CONTACTS + 7) / 8]();
|
||||
#endif
|
||||
}
|
||||
|
||||
void invalidateCache() { _cacheValid = false; }
|
||||
|
||||
// Set pointer to per-contact DM unread array (called by UITask after allocation)
|
||||
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnread = ptr; }
|
||||
|
||||
void resetScroll() {
|
||||
_scrollPos = 0;
|
||||
_cacheValid = false;
|
||||
@@ -152,6 +184,83 @@ public:
|
||||
|
||||
FilterMode getFilter() const { return _filter; }
|
||||
|
||||
// --- Select mode API ---
|
||||
bool isInSelectMode() const { return _selectMode; }
|
||||
|
||||
void enterSelectMode() {
|
||||
_selectMode = true;
|
||||
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
|
||||
// Pre-select the currently highlighted contact
|
||||
if (_filteredCount > 0 && _scrollPos < _filteredCount) {
|
||||
setSelectedRaw(_filteredIdx[_scrollPos], true);
|
||||
}
|
||||
}
|
||||
|
||||
void exitSelectMode() {
|
||||
_selectMode = false;
|
||||
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
|
||||
}
|
||||
|
||||
void toggleSelected() {
|
||||
if (_filteredCount == 0 || _scrollPos >= _filteredCount) return;
|
||||
int rawIdx = _filteredIdx[_scrollPos];
|
||||
setSelectedRaw(rawIdx, !isSelectedRaw(rawIdx));
|
||||
}
|
||||
|
||||
void selectAll() {
|
||||
for (int i = 0; i < _filteredCount; i++) {
|
||||
setSelectedRaw(_filteredIdx[i], true);
|
||||
}
|
||||
}
|
||||
|
||||
void deselectAll() {
|
||||
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
|
||||
}
|
||||
|
||||
int getSelectedCount() const {
|
||||
int count = 0;
|
||||
for (int i = 0; i < _filteredCount; i++) {
|
||||
if (isSelectedRaw(_filteredIdx[i])) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Fill outBuf with raw contact table indices of selected contacts
|
||||
int getSelectedRawIndices(uint16_t* outBuf, int maxOut) const {
|
||||
int count = 0;
|
||||
for (int i = 0; i < _filteredCount && count < maxOut; i++) {
|
||||
if (isSelectedRaw(_filteredIdx[i])) {
|
||||
outBuf[count++] = _filteredIdx[i];
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Tap-to-select: given virtual Y, select contact row.
|
||||
// Returns: 0=miss, 1=moved, 2=tapped current row.
|
||||
int selectRowAtVY(int vy) {
|
||||
if (_filteredCount == 0) return 0;
|
||||
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
int maxVisible = (128 - headerH - footerH) / lineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
|
||||
_filteredCount - maxVisible));
|
||||
|
||||
int tappedRow = startIdx + (vy - bodyTop) / lineH;
|
||||
if (tappedRow < 0 || tappedRow >= _filteredCount) return 0;
|
||||
|
||||
if (tappedRow == _scrollPos) return 2;
|
||||
_scrollPos = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the raw contact table index for the currently highlighted item
|
||||
// Returns -1 if no valid selection
|
||||
int getSelectedContactIdx() const {
|
||||
@@ -188,7 +297,12 @@ public:
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
|
||||
if (_selectMode) {
|
||||
int selCount = getSelectedCount();
|
||||
snprintf(tmp, sizeof(tmp), "%d Selected [%s]", selCount, filterLabel(_filter));
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
|
||||
}
|
||||
display.print(tmp);
|
||||
|
||||
// Count on right: All → total/max, filtered → matched/total
|
||||
@@ -204,8 +318,8 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body - contact rows ===
|
||||
display.setTextSize(0); // tiny font for compact rows
|
||||
int lineHeight = 9; // 8px font + 1px gap
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px gap
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
@@ -219,7 +333,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;
|
||||
@@ -233,11 +351,16 @@ public:
|
||||
if (!the_mesh.getContactByIdx(_filteredIdx[i], contact)) continue;
|
||||
|
||||
bool selected = (i == _scrollPos);
|
||||
bool sel = _selectMode && isSelectedRaw(_filteredIdx[i]);
|
||||
|
||||
// Highlight: fill LIGHT rect first, then draw DARK text on top
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -246,12 +369,16 @@ public:
|
||||
// Set cursor AFTER fillRect so text draws on top of highlight
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Prefix: "> " for selected, type char + space for others
|
||||
char prefix[4];
|
||||
if (selected) {
|
||||
snprintf(prefix, sizeof(prefix), ">%c", typeChar(contact.type));
|
||||
// Prefix: select mode uses * for selected, normal uses > for cursor
|
||||
char prefix[5];
|
||||
if (_selectMode) {
|
||||
snprintf(prefix, sizeof(prefix), "%c%s",
|
||||
sel ? '*' : (selected ? '>' : ' '),
|
||||
typeStr(contact.type));
|
||||
} else if (selected) {
|
||||
snprintf(prefix, sizeof(prefix), ">%s", typeStr(contact.type));
|
||||
} else {
|
||||
snprintf(prefix, sizeof(prefix), " %c", typeChar(contact.type));
|
||||
snprintf(prefix, sizeof(prefix), " %s", typeStr(contact.type));
|
||||
}
|
||||
display.print(prefix);
|
||||
|
||||
@@ -261,18 +388,47 @@ public:
|
||||
|
||||
// Reserve space for hops + age on right side
|
||||
char hopStr[6];
|
||||
if (contact.out_path_len == 0xFF || contact.out_path_len == 0) {
|
||||
strcpy(hopStr, "D"); // direct
|
||||
if (contact.out_path_len == 0xFF) {
|
||||
// No confirmed direct path — look up flood hop estimate from recent advert cache
|
||||
hopStr[0] = '?'; hopStr[1] = '\0'; // default
|
||||
for (int h = 0; h < _hopBufCount; h++) {
|
||||
if (memcmp(contact.id.pub_key, _hopBuf[h].pubkey_prefix, 7) == 0) {
|
||||
uint8_t bph = (_hopBuf[h].path_len >> 6) + 1;
|
||||
uint8_t hops = _hopBuf[h].path_len & 0x3F;
|
||||
uint8_t max_hops = 64 / bph; // sanity cap based on path encoding
|
||||
if (hops <= max_hops) {
|
||||
if (hops == 0)
|
||||
strcpy(hopStr, "~D");
|
||||
else
|
||||
snprintf(hopStr, sizeof(hopStr), "~%d", (int)hops);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (contact.out_path_len == 0) {
|
||||
bool customDirect = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
|
||||
strcpy(hopStr, customDirect ? "D*" : "D");
|
||||
} else {
|
||||
snprintf(hopStr, sizeof(hopStr), "%d", contact.out_path_len);
|
||||
int hops = contact.out_path_len & 0x3F; // lower 6 bits = hop count
|
||||
bool customPath = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
|
||||
if (customPath) {
|
||||
snprintf(hopStr, sizeof(hopStr), "%d*", hops); // asterisk = custom/locked path
|
||||
} else {
|
||||
snprintf(hopStr, sizeof(hopStr), "%d", hops);
|
||||
}
|
||||
}
|
||||
|
||||
char ageStr[6];
|
||||
formatAge(ageStr, sizeof(ageStr), now, contact.last_advert_timestamp);
|
||||
formatAge(ageStr, sizeof(ageStr), now, contact.lastmod);
|
||||
|
||||
// Build right-side string: "hops age"
|
||||
char rightStr[14];
|
||||
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
|
||||
// Build right-side string: "*N hops age" if unread, else "hops age"
|
||||
int dmCount = (_dmUnread && _filteredIdx[i] < MAX_CONTACTS) ? _dmUnread[_filteredIdx[i]] : 0;
|
||||
char rightStr[20];
|
||||
if (dmCount > 0) {
|
||||
snprintf(rightStr, sizeof(rightStr), "*%d %sh %s", dmCount, hopStr, ageStr);
|
||||
} else {
|
||||
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
|
||||
}
|
||||
int rightWidth = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
// Name region: after prefix + small gap, before right info
|
||||
@@ -297,19 +453,33 @@ public:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
// Left: Q:Bk
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bk");
|
||||
|
||||
// Center: A/D:Filter
|
||||
const char* mid = "A/D:Filtr";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
// Right: F:Dscvr
|
||||
const char* right = "F:Dscvr";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
if (_selectMode) {
|
||||
display.print("Swipe:All/Clr");
|
||||
const char* right = "Tap:Tog Hold:Exit";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
} else {
|
||||
display.print("Swipe:Filter");
|
||||
const char* right = "Hold:DM/Admin";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
if (_selectMode) {
|
||||
display.print("A:All D:Clr");
|
||||
const char* right = "X:Exp F:Fav Q:Done";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
} else {
|
||||
display.print("Q:Bk A/D:Filter");
|
||||
const char* right = "P:Path Ent:Sel";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
#endif
|
||||
|
||||
return 5000; // e-ink: next render after 5s
|
||||
}
|
||||
@@ -331,6 +501,29 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// --- Select mode key handling ---
|
||||
if (_selectMode) {
|
||||
// Enter/tap: toggle selection on current contact
|
||||
if (c == 13 || c == KEY_ENTER) {
|
||||
toggleSelected();
|
||||
return true;
|
||||
}
|
||||
// A: select all in current filter
|
||||
if (c == 'a' || c == 'A') {
|
||||
selectAll();
|
||||
return true;
|
||||
}
|
||||
// D: deselect all
|
||||
if (c == 'd' || c == 'D') {
|
||||
deselectAll();
|
||||
return true;
|
||||
}
|
||||
// Q, X, F, Backspace — handled by main.cpp (needs mesh/SD access)
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Normal mode key handling ---
|
||||
|
||||
// A - previous filter
|
||||
if (c == 'a' || c == 'A') {
|
||||
_filter = (FilterMode)(((int)_filter + FILTER_COUNT - 1) % FILTER_COUNT);
|
||||
|
||||
@@ -44,6 +44,32 @@ public:
|
||||
|
||||
int getSelectedIdx() const { return _scrollPos; }
|
||||
|
||||
// Tap-to-select: given virtual Y, select discovered node row.
|
||||
// Returns: 0=miss, 1=moved, 2=tapped current row.
|
||||
int selectRowAtVY(int vy) {
|
||||
int count = the_mesh.getDiscoveredCount();
|
||||
if (count == 0) return 0;
|
||||
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
int maxVisible = (128 - headerH - footerH) / lineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
|
||||
count - maxVisible));
|
||||
|
||||
int tappedRow = startIdx + (vy - bodyTop) / lineH;
|
||||
if (tappedRow < 0 || tappedRow >= count) return 0;
|
||||
|
||||
if (tappedRow == _scrollPos) return 2;
|
||||
_scrollPos = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
int count = the_mesh.getDiscoveredCount();
|
||||
bool active = the_mesh.isDiscoveryActive();
|
||||
@@ -65,8 +91,8 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body — discovered node rows ===
|
||||
display.setTextSize(0); // tiny font for compact rows
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
@@ -79,7 +105,11 @@ public:
|
||||
display.print(active ? "Listening for adverts..." : "No nodes found");
|
||||
if (!active) {
|
||||
display.setCursor(4, 38);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Long press: Rescan");
|
||||
#else
|
||||
display.print("F: Scan again Q: Back");
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
// Center visible window around selected item
|
||||
@@ -96,7 +126,11 @@ public:
|
||||
// Highlight selected row
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -158,15 +192,23 @@ public:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Back");
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe:Scroll");
|
||||
|
||||
const char* mid = "Ent:Add";
|
||||
const char* mid = "Tap:Add";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
const char* right = "F:Rescan";
|
||||
const char* right = "Hold:Rescan";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.print("Q:Bk F:Rescan");
|
||||
|
||||
const char* right = "Tap/Ent:Add";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
// Faster refresh while actively scanning
|
||||
return active ? 1000 : 5000;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Emoji sprites for e-ink display - dual size
|
||||
// Large (12x12) for compose/picker, Small (10x10) for channel view
|
||||
// MSB-first, 2 bytes per row
|
||||
// 65 total emoji: joy/thumbsup/frown first, then 43 original, then 19 new
|
||||
// 76 total emoji: joy/thumbsup/frown first, then 43 original, then 19 new, then 11 newest
|
||||
|
||||
#include <stdint.h>
|
||||
#ifdef ESP32
|
||||
@@ -15,11 +15,11 @@
|
||||
#define EMOJI_SM_W 10
|
||||
#define EMOJI_SM_H 10
|
||||
|
||||
#define EMOJI_COUNT 65
|
||||
#define EMOJI_COUNT 76
|
||||
|
||||
// Escape codes in 0x80+ range - safe from keyboard ASCII (32-126)
|
||||
#define EMOJI_ESCAPE_START 0x80
|
||||
#define EMOJI_ESCAPE_END 0xC0 // 0x80 + 64
|
||||
#define EMOJI_ESCAPE_END 0xCB // 0x80 + 75
|
||||
#define EMOJI_PAD_BYTE 0x7F // DEL, not typeable (key < 127 guard)
|
||||
|
||||
// ======== LARGE 12x12 SPRITES ========
|
||||
@@ -36,6 +36,14 @@ static const uint8_t emoji_lg_thumbsup[] PROGMEM = {
|
||||
static const uint8_t emoji_lg_frown[] PROGMEM = {
|
||||
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0, 0x80,0x10, 0x9F,0x90, 0xA0,0x50, 0x40,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [65] loudly_crying 😭
|
||||
static const uint8_t emoji_lg_loudly_crying[] PROGMEM = {
|
||||
0x1F,0x80, 0x20,0x40, 0x5B,0x40, 0x5B,0x20, 0x80,0x10, 0x9F,0x10, 0xA0,0x90, 0x60,0xC0, 0xA0,0xA0, 0x1F,0x00, 0x40,0x40, 0x00,0x00,
|
||||
};
|
||||
// [66] heart ♥️
|
||||
static const uint8_t emoji_lg_heart[] PROGMEM = {
|
||||
0x00,0x00, 0x73,0x80, 0xFF,0xC0, 0xFF,0xC0, 0xFF,0xC0, 0x7F,0x80, 0x3F,0x00, 0x1E,0x00, 0x0C,0x00, 0x00,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [3] wireless
|
||||
static const uint8_t emoji_lg_wireless[] PROGMEM = {
|
||||
0x00,0x00, 0x3F,0xC0, 0x60,0x60, 0xC0,0x30, 0x0F,0x00, 0x19,0x80, 0x30,0xC0, 0x00,0x00, 0x06,0x00, 0x0F,0x00, 0x06,0x00, 0x00,0x00,
|
||||
@@ -284,23 +292,68 @@ static const uint8_t emoji_lg_tipping[] PROGMEM = {
|
||||
static const uint8_t emoji_lg_hedgehog[] PROGMEM = {
|
||||
0x00,0x00, 0x0A,0x80, 0x15,0x40, 0x2A,0xA0, 0x55,0x60, 0x7E,0xF0, 0xDB,0x90, 0xFF,0xD0, 0x7F,0xE0, 0x3F,0xC0, 0x24,0x80, 0x00,0x00,
|
||||
};
|
||||
// [67] diamond_suit ♦️
|
||||
static const uint8_t emoji_lg_diamond_suit[] PROGMEM = {
|
||||
0x00,0x00, 0x04,0x00, 0x0E,0x00, 0x1F,0x00, 0x3F,0x80, 0x7F,0xC0, 0x3F,0x80, 0x1F,0x00, 0x0E,0x00, 0x04,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [68] spade_suit ♠️
|
||||
static const uint8_t emoji_lg_spade_suit[] PROGMEM = {
|
||||
0x04,0x00, 0x0E,0x00, 0x1F,0x00, 0x3F,0x80, 0x7F,0xC0, 0xFF,0xE0, 0xFF,0xE0, 0x7F,0xC0, 0x15,0x00, 0x04,0x00, 0x0E,0x00, 0x00,0x00,
|
||||
};
|
||||
// [69] pizza 🍕
|
||||
static const uint8_t emoji_lg_pizza[] PROGMEM = {
|
||||
0x02,0x00, 0x06,0x00, 0x0F,0x00, 0x0B,0x00, 0x1F,0x80, 0x1D,0x80, 0x3F,0xC0, 0x2F,0x40, 0x7F,0xE0, 0x7F,0xE0, 0xFF,0xF0, 0x00,0x00,
|
||||
};
|
||||
// [70] four_leaf_clover 🍀
|
||||
static const uint8_t emoji_lg_four_leaf_clover[] PROGMEM = {
|
||||
0x0C,0x00, 0x1E,0x00, 0x1E,0x00, 0x6D,0x80, 0xF3,0xC0, 0xF3,0xC0, 0x6D,0x80, 0x1E,0x00, 0x1E,0x00, 0x0C,0x00, 0x06,0x00, 0x00,0x00,
|
||||
};
|
||||
// [71] cloud ☁️
|
||||
static const uint8_t emoji_lg_cloud[] PROGMEM = {
|
||||
0x00,0x00, 0x1C,0x00, 0x3E,0x00, 0x7F,0x80, 0xFF,0xC0, 0xFF,0xE0, 0xFF,0xE0, 0x7F,0xC0, 0x00,0x00, 0x00,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [72] rocket 🚀
|
||||
static const uint8_t emoji_lg_rocket[] PROGMEM = {
|
||||
0x04,0x00, 0x0E,0x00, 0x1F,0x00, 0x1B,0x00, 0x1B,0x00, 0x3F,0x80, 0x7F,0xC0, 0x5F,0x40, 0x9F,0x20, 0x0E,0x00, 0x15,0x00, 0x00,0x00,
|
||||
};
|
||||
// [73] passport_control 🛂
|
||||
static const uint8_t emoji_lg_passport_control[] PROGMEM = {
|
||||
0x3F,0xC0, 0x40,0x20, 0x46,0x20, 0x4F,0x20, 0x46,0x20, 0x40,0x20, 0x44,0x20, 0x4E,0x20, 0x5F,0x20, 0x40,0x20, 0x3F,0xC0, 0x00,0x00,
|
||||
};
|
||||
// [74] eight_spoked_asterisk ✳️
|
||||
static const uint8_t emoji_lg_eight_spoked_asterisk[] PROGMEM = {
|
||||
0x7F,0xE0, 0x84,0x10, 0xA4,0x90, 0x95,0x10, 0x8E,0x10, 0xFF,0xD0, 0x8E,0x10, 0x95,0x10, 0xA4,0x90, 0x84,0x10, 0x7F,0xE0, 0x00,0x00,
|
||||
};
|
||||
// [75] signal_strength 📶
|
||||
static const uint8_t emoji_lg_signal_strength[] PROGMEM = {
|
||||
0x00,0x20, 0x00,0x20, 0x00,0xA0, 0x00,0xA0, 0x02,0xA0, 0x02,0xA0, 0x0A,0xA0, 0x0A,0xA0, 0x2A,0xA0, 0x2A,0xA0, 0xAA,0xA0, 0xAA,0xA0,
|
||||
};
|
||||
|
||||
|
||||
static const uint8_t* const EMOJI_SPRITES_LG[] PROGMEM = {
|
||||
emoji_lg_joy, emoji_lg_thumbsup, emoji_lg_frown,
|
||||
// Faces/emotion first
|
||||
emoji_lg_joy, emoji_lg_frown, emoji_lg_loudly_crying,
|
||||
emoji_lg_grimace, emoji_lg_zany_face, emoji_lg_cowboy,
|
||||
// Thumbsup + heart
|
||||
emoji_lg_thumbsup, emoji_lg_heart,
|
||||
// Everything else in original relative order
|
||||
emoji_lg_wireless, emoji_lg_infinity, emoji_lg_trex, emoji_lg_skull, emoji_lg_cross,
|
||||
emoji_lg_lightning, emoji_lg_tophat, emoji_lg_motorcycle, emoji_lg_seedling, emoji_lg_flag_au,
|
||||
emoji_lg_umbrella, emoji_lg_nazar, emoji_lg_globe, emoji_lg_radioactive, emoji_lg_cow,
|
||||
emoji_lg_alien, emoji_lg_invader, emoji_lg_dagger, emoji_lg_grimace,
|
||||
emoji_lg_alien, emoji_lg_invader, emoji_lg_dagger,
|
||||
emoji_lg_mountain, emoji_lg_end_arrow, emoji_lg_hollow_circle, emoji_lg_dragon, emoji_lg_globe_meridians,
|
||||
emoji_lg_eggplant, emoji_lg_shield, emoji_lg_goggles, emoji_lg_lizard, emoji_lg_zany_face,
|
||||
emoji_lg_eggplant, emoji_lg_shield, emoji_lg_goggles, emoji_lg_lizard,
|
||||
emoji_lg_kangaroo, emoji_lg_feather, emoji_lg_bright, emoji_lg_part_alt, emoji_lg_motorboat,
|
||||
emoji_lg_domino, emoji_lg_satellite, emoji_lg_customs, emoji_lg_cowboy, emoji_lg_wheel,
|
||||
emoji_lg_domino, emoji_lg_satellite, emoji_lg_customs, emoji_lg_wheel,
|
||||
emoji_lg_koala, emoji_lg_control_knobs, emoji_lg_peach, emoji_lg_racing_car,
|
||||
emoji_lg_mouse, emoji_lg_mushroom, emoji_lg_biohazard, emoji_lg_panda,
|
||||
emoji_lg_anger, emoji_lg_dragon_face, emoji_lg_pager, emoji_lg_bee,
|
||||
emoji_lg_bulb, emoji_lg_cat, emoji_lg_fleur, emoji_lg_moon,
|
||||
emoji_lg_coffee, emoji_lg_tooth, emoji_lg_pretzel, emoji_lg_abacus,
|
||||
emoji_lg_moai, emoji_lg_tipping, emoji_lg_hedgehog,
|
||||
emoji_lg_diamond_suit, emoji_lg_spade_suit, emoji_lg_pizza, emoji_lg_four_leaf_clover,
|
||||
emoji_lg_cloud, emoji_lg_rocket, emoji_lg_passport_control,
|
||||
emoji_lg_eight_spoked_asterisk, emoji_lg_signal_strength,
|
||||
};
|
||||
|
||||
// ======== SMALL 10x10 SPRITES ========
|
||||
@@ -519,94 +572,160 @@ static const uint8_t emoji_sm_tipping[] PROGMEM = {
|
||||
static const uint8_t emoji_sm_hedgehog[] PROGMEM = {
|
||||
0x15,0x00, 0x2A,0x80, 0x55,0x40, 0xFF,0xC0, 0xDB,0x40, 0xFF,0x80, 0x7F,0x80, 0x3F,0x00, 0x24,0x00, 0x00,0x00,
|
||||
};
|
||||
// [65] loudly_crying 😭
|
||||
static const uint8_t emoji_sm_loudly_crying[] PROGMEM = {
|
||||
0x3E,0x00, 0x41,0x00, 0xB6,0x80, 0x80,0x40, 0xBE,0x40, 0x81,0x40, 0x63,0x00, 0x9C,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [66] heart ♥️
|
||||
static const uint8_t emoji_sm_heart[] PROGMEM = {
|
||||
0x00,0x00, 0x6C,0x00, 0xFE,0x00, 0xFE,0x00, 0x7C,0x00, 0x38,0x00, 0x10,0x00, 0x00,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [67] diamond_suit ♦️
|
||||
static const uint8_t emoji_sm_diamond_suit[] PROGMEM = {
|
||||
0x00,0x00, 0x08,0x00, 0x1C,0x00, 0x3E,0x00, 0x7F,0x00, 0x3E,0x00, 0x1C,0x00, 0x08,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [68] spade_suit ♠️
|
||||
static const uint8_t emoji_sm_spade_suit[] PROGMEM = {
|
||||
0x08,0x00, 0x1C,0x00, 0x3E,0x00, 0x7F,0x00, 0xFF,0x80, 0xFF,0x80, 0x2A,0x00, 0x08,0x00, 0x1C,0x00, 0x00,0x00,
|
||||
};
|
||||
// [69] pizza 🍕
|
||||
static const uint8_t emoji_sm_pizza[] PROGMEM = {
|
||||
0x08,0x00, 0x1C,0x00, 0x14,0x00, 0x3E,0x00, 0x36,0x00, 0x6D,0x00, 0x7F,0x00, 0xFF,0x80, 0xFF,0x80, 0x00,0x00,
|
||||
};
|
||||
// [70] four_leaf_clover 🍀
|
||||
static const uint8_t emoji_sm_four_leaf_clover[] PROGMEM = {
|
||||
0x18,0x00, 0x3C,0x00, 0xDB,0x00, 0xE7,0x00, 0xE7,0x00, 0xDB,0x00, 0x3C,0x00, 0x18,0x00, 0x0C,0x00, 0x00,0x00,
|
||||
};
|
||||
// [71] cloud ☁️
|
||||
static const uint8_t emoji_sm_cloud[] PROGMEM = {
|
||||
0x00,0x00, 0x38,0x00, 0x7E,0x00, 0xFF,0x00, 0xFF,0x80, 0xFF,0x80, 0x7F,0x00, 0x00,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [72] rocket 🚀
|
||||
static const uint8_t emoji_sm_rocket[] PROGMEM = {
|
||||
0x08,0x00, 0x1C,0x00, 0x36,0x00, 0x36,0x00, 0x7F,0x00, 0xBE,0x80, 0x3E,0x00, 0x1C,0x00, 0x2A,0x00, 0x00,0x00,
|
||||
};
|
||||
// [73] passport_control 🛂
|
||||
static const uint8_t emoji_sm_passport_control[] PROGMEM = {
|
||||
0x7F,0x80, 0x80,0x40, 0x8C,0x40, 0x9E,0x40, 0x8C,0x40, 0x80,0x40, 0x9C,0x40, 0xBE,0x40, 0x80,0x40, 0x7F,0x80,
|
||||
};
|
||||
// [74] eight_spoked_asterisk ✳️
|
||||
static const uint8_t emoji_sm_eight_spoked_asterisk[] PROGMEM = {
|
||||
0x7F,0x80, 0x84,0x40, 0xA5,0x40, 0x9E,0x40, 0xFF,0xC0, 0x9E,0x40, 0xA5,0x40, 0x84,0x40, 0x7F,0x80, 0x00,0x00,
|
||||
};
|
||||
// [75] signal_strength 📶
|
||||
static const uint8_t emoji_sm_signal_strength[] PROGMEM = {
|
||||
0x00,0x80, 0x00,0x80, 0x02,0x80, 0x02,0x80, 0x0A,0x80, 0x0A,0x80, 0x2A,0x80, 0x2A,0x80, 0xAA,0x80, 0xAA,0x80,
|
||||
};
|
||||
|
||||
static const uint8_t* const EMOJI_SPRITES_SM[] PROGMEM = {
|
||||
emoji_sm_joy, emoji_sm_thumbsup, emoji_sm_frown,
|
||||
// Faces/emotion first
|
||||
emoji_sm_joy, emoji_sm_frown, emoji_sm_loudly_crying,
|
||||
emoji_sm_grimace, emoji_sm_zany_face, emoji_sm_cowboy,
|
||||
// Thumbsup + heart
|
||||
emoji_sm_thumbsup, emoji_sm_heart,
|
||||
// Everything else in original relative order
|
||||
emoji_sm_wireless, emoji_sm_infinity, emoji_sm_trex, emoji_sm_skull, emoji_sm_cross,
|
||||
emoji_sm_lightning, emoji_sm_tophat, emoji_sm_motorcycle, emoji_sm_seedling, emoji_sm_flag_au,
|
||||
emoji_sm_umbrella, emoji_sm_nazar, emoji_sm_globe, emoji_sm_radioactive, emoji_sm_cow,
|
||||
emoji_sm_alien, emoji_sm_invader, emoji_sm_dagger, emoji_sm_grimace,
|
||||
emoji_sm_alien, emoji_sm_invader, emoji_sm_dagger,
|
||||
emoji_sm_mountain, emoji_sm_end_arrow, emoji_sm_hollow_circle, emoji_sm_dragon, emoji_sm_globe_meridians,
|
||||
emoji_sm_eggplant, emoji_sm_shield, emoji_sm_goggles, emoji_sm_lizard, emoji_sm_zany_face,
|
||||
emoji_sm_eggplant, emoji_sm_shield, emoji_sm_goggles, emoji_sm_lizard,
|
||||
emoji_sm_kangaroo, emoji_sm_feather, emoji_sm_bright, emoji_sm_part_alt, emoji_sm_motorboat,
|
||||
emoji_sm_domino, emoji_sm_satellite, emoji_sm_customs, emoji_sm_cowboy, emoji_sm_wheel,
|
||||
emoji_sm_domino, emoji_sm_satellite, emoji_sm_customs, emoji_sm_wheel,
|
||||
emoji_sm_koala, emoji_sm_control_knobs, emoji_sm_peach, emoji_sm_racing_car,
|
||||
emoji_sm_mouse, emoji_sm_mushroom, emoji_sm_biohazard, emoji_sm_panda,
|
||||
emoji_sm_anger, emoji_sm_dragon_face, emoji_sm_pager, emoji_sm_bee,
|
||||
emoji_sm_bulb, emoji_sm_cat, emoji_sm_fleur, emoji_sm_moon,
|
||||
emoji_sm_coffee, emoji_sm_tooth, emoji_sm_pretzel, emoji_sm_abacus,
|
||||
emoji_sm_moai, emoji_sm_tipping, emoji_sm_hedgehog,
|
||||
emoji_sm_diamond_suit, emoji_sm_spade_suit, emoji_sm_pizza, emoji_sm_four_leaf_clover,
|
||||
emoji_sm_cloud, emoji_sm_rocket, emoji_sm_passport_control,
|
||||
emoji_sm_eight_spoked_asterisk, emoji_sm_signal_strength,
|
||||
};
|
||||
|
||||
// ---- Codepoint lookup for UTF-8 conversion ----
|
||||
struct EmojiCodepoint { uint32_t cp; uint32_t cp2; uint8_t escape; };
|
||||
|
||||
static const EmojiCodepoint EMOJI_CODEPOINTS[EMOJI_COUNT] = {
|
||||
// Faces/emotion first
|
||||
{ 0x1F602, 0x0000, 0x80 }, // joy
|
||||
{ 0x1F44D, 0x0000, 0x81 }, // thumbsup
|
||||
{ 0x2639, 0x0000, 0x82 }, // frown
|
||||
{ 0x1F6DC, 0x0000, 0x83 }, // wireless
|
||||
{ 0x267E, 0x0000, 0x84 }, // infinity
|
||||
{ 0x1F996, 0x0000, 0x85 }, // trex
|
||||
{ 0x2620, 0x0000, 0x86 }, // skull
|
||||
{ 0x271D, 0x0000, 0x87 }, // cross
|
||||
{ 0x26A1, 0x0000, 0x88 }, // lightning
|
||||
{ 0x1F3A9, 0x0000, 0x89 }, // tophat
|
||||
{ 0x1F3CD, 0x0000, 0x8A }, // motorcycle
|
||||
{ 0x1F331, 0x0000, 0x8B }, // seedling
|
||||
{ 0x1F1E6, 0x1F1FA, 0x8C }, // flag_au
|
||||
{ 0x2602, 0x0000, 0x8D }, // umbrella
|
||||
{ 0x1F9FF, 0x0000, 0x8E }, // nazar
|
||||
{ 0x1F30F, 0x0000, 0x8F }, // globe
|
||||
{ 0x2622, 0x0000, 0x90 }, // radioactive
|
||||
{ 0x1F404, 0x0000, 0x91 }, // cow
|
||||
{ 0x1F47D, 0x0000, 0x92 }, // alien
|
||||
{ 0x1F47E, 0x0000, 0x93 }, // invader
|
||||
{ 0x1F5E1, 0x0000, 0x94 }, // dagger
|
||||
{ 0x1F62C, 0x0000, 0x95 }, // grimace
|
||||
{ 0x26F0, 0x0000, 0x96 }, // mountain
|
||||
{ 0x1F51A, 0x0000, 0x97 }, // end_arrow
|
||||
{ 0x2B55, 0x0000, 0x98 }, // hollow_circle
|
||||
{ 0x1F409, 0x0000, 0x99 }, // dragon
|
||||
{ 0x1F310, 0x0000, 0x9A }, // globe_meridians
|
||||
{ 0x1F346, 0x0000, 0x9B }, // eggplant
|
||||
{ 0x1F6E1, 0x0000, 0x9C }, // shield
|
||||
{ 0x1F97D, 0x0000, 0x9D }, // goggles
|
||||
{ 0x1F98E, 0x0000, 0x9E }, // lizard
|
||||
{ 0x1F92A, 0x0000, 0x9F }, // zany_face
|
||||
{ 0x1F998, 0x0000, 0xA0 }, // kangaroo
|
||||
{ 0x1FAB6, 0x0000, 0xA1 }, // feather
|
||||
{ 0x1F506, 0x0000, 0xA2 }, // bright
|
||||
{ 0x303D, 0x0000, 0xA3 }, // part_alt
|
||||
{ 0x1F6E5, 0x0000, 0xA4 }, // motorboat
|
||||
{ 0x1F030, 0x0000, 0xA5 }, // domino
|
||||
{ 0x1F4E1, 0x0000, 0xA6 }, // satellite
|
||||
{ 0x1F6C3, 0x0000, 0xA7 }, // customs
|
||||
{ 0x1F920, 0x0000, 0xA8 }, // cowboy
|
||||
{ 0x1F6DE, 0x0000, 0xA9 }, // wheel
|
||||
{ 0x1F428, 0x0000, 0xAA }, // koala
|
||||
{ 0x1F39B, 0x0000, 0xAB }, // control_knobs
|
||||
{ 0x1F351, 0x0000, 0xAC }, // peach
|
||||
{ 0x1F3CE, 0x0000, 0xAD }, // racing_car
|
||||
{ 0x1F42D, 0x0000, 0xAE }, // mouse
|
||||
{ 0x1F344, 0x0000, 0xAF }, // mushroom
|
||||
{ 0x2623, 0x0000, 0xB0 }, // biohazard
|
||||
{ 0x1F43C, 0x0000, 0xB1 }, // panda
|
||||
{ 0x1F4A2, 0x0000, 0xB2 }, // anger
|
||||
{ 0x1F432, 0x0000, 0xB3 }, // dragon_face
|
||||
{ 0x1F4DF, 0x0000, 0xB4 }, // pager
|
||||
{ 0x1F41D, 0x0000, 0xB5 }, // bee
|
||||
{ 0x1F4A1, 0x0000, 0xB6 }, // bulb
|
||||
{ 0x1F431, 0x0000, 0xB7 }, // cat
|
||||
{ 0x269C, 0x0000, 0xB8 }, // fleur
|
||||
{ 0x1F314, 0x0000, 0xB9 }, // moon
|
||||
{ 0x2615, 0x0000, 0xBA }, // coffee
|
||||
{ 0x1F9B7, 0x0000, 0xBB }, // tooth
|
||||
{ 0x1F968, 0x0000, 0xBC }, // pretzel
|
||||
{ 0x1F9EE, 0x0000, 0xBD }, // abacus
|
||||
{ 0x1F5FF, 0x0000, 0xBE }, // moai
|
||||
{ 0x1F481, 0x0000, 0xBF }, // tipping
|
||||
{ 0x1F994, 0x0000, 0xC0 }, // hedgehog
|
||||
{ 0x2639, 0x0000, 0x81 }, // frown
|
||||
{ 0x1F62D, 0x0000, 0x82 }, // loudly_crying
|
||||
{ 0x1F62C, 0x0000, 0x83 }, // grimace
|
||||
{ 0x1F92A, 0x0000, 0x84 }, // zany_face
|
||||
{ 0x1F920, 0x0000, 0x85 }, // cowboy
|
||||
// Thumbsup + heart
|
||||
{ 0x1F44D, 0x0000, 0x86 }, // thumbsup
|
||||
{ 0x2665, 0x0000, 0x87 }, // heart
|
||||
// Everything else in original relative order
|
||||
{ 0x1F6DC, 0x0000, 0x88 }, // wireless
|
||||
{ 0x267E, 0x0000, 0x89 }, // infinity
|
||||
{ 0x1F996, 0x0000, 0x8A }, // trex
|
||||
{ 0x2620, 0x0000, 0x8B }, // skull
|
||||
{ 0x271D, 0x0000, 0x8C }, // cross
|
||||
{ 0x26A1, 0x0000, 0x8D }, // lightning
|
||||
{ 0x1F3A9, 0x0000, 0x8E }, // tophat
|
||||
{ 0x1F3CD, 0x0000, 0x8F }, // motorcycle
|
||||
{ 0x1F331, 0x0000, 0x90 }, // seedling
|
||||
{ 0x1F1E6, 0x1F1FA, 0x91 }, // flag_au
|
||||
{ 0x2602, 0x0000, 0x92 }, // umbrella
|
||||
{ 0x1F9FF, 0x0000, 0x93 }, // nazar
|
||||
{ 0x1F30F, 0x0000, 0x94 }, // globe
|
||||
{ 0x2622, 0x0000, 0x95 }, // radioactive
|
||||
{ 0x1F404, 0x0000, 0x96 }, // cow
|
||||
{ 0x1F47D, 0x0000, 0x97 }, // alien
|
||||
{ 0x1F47E, 0x0000, 0x98 }, // invader
|
||||
{ 0x1F5E1, 0x0000, 0x99 }, // dagger
|
||||
{ 0x26F0, 0x0000, 0x9A }, // mountain
|
||||
{ 0x1F51A, 0x0000, 0x9B }, // end_arrow
|
||||
{ 0x2B55, 0x0000, 0x9C }, // hollow_circle
|
||||
{ 0x1F409, 0x0000, 0x9D }, // dragon
|
||||
{ 0x1F310, 0x0000, 0x9E }, // globe_meridians
|
||||
{ 0x1F346, 0x0000, 0x9F }, // eggplant
|
||||
{ 0x1F6E1, 0x0000, 0xA0 }, // shield
|
||||
{ 0x1F97D, 0x0000, 0xA1 }, // goggles
|
||||
{ 0x1F98E, 0x0000, 0xA2 }, // lizard
|
||||
{ 0x1F998, 0x0000, 0xA3 }, // kangaroo
|
||||
{ 0x1FAB6, 0x0000, 0xA4 }, // feather
|
||||
{ 0x1F506, 0x0000, 0xA5 }, // bright
|
||||
{ 0x303D, 0x0000, 0xA6 }, // part_alt
|
||||
{ 0x1F6E5, 0x0000, 0xA7 }, // motorboat
|
||||
{ 0x1F030, 0x0000, 0xA8 }, // domino
|
||||
{ 0x1F4E1, 0x0000, 0xA9 }, // satellite
|
||||
{ 0x1F6C3, 0x0000, 0xAA }, // customs
|
||||
{ 0x1F6DE, 0x0000, 0xAB }, // wheel
|
||||
{ 0x1F428, 0x0000, 0xAC }, // koala
|
||||
{ 0x1F39B, 0x0000, 0xAD }, // control_knobs
|
||||
{ 0x1F351, 0x0000, 0xAE }, // peach
|
||||
{ 0x1F3CE, 0x0000, 0xAF }, // racing_car
|
||||
{ 0x1F42D, 0x0000, 0xB0 }, // mouse
|
||||
{ 0x1F344, 0x0000, 0xB1 }, // mushroom
|
||||
{ 0x2623, 0x0000, 0xB2 }, // biohazard
|
||||
{ 0x1F43C, 0x0000, 0xB3 }, // panda
|
||||
{ 0x1F4A2, 0x0000, 0xB4 }, // anger
|
||||
{ 0x1F432, 0x0000, 0xB5 }, // dragon_face
|
||||
{ 0x1F4DF, 0x0000, 0xB6 }, // pager
|
||||
{ 0x1F41D, 0x0000, 0xB7 }, // bee
|
||||
{ 0x1F4A1, 0x0000, 0xB8 }, // bulb
|
||||
{ 0x1F431, 0x0000, 0xB9 }, // cat
|
||||
{ 0x269C, 0x0000, 0xBA }, // fleur
|
||||
{ 0x1F314, 0x0000, 0xBB }, // moon
|
||||
{ 0x2615, 0x0000, 0xBC }, // coffee
|
||||
{ 0x1F9B7, 0x0000, 0xBD }, // tooth
|
||||
{ 0x1F968, 0x0000, 0xBE }, // pretzel
|
||||
{ 0x1F9EE, 0x0000, 0xBF }, // abacus
|
||||
{ 0x1F5FF, 0x0000, 0xC0 }, // moai
|
||||
{ 0x1F481, 0x0000, 0xC1 }, // tipping
|
||||
{ 0x1F994, 0x0000, 0xC2 }, // hedgehog
|
||||
{ 0x2666, 0x0000, 0xC3 }, // diamond_suit
|
||||
{ 0x2660, 0x0000, 0xC4 }, // spade_suit
|
||||
{ 0x1F355, 0x0000, 0xC5 }, // pizza
|
||||
{ 0x1F340, 0x0000, 0xC6 }, // four_leaf_clover
|
||||
{ 0x2601, 0x0000, 0xC7 }, // cloud
|
||||
{ 0x1F680, 0x0000, 0xC8 }, // rocket
|
||||
{ 0x1F6C2, 0x0000, 0xC9 }, // passport_control
|
||||
{ 0x2733, 0x0000, 0xCA }, // eight_spoked_asterisk
|
||||
{ 0x1F4F6, 0x0000, 0xCB }, // signal_strength
|
||||
};
|
||||
|
||||
// ---- Helper functions ----
|
||||
@@ -616,7 +735,7 @@ static const EmojiCodepoint EMOJI_CODEPOINTS[EMOJI_COUNT] = {
|
||||
struct EmojiAlias { uint32_t cp; uint8_t escape; };
|
||||
#define EMOJI_ALIAS_COUNT 1
|
||||
static const EmojiAlias EMOJI_ALIASES[EMOJI_ALIAS_COUNT] = {
|
||||
{ 0x1F08E, 0xA5 }, // domino tile (MWD node signifier) -> domino sprite
|
||||
{ 0x1F08E, 0xA8 }, // domino tile (MWD node signifier) -> domino sprite
|
||||
};
|
||||
|
||||
static uint32_t emojiDecodeUtf8(const uint8_t* s, int remaining, int* bytes_consumed) {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/AdvertDataHelpers.h>
|
||||
#include <MeshCore.h>
|
||||
|
||||
extern MyMesh the_mesh;
|
||||
|
||||
// ==========================================================================
|
||||
// Last Heard Screen — passive advert list
|
||||
// Shows all recently heard nodes from the advert path table, sorted by
|
||||
// recency. Unlike Discovery (active zero-hop scan), this is purely passive
|
||||
// — it shows nodes whose adverts have been received over time.
|
||||
// ==========================================================================
|
||||
// Display cap — we never need to show all 200 storage entries at once
|
||||
#define LAST_HEARD_DISPLAY_SIZE 100
|
||||
|
||||
class LastHeardScreen : public UIScreen {
|
||||
mesh::RTCClock* _rtc;
|
||||
int _scrollPos;
|
||||
|
||||
// Local sorted copy of advert paths (PSRAM-allocated, refreshed each render)
|
||||
AdvertPath* _entries;
|
||||
int _count;
|
||||
|
||||
static char typeChar(uint8_t adv_type) {
|
||||
switch (adv_type) {
|
||||
case ADV_TYPE_CHAT: return 'C';
|
||||
case ADV_TYPE_REPEATER: return 'R';
|
||||
case ADV_TYPE_ROOM: return 'S';
|
||||
case ADV_TYPE_SENSOR: return 'N';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
|
||||
// Format age as human-readable string (e.g. "2m", "1h", "3d")
|
||||
static void formatAge(uint32_t now, uint32_t timestamp, char* buf, int bufLen) {
|
||||
if (timestamp == 0 || now < timestamp) {
|
||||
snprintf(buf, bufLen, "---");
|
||||
return;
|
||||
}
|
||||
uint32_t age = now - timestamp;
|
||||
if (age < 60) snprintf(buf, bufLen, "%ds", age);
|
||||
else if (age < 3600) snprintf(buf, bufLen, "%dm", age / 60);
|
||||
else if (age < 86400) snprintf(buf, bufLen, "%dh", age / 3600);
|
||||
else snprintf(buf, bufLen, "%dd", age / 86400);
|
||||
}
|
||||
|
||||
public:
|
||||
LastHeardScreen(mesh::RTCClock* rtc)
|
||||
: _rtc(rtc), _scrollPos(0), _count(0) {
|
||||
#if defined(ESP32)
|
||||
// ESP32 variants have PSRAM — allocate the entries buffer there
|
||||
_entries = (AdvertPath*)ps_calloc(LAST_HEARD_DISPLAY_SIZE, sizeof(AdvertPath));
|
||||
#else
|
||||
// nRF52 has no PSRAM — fall back to regular heap. At 100 entries × ~84
|
||||
// bytes each this is ~8.4KB, manageable within Meshpocket's SRAM budget.
|
||||
_entries = (AdvertPath*)calloc(LAST_HEARD_DISPLAY_SIZE, sizeof(AdvertPath));
|
||||
#endif
|
||||
}
|
||||
|
||||
void resetScroll() { _scrollPos = 0; }
|
||||
|
||||
int getSelectedIdx() const { return _scrollPos; }
|
||||
|
||||
// Check if selected node is already in contacts
|
||||
bool isSelectedInContacts() const {
|
||||
if (_scrollPos < 0 || _scrollPos >= _count) return false;
|
||||
return the_mesh.lookupContactByPubKey(_entries[_scrollPos].pubkey_prefix, 8) != nullptr;
|
||||
}
|
||||
|
||||
// Get selected entry (for add/delete operations)
|
||||
const AdvertPath* getSelectedEntry() const {
|
||||
if (_scrollPos < 0 || _scrollPos >= _count) return nullptr;
|
||||
return &_entries[_scrollPos];
|
||||
}
|
||||
|
||||
// Tap-to-select: given virtual Y, select row.
|
||||
// Returns: 0=miss, 1=moved, 2=tapped current row.
|
||||
int selectRowAtVY(int vy) {
|
||||
if (_count == 0) return 0;
|
||||
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
int maxVisible = (128 - headerH - footerH) / lineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
|
||||
_count - maxVisible));
|
||||
|
||||
int tappedRow = startIdx + (vy - bodyTop) / lineH;
|
||||
if (tappedRow < 0 || tappedRow >= _count) return 0;
|
||||
|
||||
if (tappedRow == _scrollPos) return 2;
|
||||
_scrollPos = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
// Refresh sorted list from mesh
|
||||
_count = the_mesh.getRecentlyHeard(_entries, LAST_HEARD_DISPLAY_SIZE);
|
||||
|
||||
// Filter out empty entries (recv_timestamp == 0)
|
||||
int validCount = 0;
|
||||
for (int i = 0; i < _count; i++) {
|
||||
if (_entries[i].recv_timestamp > 0) validCount++;
|
||||
else break; // sorted by recency, so first zero means rest are empty
|
||||
}
|
||||
_count = validCount;
|
||||
|
||||
if (_scrollPos >= _count) _scrollPos = max(0, _count - 1);
|
||||
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
|
||||
// === Header ===
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
|
||||
char hdr[32];
|
||||
snprintf(hdr, sizeof(hdr), "Last Heard: %d nodes", _count);
|
||||
display.print(hdr);
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body — node rows ===
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
int y = headerHeight;
|
||||
|
||||
if (_count == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 28);
|
||||
display.print("No adverts received yet");
|
||||
display.setCursor(4, 38);
|
||||
display.print("Nodes appear as adverts arrive");
|
||||
} else {
|
||||
int maxVisible = (maxY - headerHeight) / lineHeight;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
|
||||
_count - maxVisible));
|
||||
int endIdx = min(_count, startIdx + maxVisible);
|
||||
|
||||
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
|
||||
const AdvertPath& entry = _entries[i];
|
||||
bool selected = (i == _scrollPos);
|
||||
|
||||
// Highlight selected row
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Prefix: cursor + type char
|
||||
char prefix[4];
|
||||
snprintf(prefix, sizeof(prefix), "%c%c",
|
||||
selected ? '>' : ' ', typeChar(entry.type));
|
||||
display.print(prefix);
|
||||
|
||||
// Right side: age + hops + [★] for favourites, [+] for other contacts
|
||||
char rightStr[20];
|
||||
char ageBuf[8];
|
||||
formatAge(now, entry.recv_timestamp, ageBuf, sizeof(ageBuf));
|
||||
|
||||
ContactInfo* ci = the_mesh.lookupContactByPubKey(entry.pubkey_prefix, 8);
|
||||
bool inContacts = (ci != nullptr);
|
||||
bool isFav = inContacts && (ci->flags & 0x01);
|
||||
if (isFav) {
|
||||
snprintf(rightStr, sizeof(rightStr), "%s %dh [*]", ageBuf, entry.path_len & 63);
|
||||
} else if (inContacts) {
|
||||
snprintf(rightStr, sizeof(rightStr), "%s %dh [+]", ageBuf, entry.path_len & 63);
|
||||
} else {
|
||||
snprintf(rightStr, sizeof(rightStr), "%s %dh", ageBuf, entry.path_len & 63);
|
||||
}
|
||||
int rightWidth = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
// Name (truncated with ellipsis)
|
||||
char filteredName[32];
|
||||
display.translateUTF8ToBlocks(filteredName, entry.name, sizeof(filteredName));
|
||||
int nameX = display.getTextWidth(prefix) + 2;
|
||||
int nameMaxW = display.width() - nameX - rightWidth - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
// Right-aligned info
|
||||
display.setCursor(display.width() - rightWidth, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
|
||||
// === Footer ===
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe:Scroll");
|
||||
const char* right = "Tap:Add/Del";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.print("Q:Bk");
|
||||
const char* right = "Tap/Ent:Add/Del";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
return 5000; // refresh every 5s to update ages
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// Scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_scrollPos > 0) { _scrollPos--; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_scrollPos < _count - 1) { _scrollPos++; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter — handled by main.cpp (needs access to private MyMesh methods)
|
||||
// Q — handled by main.cpp (navigation)
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -137,22 +137,18 @@ public:
|
||||
_zoomMin(MAP_MIN_ZOOM),
|
||||
_zoomMax(MAP_MAX_ZOOM),
|
||||
_pngBuf(nullptr),
|
||||
_lineBuf(nullptr),
|
||||
_tileFound(false)
|
||||
{
|
||||
// Allocate marker array in PSRAM at construction (~20KB)
|
||||
// so addMarker() works before enter() is called
|
||||
_markers = (MapMarker*)ps_calloc(MAP_MAX_MARKERS, sizeof(MapMarker));
|
||||
if (_markers) {
|
||||
Serial.printf("MapScreen: markers allocated (%d × %d = %d bytes PSRAM)\n",
|
||||
MAP_MAX_MARKERS, (int)sizeof(MapMarker),
|
||||
MAP_MAX_MARKERS * (int)sizeof(MapMarker));
|
||||
} else {
|
||||
Serial.println("MapScreen: marker PSRAM alloc FAILED");
|
||||
}
|
||||
// Marker array and PNG buffers are deferred to enter() to avoid
|
||||
// consuming 20KB+ PSRAM at boot when the map may never be opened.
|
||||
_markers = nullptr;
|
||||
_numMarkers = 0;
|
||||
}
|
||||
|
||||
~MapScreen() {
|
||||
if (_pngBuf) { free(_pngBuf); _pngBuf = nullptr; }
|
||||
if (_lineBuf) { free(_lineBuf); _lineBuf = nullptr; }
|
||||
if (_markers) { free(_markers); _markers = nullptr; }
|
||||
}
|
||||
|
||||
@@ -184,7 +180,12 @@ public:
|
||||
// Add a location marker (call once per contact before entering map)
|
||||
void clearMarkers() { _numMarkers = 0; }
|
||||
void addMarker(double lat, double lon, const char* name = "", uint8_t type = 0) {
|
||||
if (!_markers || _numMarkers >= MAP_MAX_MARKERS) return;
|
||||
// Lazy-allocate markers on first use (deferred from constructor)
|
||||
if (!_markers) {
|
||||
_markers = (MapMarker*)ps_calloc(MAP_MAX_MARKERS, sizeof(MapMarker));
|
||||
if (!_markers) return; // Alloc failed — skip silently
|
||||
}
|
||||
if (_numMarkers >= MAP_MAX_MARKERS) return;
|
||||
if (lat == 0.0 && lon == 0.0) return; // Skip no-location contacts
|
||||
_markers[_numMarkers].lat = lat;
|
||||
_markers[_numMarkers].lon = lon;
|
||||
@@ -203,6 +204,18 @@ public:
|
||||
_einkDisplay = static_cast<GxEPDDisplay*>(&display);
|
||||
_needsRedraw = true;
|
||||
|
||||
// Allocate marker array in PSRAM on first use (~20KB)
|
||||
if (!_markers) {
|
||||
_markers = (MapMarker*)ps_calloc(MAP_MAX_MARKERS, sizeof(MapMarker));
|
||||
if (_markers) {
|
||||
Serial.printf("MapScreen: markers allocated (%d × %d = %d bytes PSRAM)\n",
|
||||
MAP_MAX_MARKERS, (int)sizeof(MapMarker),
|
||||
MAP_MAX_MARKERS * (int)sizeof(MapMarker));
|
||||
} else {
|
||||
Serial.println("MapScreen: marker PSRAM alloc FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate PNG read buffer in PSRAM on first use
|
||||
if (!_pngBuf) {
|
||||
_pngBuf = (uint8_t*)ps_malloc(MAP_PNG_BUF_SIZE);
|
||||
@@ -217,6 +230,20 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate scanline decode buffer in PSRAM (512 bytes — avoids stack
|
||||
// allocation inside the PNGdec callback which is called 256× per tile)
|
||||
if (!_lineBuf) {
|
||||
_lineBuf = (uint16_t*)ps_malloc(MAP_TILE_SIZE * sizeof(uint16_t));
|
||||
if (!_lineBuf) {
|
||||
_lineBuf = (uint16_t*)malloc(MAP_TILE_SIZE * sizeof(uint16_t));
|
||||
}
|
||||
if (_lineBuf) {
|
||||
Serial.println("MapScreen: lineBuf allocated");
|
||||
} else {
|
||||
Serial.println("MapScreen: lineBuf alloc FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
// Detect available zoom levels from SD card directories
|
||||
detectZoomRange();
|
||||
}
|
||||
@@ -356,6 +383,7 @@ private:
|
||||
|
||||
// PNG decode buffer (PSRAM)
|
||||
uint8_t* _pngBuf;
|
||||
uint16_t* _lineBuf; // Scanline RGB565 buffer for PNG decode (PSRAM)
|
||||
bool _tileFound; // Did last tile load succeed?
|
||||
|
||||
// PNGdec instance
|
||||
@@ -381,6 +409,7 @@ private:
|
||||
int offsetY; // Screen Y offset for this tile
|
||||
int viewportY; // Top of viewport (MAP_VIEWPORT_Y)
|
||||
int viewportH; // Height of viewport (MAP_VIEWPORT_H)
|
||||
uint16_t* lineBuf; // Scanline decode buffer (PSRAM-allocated, avoids 512B stack usage per callback)
|
||||
};
|
||||
DrawContext _drawCtx;
|
||||
|
||||
@@ -487,7 +516,7 @@ private:
|
||||
// Load a PNG tile from SD and decode it directly to the display
|
||||
// screenX, screenY = top-left corner on display where this tile goes
|
||||
bool loadAndRenderTile(int tileX, int tileY, int screenX, int screenY) {
|
||||
if (!_pngBuf || !_einkDisplay) return false;
|
||||
if (!_pngBuf || !_lineBuf || !_einkDisplay) return false;
|
||||
|
||||
char path[64];
|
||||
buildTilePath(path, sizeof(path), _zoom, tileX, tileY);
|
||||
@@ -521,6 +550,7 @@ private:
|
||||
_drawCtx.offsetY = screenY;
|
||||
_drawCtx.viewportY = MAP_VIEWPORT_Y;
|
||||
_drawCtx.viewportH = MAP_VIEWPORT_H;
|
||||
_drawCtx.lineBuf = _lineBuf;
|
||||
|
||||
// Open PNG from memory buffer
|
||||
int rc = _png.openRAM(_pngBuf, fileSize, pngDrawCallback);
|
||||
@@ -547,7 +577,7 @@ private:
|
||||
// Uses getLineAsRGB565 with correct (little) endianness for ESP32.
|
||||
static int pngDrawCallback(PNGDRAW* pDraw) {
|
||||
DrawContext* ctx = (DrawContext*)pDraw->pUser;
|
||||
if (!ctx || !ctx->display || !ctx->png) return 0;
|
||||
if (!ctx || !ctx->display || !ctx->png || !ctx->lineBuf) return 0;
|
||||
|
||||
int screenY = ctx->offsetY + pDraw->y;
|
||||
|
||||
@@ -564,9 +594,8 @@ private:
|
||||
}
|
||||
|
||||
uint16_t lineWidth = pDraw->iWidth;
|
||||
uint16_t lineBuf[MAP_TILE_SIZE];
|
||||
if (lineWidth > MAP_TILE_SIZE) lineWidth = MAP_TILE_SIZE;
|
||||
ctx->png->getLineAsRGB565(pDraw, lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF);
|
||||
ctx->png->getLineAsRGB565(pDraw, ctx->lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF);
|
||||
|
||||
for (int x = 0; x < lineWidth; x++) {
|
||||
int screenX = ctx->offsetX + x;
|
||||
@@ -574,7 +603,7 @@ private:
|
||||
|
||||
// RGB565 little-endian on ESP32: standard bit layout
|
||||
// R[15:11] G[10:5] B[4:0]
|
||||
uint16_t pixel = lineBuf[x];
|
||||
uint16_t pixel = ctx->lineBuf[x];
|
||||
|
||||
// For B&W tiles this is 0x0000 (black) or 0xFFFF (white)
|
||||
// Simple threshold on full 16-bit value handles both cleanly
|
||||
@@ -639,6 +668,7 @@ private:
|
||||
} else {
|
||||
missing++;
|
||||
}
|
||||
yield(); // Feed WDT between tiles — each tile can take 1-2s at 80MHz
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <SD.h>
|
||||
#include <vector>
|
||||
#include "Utf8CP437.h"
|
||||
#ifdef ESP32
|
||||
#include <SD.h>
|
||||
#include <vector>
|
||||
#include "Utf8CP437.h"
|
||||
#endif
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
@@ -52,9 +57,11 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Mode _mode;
|
||||
bool _sdReady;
|
||||
bool _initialized;
|
||||
uint8_t _lastFontPref;
|
||||
DisplayDriver* _display;
|
||||
|
||||
// Display layout (calculated once from display metrics)
|
||||
@@ -102,6 +109,10 @@ private:
|
||||
uint32_t _rtcTime; // Unix timestamp (0 = unavailable)
|
||||
int8_t _utcOffset; // UTC offset in hours
|
||||
|
||||
// Callback to get fresh RTC time (set by UITask at init)
|
||||
typedef uint32_t (*TimeGetterFn)();
|
||||
TimeGetterFn _getTimeFn = nullptr;
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
String getFullPath(const String& filename) {
|
||||
@@ -496,7 +507,11 @@ private:
|
||||
int rightX = display.width() - display.getTextWidth(tmp) - 2;
|
||||
|
||||
if (_selectedFile >= 1 && _selectedFile <= (int)_fileList.size()) {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const char* hint = "[Hold:Rename]";
|
||||
#else
|
||||
const char* hint = "[R:Rename]";
|
||||
#endif
|
||||
int hintX = rightX - display.getTextWidth(hint) - 4;
|
||||
display.setCursor(hintX, 0);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
@@ -510,8 +525,8 @@ private:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// File list with "+ New Note" at index 0
|
||||
display.setTextSize(0);
|
||||
int listLineH = 8;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int listLineH = _prefs->smallLineH();
|
||||
int startY = 14;
|
||||
int totalItems = 1 + (int)_fileList.size();
|
||||
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
|
||||
@@ -528,26 +543,24 @@ private:
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
if (i == 0) {
|
||||
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
|
||||
display.print(selected ? "> + New Note" : " + New Note");
|
||||
display.drawTextEllipsized(0, y, display.width() - 4,
|
||||
selected ? "> + New Note" : " + New Note");
|
||||
} else {
|
||||
String line = selected ? "> " : " ";
|
||||
String name = _fileList[i - 1];
|
||||
int maxLen = _charsPerLine - 4;
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name;
|
||||
display.print(line.c_str());
|
||||
line += _fileList[i - 1];
|
||||
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
|
||||
}
|
||||
y += listLineH;
|
||||
}
|
||||
@@ -558,9 +571,13 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("Q:Back W/S:Nav");
|
||||
|
||||
const char* right = "Ent:Open";
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe:Nav");
|
||||
const char* right = "Tap:Open";
|
||||
#else
|
||||
display.print("Q:Bk");
|
||||
const char* right = "Tap/Ent:Open";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
@@ -576,16 +593,20 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bck Ent:Edit");
|
||||
|
||||
const char* right = "Sh+Del:Del";
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap:Edit");
|
||||
const char* right = "Hold:Delete";
|
||||
#else
|
||||
display.print("Q:Bk Ent:Edit");
|
||||
const char* right = "X:Delete";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render current page using tiny font
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
int pageStart = _pageOffsets[_currentPage];
|
||||
@@ -663,9 +684,15 @@ private:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bck Ent:Edit");
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe:Page");
|
||||
|
||||
const char* right = "Sh+Del:Del";
|
||||
const char* right = "Tap:Edit";
|
||||
#else
|
||||
display.print("Q:Bk Ent:Edit");
|
||||
|
||||
const char* right = "X:Delete";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
@@ -696,7 +723,7 @@ private:
|
||||
int textAreaTop = 14;
|
||||
int textAreaBottom = display.height() - 16;
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
// Find cursor line
|
||||
int cursorLine = lineForPos(_cursorPos);
|
||||
@@ -745,7 +772,7 @@ private:
|
||||
|
||||
// If buffer is empty, show cursor at top
|
||||
if (_bufLen == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, textAreaTop);
|
||||
display.print("|");
|
||||
@@ -766,11 +793,25 @@ private:
|
||||
snprintf(status, sizeof(status), "Pg %d/%d", curPage, totalPg);
|
||||
display.print(status);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const char* mid = "Tap:Type";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
#endif
|
||||
|
||||
const char* right;
|
||||
if (_bufLen == 0 || !_dirty) {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
right = "Back";
|
||||
#else
|
||||
right = "Q:Back";
|
||||
#endif
|
||||
} else {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
right = "Hold:Save";
|
||||
#else
|
||||
right = "Sh+Del:Save";
|
||||
#endif
|
||||
}
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
@@ -789,7 +830,7 @@ private:
|
||||
display.setCursor(0, 20);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("From: ");
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
String origDisplay = _renameOriginal;
|
||||
if (origDisplay.length() > 30) origDisplay = origDisplay.substring(0, 27) + "...";
|
||||
display.print(origDisplay.c_str());
|
||||
@@ -800,7 +841,7 @@ private:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("To: ");
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
char displayName[NOTES_RENAME_MAX + 2];
|
||||
snprintf(displayName, sizeof(displayName), "%s|", _renameBuf);
|
||||
@@ -817,9 +858,13 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Cancel");
|
||||
const char* right = "Tap:Confirm";
|
||||
#else
|
||||
display.print("Q:Cancel");
|
||||
|
||||
const char* right = "Ent:Confirm";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
@@ -836,7 +881,7 @@ private:
|
||||
display.setCursor(0, 25);
|
||||
display.print("File:");
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setCursor(0, 38);
|
||||
String nameDisplay = _deleteTarget;
|
||||
if (nameDisplay.length() > 35) nameDisplay = nameDisplay.substring(0, 32) + "...";
|
||||
@@ -852,9 +897,13 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Cancel");
|
||||
const char* right = "Tap:Delete";
|
||||
#else
|
||||
display.print("Q:Cancel");
|
||||
|
||||
const char* right = "Ent:Delete";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
@@ -1033,6 +1082,10 @@ private:
|
||||
// ---- Note Creation ----
|
||||
|
||||
void createNewNote() {
|
||||
// Refresh timestamp at creation time for accurate filenames
|
||||
if (_getTimeFn) {
|
||||
_rtcTime = _getTimeFn();
|
||||
}
|
||||
_currentFile = generateFilename();
|
||||
_buf[0] = '\0';
|
||||
_bufLen = 0;
|
||||
@@ -1044,9 +1097,9 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
NotesScreen(UITask* task)
|
||||
: _task(task), _mode(FILE_LIST),
|
||||
_sdReady(false), _initialized(false), _display(nullptr),
|
||||
NotesScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _mode(FILE_LIST),
|
||||
_sdReady(false), _initialized(false), _lastFontPref(0), _display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5), _footerHeight(14),
|
||||
_editCharsPerLine(20), _editLineHeight(12), _editMaxLines(8),
|
||||
_selectedFile(0), _buf(nullptr), _bufLen(0), _cursorPos(0),
|
||||
@@ -1081,15 +1134,31 @@ public:
|
||||
// ---- Layout Init ----
|
||||
|
||||
void initLayout(DisplayDriver& display) {
|
||||
// Re-init if font preference changed since last layout
|
||||
uint8_t curFont = _prefs ? _prefs->large_font : 0;
|
||||
if (_initialized && curFont != _lastFontPref) {
|
||||
_initialized = false;
|
||||
Serial.println("Notes: font changed, recalculating layout");
|
||||
}
|
||||
if (_initialized) return;
|
||||
_lastFontPref = curFont;
|
||||
_display = &display;
|
||||
|
||||
// Tiny font metrics (for read mode)
|
||||
display.setTextSize(0);
|
||||
// Font metrics (for read mode)
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
|
||||
if (tenCharsW > 0) {
|
||||
_charsPerLine = (display.width() * 10) / tenCharsW;
|
||||
}
|
||||
// Proportional font: use average-width measurement instead of M-width
|
||||
if (_prefs && _prefs->large_font) {
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 60) _charsPerLine = 60;
|
||||
|
||||
@@ -1099,6 +1168,10 @@ public:
|
||||
} else {
|
||||
_lineHeight = 5;
|
||||
}
|
||||
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
|
||||
if (_prefs && _prefs->large_font) {
|
||||
_lineHeight = _prefs->smallLineH();
|
||||
}
|
||||
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _footerHeight;
|
||||
@@ -1124,12 +1197,16 @@ public:
|
||||
|
||||
void setSDReady(bool ready) { _sdReady = ready; }
|
||||
bool isSDReady() const { return _sdReady; }
|
||||
bool isDirty() const { return _dirty; }
|
||||
void triggerSaveAndExit() { saveAndExit(); }
|
||||
|
||||
void setTimestamp(uint32_t rtcTime, int8_t utcOffset) {
|
||||
_rtcTime = rtcTime;
|
||||
_utcOffset = utcOffset;
|
||||
}
|
||||
|
||||
void setTimeGetter(TimeGetterFn fn) { _getTimeFn = fn; }
|
||||
|
||||
void enter(DisplayDriver& display) {
|
||||
initLayout(display);
|
||||
scanFiles();
|
||||
@@ -1145,7 +1222,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) ----
|
||||
@@ -1296,4 +1372,42 @@ public:
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
#else // !ESP32
|
||||
|
||||
// Non-ESP32 stub: Meshpocket / T-Echo Card have no SD card hardware, so the
|
||||
// full notes editor (which depends on SD.h) can't work here. This stub keeps
|
||||
// UITask.cpp compilable by providing the same public interface as no-ops.
|
||||
// Navigating to notes from the home screen on a Meshpocket will just render
|
||||
// a placeholder message and do nothing.
|
||||
class NotesScreen : public UIScreen {
|
||||
public:
|
||||
typedef uint32_t (*TimeGetterFn)();
|
||||
|
||||
NotesScreen(UITask* task, NodePrefs* prefs = nullptr) {
|
||||
(void)task; (void)prefs;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 20);
|
||||
display.print("Notes: SD card required");
|
||||
display.setCursor(0, 30);
|
||||
display.print("(not available)");
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override { (void)c; return false; }
|
||||
bool isEditing() const { return false; }
|
||||
void triggerSaveAndExit() {}
|
||||
void exitNotes() {}
|
||||
void enter(DisplayDriver& display) { (void)display; }
|
||||
void setTimestamp(uint32_t rtcTime, int8_t utcOffset) {
|
||||
(void)rtcTime; (void)utcOffset;
|
||||
}
|
||||
void setTimeGetter(TimeGetterFn fn) { (void)fn; }
|
||||
};
|
||||
|
||||
#endif // ESP32
|
||||
@@ -0,0 +1,805 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <MeshCore.h>
|
||||
#include <Packet.h>
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
class MyMesh;
|
||||
extern MyMesh the_mesh;
|
||||
|
||||
class PathEditorScreen : public UIScreen {
|
||||
public:
|
||||
enum EditorState {
|
||||
STATE_MAIN,
|
||||
STATE_PICK_HOP
|
||||
};
|
||||
|
||||
// Main-state menu items (dynamic, built each render)
|
||||
enum MenuItem {
|
||||
MENU_MODE = 0, // "Mode: 1B/hop" or "Mode: 2B/hop"
|
||||
// After mode: hop lines (MENU_HOP_BASE + i)
|
||||
// Then: action items
|
||||
MENU_HOP_BASE = 1,
|
||||
// Dynamic items after hops:
|
||||
MENU_ADD_HOP = 100,
|
||||
MENU_SET_DIRECT,
|
||||
MENU_REMOVE_LAST,
|
||||
MENU_CLEAR_PATH,
|
||||
MENU_SAVE_EXIT
|
||||
};
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
|
||||
int _contactIdx; // Index into contact table
|
||||
char _contactName[32]; // Contact name for header
|
||||
|
||||
EditorState _state;
|
||||
int _menuSel; // Selected menu item index (0-based in visible list)
|
||||
int _menuCount; // Total visible menu items
|
||||
|
||||
// Path being edited (working copy)
|
||||
uint8_t _pathBuf[MAX_PATH_SIZE];
|
||||
uint8_t _pathLen; // Encoded: bits[7:6]=mode, bits[5:0]=hops
|
||||
int _hopCount; // Decoded hop count
|
||||
int _bytesPerHop; // 1 or 2
|
||||
|
||||
// Repeater picker state
|
||||
static const int MAX_REPEATERS = 200;
|
||||
uint16_t* _repIdx; // Indices into contact table (PSRAM)
|
||||
int _repCount; // Number of repeaters found
|
||||
int _repSel; // Selected repeater in picker
|
||||
int _repScroll; // Scroll offset in picker
|
||||
|
||||
bool _dirty; // Path has been modified
|
||||
bool _wantExit; // Set by Save & Exit — caller should navigate back
|
||||
bool _directLocked; // True = path is explicitly set to direct (0 hops, locked)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
void decodePath() {
|
||||
_hopCount = _pathLen & 0x3F;
|
||||
uint8_t mode = (_pathLen >> 6) & 0x03;
|
||||
_bytesPerHop = mode + 1;
|
||||
}
|
||||
|
||||
uint8_t encodePath() const {
|
||||
uint8_t mode = (_bytesPerHop - 1) & 0x03;
|
||||
return (mode << 6) | (_hopCount & 0x3F);
|
||||
}
|
||||
|
||||
void buildRepeaterList() {
|
||||
_repCount = 0;
|
||||
uint32_t numContacts = the_mesh.getNumContacts();
|
||||
ContactInfo c;
|
||||
for (uint32_t i = 0; i < numContacts && _repCount < MAX_REPEATERS; i++) {
|
||||
if (the_mesh.getContactByIdx(i, c)) {
|
||||
if (c.type == ADV_TYPE_REPEATER) {
|
||||
_repIdx[_repCount++] = (uint16_t)i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look up a contact name by matching pub_key prefix bytes
|
||||
bool findNameForHop(int hopIndex, char* name, size_t nameLen) const {
|
||||
if (hopIndex < 0 || hopIndex >= _hopCount) return false;
|
||||
int offset = hopIndex * _bytesPerHop;
|
||||
uint32_t numContacts = the_mesh.getNumContacts();
|
||||
ContactInfo c;
|
||||
for (uint32_t i = 0; i < numContacts; i++) {
|
||||
if (the_mesh.getContactByIdx(i, c)) {
|
||||
bool match = true;
|
||||
for (int b = 0; b < _bytesPerHop; b++) {
|
||||
if (c.id.pub_key[b] != _pathBuf[offset + b]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
strncpy(name, c.name, nameLen);
|
||||
name[nameLen - 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build the visible menu items list and return count
|
||||
// Menu layout:
|
||||
// 0: Mode selector
|
||||
// 1..hopCount: each hop
|
||||
// hopCount+1: Add hop
|
||||
// hopCount+2: Remove last (only if hops > 0)
|
||||
// hopCount+2 or +3: Clear path (only if custom path flag set or hops > 0)
|
||||
// last: Save & Exit
|
||||
int buildMenuCount() const {
|
||||
int count = 1; // Mode selector
|
||||
count += _hopCount; // One per hop
|
||||
if (_hopCount < 8) count++; // Add hop (max 8 hops)
|
||||
count++; // Set Direct (always visible)
|
||||
if (_hopCount > 0) count++; // Remove last
|
||||
if (_hopCount > 0 || _directLocked || isCustomPathSet()) count++; // Clear path
|
||||
count++; // Save & Exit
|
||||
return count;
|
||||
}
|
||||
|
||||
// Map a menu index to a MenuItem enum
|
||||
MenuItem menuItemAt(int idx) const {
|
||||
if (idx == 0) return MENU_MODE;
|
||||
int pos = 1;
|
||||
// Hop lines
|
||||
for (int h = 0; h < _hopCount; h++) {
|
||||
if (idx == pos) return (MenuItem)(MENU_HOP_BASE + h);
|
||||
pos++;
|
||||
}
|
||||
// Add hop
|
||||
if (_hopCount < 8) {
|
||||
if (idx == pos) return MENU_ADD_HOP;
|
||||
pos++;
|
||||
}
|
||||
// Set Direct
|
||||
if (idx == pos) return MENU_SET_DIRECT;
|
||||
pos++;
|
||||
// Remove last
|
||||
if (_hopCount > 0) {
|
||||
if (idx == pos) return MENU_REMOVE_LAST;
|
||||
pos++;
|
||||
}
|
||||
// Clear path
|
||||
if (_hopCount > 0 || _directLocked || isCustomPathSet()) {
|
||||
if (idx == pos) return MENU_CLEAR_PATH;
|
||||
pos++;
|
||||
}
|
||||
// Save & Exit
|
||||
return MENU_SAVE_EXIT;
|
||||
}
|
||||
|
||||
bool isCustomPathSet() const {
|
||||
ContactInfo c;
|
||||
if (!the_mesh.getContactByIdx(_contactIdx, c)) return false;
|
||||
return (c.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
|
||||
}
|
||||
|
||||
public:
|
||||
PathEditorScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _contactIdx(-1), _state(STATE_MAIN),
|
||||
_menuSel(0), _menuCount(1), _pathLen(0), _hopCount(0),
|
||||
_bytesPerHop(1), _repCount(0), _repSel(0), _repScroll(0),
|
||||
_dirty(false), _wantExit(false), _directLocked(false) {
|
||||
memset(_contactName, 0, sizeof(_contactName));
|
||||
memset(_pathBuf, 0, sizeof(_pathBuf));
|
||||
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
|
||||
_repIdx = (uint16_t*)ps_calloc(MAX_REPEATERS, sizeof(uint16_t));
|
||||
#else
|
||||
_repIdx = new uint16_t[MAX_REPEATERS]();
|
||||
#endif
|
||||
}
|
||||
|
||||
void openForContact(int contactIdx) {
|
||||
_contactIdx = contactIdx;
|
||||
_state = STATE_MAIN;
|
||||
_menuSel = 0;
|
||||
_repSel = 0;
|
||||
_repScroll = 0;
|
||||
_dirty = false;
|
||||
_wantExit = false;
|
||||
_directLocked = false;
|
||||
|
||||
// Load contact info
|
||||
ContactInfo c;
|
||||
if (the_mesh.getContactByIdx(contactIdx, c)) {
|
||||
strncpy(_contactName, c.name, sizeof(_contactName) - 1);
|
||||
_contactName[sizeof(_contactName) - 1] = '\0';
|
||||
|
||||
// Copy current path
|
||||
if (c.out_path_len != OUT_PATH_UNKNOWN) {
|
||||
_pathLen = c.out_path_len;
|
||||
decodePath();
|
||||
int byteLen = _hopCount * _bytesPerHop;
|
||||
if (byteLen > MAX_PATH_SIZE) byteLen = MAX_PATH_SIZE;
|
||||
memcpy(_pathBuf, c.out_path, byteLen);
|
||||
// Detect existing direct-locked path
|
||||
if (_hopCount == 0 && (c.flags & CONTACT_FLAG_CUSTOM_PATH)) {
|
||||
_directLocked = true;
|
||||
}
|
||||
} else {
|
||||
_pathLen = 0;
|
||||
_hopCount = 0;
|
||||
_bytesPerHop = 1;
|
||||
memset(_pathBuf, 0, sizeof(_pathBuf));
|
||||
}
|
||||
} else {
|
||||
strcpy(_contactName, "Unknown");
|
||||
_pathLen = 0;
|
||||
_hopCount = 0;
|
||||
_bytesPerHop = 1;
|
||||
}
|
||||
|
||||
_menuCount = buildMenuCount();
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
if (_state == STATE_PICK_HOP) {
|
||||
return renderPicker(display);
|
||||
}
|
||||
return renderMain(display);
|
||||
}
|
||||
|
||||
int renderMain(DisplayDriver& display) {
|
||||
char tmp[64];
|
||||
|
||||
// === Header ===
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
snprintf(tmp, sizeof(tmp), "Path: %s", _contactName);
|
||||
// Truncate if too long
|
||||
if (display.getTextWidth(tmp) > display.width() - 4) {
|
||||
snprintf(tmp, sizeof(tmp), "Path: %.12s..", _contactName);
|
||||
}
|
||||
display.print(tmp);
|
||||
|
||||
// Show lock icon or dirty indicator on right
|
||||
if (_dirty) {
|
||||
const char* mod = "[*]";
|
||||
display.setCursor(display.width() - display.getTextWidth(mod) - 2, 0);
|
||||
display.print(mod);
|
||||
} else if (isCustomPathSet()) {
|
||||
const char* lock = "[L]";
|
||||
display.setCursor(display.width() - display.getTextWidth(lock) - 2, 0);
|
||||
display.print(lock);
|
||||
}
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body ===
|
||||
display.setTextSize(0);
|
||||
int lineH = 9;
|
||||
int headerH = 14;
|
||||
int footerH = 14;
|
||||
int maxY = display.height() - footerH;
|
||||
int y = headerH;
|
||||
|
||||
_menuCount = buildMenuCount();
|
||||
|
||||
// Center visible window around selected item
|
||||
int maxVisible = (maxY - headerH) / lineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_menuSel - maxVisible / 2, _menuCount - maxVisible));
|
||||
if (startIdx < 0) startIdx = 0;
|
||||
int endIdx = min(_menuCount, startIdx + maxVisible);
|
||||
|
||||
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
|
||||
bool selected = (i == _menuSel);
|
||||
MenuItem item = menuItemAt(i);
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(2, y);
|
||||
char prefix = selected ? '>' : ' ';
|
||||
|
||||
switch (item) {
|
||||
case MENU_MODE:
|
||||
if (_directLocked) {
|
||||
snprintf(tmp, sizeof(tmp), "%c Mode: DIRECT", prefix);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "%c Mode: %dB/hop", prefix, _bytesPerHop);
|
||||
}
|
||||
display.print(tmp);
|
||||
// Show hint on right
|
||||
if (!_directLocked) {
|
||||
const char* hint = "(A/D)";
|
||||
display.setCursor(display.width() - display.getTextWidth(hint) - 4, y);
|
||||
display.print(hint);
|
||||
}
|
||||
break;
|
||||
|
||||
case MENU_ADD_HOP:
|
||||
snprintf(tmp, sizeof(tmp), "%c + Add hop...", prefix);
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case MENU_SET_DIRECT:
|
||||
if (_directLocked) {
|
||||
snprintf(tmp, sizeof(tmp), "%c * Direct (set)", prefix);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "%c * Set Direct", prefix);
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case MENU_REMOVE_LAST:
|
||||
snprintf(tmp, sizeof(tmp), "%c - Remove last hop", prefix);
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case MENU_CLEAR_PATH:
|
||||
snprintf(tmp, sizeof(tmp), "%c Clear custom path", prefix);
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case MENU_SAVE_EXIT:
|
||||
snprintf(tmp, sizeof(tmp), "%c Save & Exit", prefix);
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Hop line: MENU_HOP_BASE + hopIndex
|
||||
if (item >= MENU_HOP_BASE && item < MENU_HOP_BASE + 64) {
|
||||
int hopIdx = item - MENU_HOP_BASE;
|
||||
char hopName[24];
|
||||
int offset = hopIdx * _bytesPerHop;
|
||||
|
||||
if (findNameForHop(hopIdx, hopName, sizeof(hopName))) {
|
||||
if (_bytesPerHop == 1) {
|
||||
snprintf(tmp, sizeof(tmp), "%c %d: %s (%02X)", prefix, hopIdx + 1,
|
||||
hopName, _pathBuf[offset]);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "%c %d: %s (%02X%02X)", prefix, hopIdx + 1,
|
||||
hopName, _pathBuf[offset], _pathBuf[offset + 1]);
|
||||
}
|
||||
} else {
|
||||
if (_bytesPerHop == 1) {
|
||||
snprintf(tmp, sizeof(tmp), "%c %d: ??? (%02X)", prefix, hopIdx + 1,
|
||||
_pathBuf[offset]);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "%c %d: ??? (%02X%02X)", prefix, hopIdx + 1,
|
||||
_pathBuf[offset], _pathBuf[offset + 1]);
|
||||
}
|
||||
}
|
||||
display.drawTextEllipsized(2, y, display.width() - 4, tmp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
y += lineH;
|
||||
}
|
||||
|
||||
// === Footer ===
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Swipe:Nav");
|
||||
const char* right = "Hold:Select";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bk W/S:Nav");
|
||||
const char* right = "Enter:Sel";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
return 5000;
|
||||
}
|
||||
|
||||
int renderPicker(DisplayDriver& display) {
|
||||
char tmp[64];
|
||||
|
||||
// === Header ===
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
snprintf(tmp, sizeof(tmp), "Select Repeater (%d)", _repCount);
|
||||
display.print(tmp);
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body ===
|
||||
display.setTextSize(0);
|
||||
int lineH = 9;
|
||||
int headerH = 14;
|
||||
int footerH = 14;
|
||||
int maxY = display.height() - footerH;
|
||||
int y = headerH;
|
||||
|
||||
if (_repCount == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
display.print("No repeaters in contacts");
|
||||
display.setCursor(0, y + lineH);
|
||||
display.print("Add repeaters first");
|
||||
} else {
|
||||
int maxVisible = (maxY - headerH) / lineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_repSel - maxVisible / 2, _repCount - maxVisible));
|
||||
if (startIdx < 0) startIdx = 0;
|
||||
int endIdx = min(_repCount, startIdx + maxVisible);
|
||||
|
||||
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
|
||||
ContactInfo c;
|
||||
if (!the_mesh.getContactByIdx(_repIdx[i], c)) continue;
|
||||
|
||||
bool selected = (i == _repSel);
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(2, y);
|
||||
char prefix = selected ? '>' : ' ';
|
||||
|
||||
if (_bytesPerHop == 1) {
|
||||
snprintf(tmp, sizeof(tmp), "%c %s (%02X)", prefix, c.name, c.id.pub_key[0]);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "%c %s (%02X%02X)", prefix, c.name,
|
||||
c.id.pub_key[0], c.id.pub_key[1]);
|
||||
}
|
||||
display.drawTextEllipsized(2, y, display.width() - 4, tmp);
|
||||
|
||||
y += lineH;
|
||||
}
|
||||
}
|
||||
|
||||
// === Footer ===
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Swipe:Scroll");
|
||||
const char* right = "Hold:Add Back:Cancel";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Cancel W/S:Scroll");
|
||||
const char* right = "Enter:Add";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (_state == STATE_PICK_HOP) {
|
||||
return handlePickerInput(c);
|
||||
}
|
||||
return handleMainInput(c);
|
||||
}
|
||||
|
||||
bool handleMainInput(char c) {
|
||||
// W - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_menuSel > 0) {
|
||||
_menuSel--;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// S - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_menuSel < _menuCount - 1) {
|
||||
_menuSel++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A/D — toggle mode (only when Mode item is selected and not direct-locked)
|
||||
if (c == 'a' || c == 'A' || c == 'd' || c == 'D') {
|
||||
MenuItem item = menuItemAt(_menuSel);
|
||||
if (item == MENU_MODE && !_directLocked) {
|
||||
// Toggle between 1-byte and 2-byte
|
||||
if (_bytesPerHop == 1) {
|
||||
switchMode(2);
|
||||
} else {
|
||||
switchMode(1);
|
||||
}
|
||||
_dirty = true;
|
||||
_menuCount = buildMenuCount();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter - select
|
||||
if (c == 13 || c == KEY_ENTER || c == '\r') {
|
||||
MenuItem item = menuItemAt(_menuSel);
|
||||
|
||||
switch (item) {
|
||||
case MENU_MODE:
|
||||
// Toggle mode on Enter too (no-op if direct locked)
|
||||
if (!_directLocked) {
|
||||
if (_bytesPerHop == 1) {
|
||||
switchMode(2);
|
||||
} else {
|
||||
switchMode(1);
|
||||
}
|
||||
_dirty = true;
|
||||
_menuCount = buildMenuCount();
|
||||
}
|
||||
return true;
|
||||
|
||||
case MENU_ADD_HOP:
|
||||
// Enter picker mode — adding a hop clears direct lock
|
||||
_directLocked = false;
|
||||
buildRepeaterList();
|
||||
_repSel = 0;
|
||||
_repScroll = 0;
|
||||
_state = STATE_PICK_HOP;
|
||||
return true;
|
||||
|
||||
case MENU_SET_DIRECT:
|
||||
// Set path to direct (0 hops, locked)
|
||||
_hopCount = 0;
|
||||
_pathLen = 0;
|
||||
memset(_pathBuf, 0, sizeof(_pathBuf));
|
||||
_directLocked = true;
|
||||
_dirty = true;
|
||||
_menuCount = buildMenuCount();
|
||||
return true;
|
||||
|
||||
case MENU_REMOVE_LAST:
|
||||
if (_hopCount > 0) {
|
||||
_hopCount--;
|
||||
_pathLen = encodePath();
|
||||
_dirty = true;
|
||||
_menuCount = buildMenuCount();
|
||||
// Clamp selection
|
||||
if (_menuSel >= _menuCount) _menuSel = _menuCount - 1;
|
||||
}
|
||||
return true;
|
||||
|
||||
case MENU_CLEAR_PATH:
|
||||
_hopCount = 0;
|
||||
_pathLen = 0;
|
||||
_directLocked = false;
|
||||
memset(_pathBuf, 0, sizeof(_pathBuf));
|
||||
_dirty = true;
|
||||
_menuCount = buildMenuCount();
|
||||
_menuSel = 0;
|
||||
return true;
|
||||
|
||||
case MENU_SAVE_EXIT:
|
||||
savePath();
|
||||
_wantExit = true; // Signal to main.cpp to navigate back to contacts
|
||||
return true;
|
||||
|
||||
default:
|
||||
// Hop line — no action (could add remove-specific-hop later)
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Q - back (discard changes or prompt?)
|
||||
// For simplicity, just go back without saving
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Return to contacts screen without saving
|
||||
// The UITask will handle this via the key falling through
|
||||
return false; // Let UITask handle Q as back
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool handlePickerInput(char c) {
|
||||
// W - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_repSel > 0) {
|
||||
_repSel--;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// S - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_repSel < _repCount - 1) {
|
||||
_repSel++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter - add selected repeater as hop
|
||||
if (c == 13 || c == KEY_ENTER || c == '\r') {
|
||||
if (_repCount > 0 && _repSel >= 0 && _repSel < _repCount) {
|
||||
addHopFromContact(_repIdx[_repSel]);
|
||||
}
|
||||
_state = STATE_MAIN;
|
||||
_menuCount = buildMenuCount();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Q - cancel picker, return to main
|
||||
if (c == 'q' || c == 'Q') {
|
||||
_state = STATE_MAIN;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tap-to-select for T5S3 touch
|
||||
int selectRowAtVY(int vy) {
|
||||
if (_state == STATE_PICK_HOP) {
|
||||
return selectPickerRowAtVY(vy);
|
||||
}
|
||||
return selectMainRowAtVY(vy);
|
||||
}
|
||||
|
||||
int selectMainRowAtVY(int vy) {
|
||||
if (_menuCount == 0) return 0;
|
||||
const int headerH = 14, footerH = 14, lineH = 9;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + 5;
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
int maxVisible = (128 - headerH - footerH) / lineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_menuSel - maxVisible / 2, _menuCount - maxVisible));
|
||||
if (startIdx < 0) startIdx = 0;
|
||||
|
||||
int tappedRow = startIdx + (vy - bodyTop) / lineH;
|
||||
if (tappedRow < 0 || tappedRow >= _menuCount) return 0;
|
||||
if (tappedRow == _menuSel) return 2;
|
||||
_menuSel = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int selectPickerRowAtVY(int vy) {
|
||||
if (_repCount == 0) return 0;
|
||||
const int headerH = 14, footerH = 14, lineH = 9;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + 5;
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
int maxVisible = (128 - headerH - footerH) / lineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_repSel - maxVisible / 2, _repCount - maxVisible));
|
||||
if (startIdx < 0) startIdx = 0;
|
||||
|
||||
int tappedRow = startIdx + (vy - bodyTop) / lineH;
|
||||
if (tappedRow < 0 || tappedRow >= _repCount) return 0;
|
||||
if (tappedRow == _repSel) return 2;
|
||||
_repSel = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
EditorState getState() const { return _state; }
|
||||
bool isDirty() const { return _dirty; }
|
||||
bool wantsExit() const { return _wantExit; }
|
||||
|
||||
private:
|
||||
void switchMode(int newBytesPerHop) {
|
||||
if (newBytesPerHop == _bytesPerHop) return;
|
||||
|
||||
if (_hopCount > 0) {
|
||||
// Rebuild path buffer for new mode
|
||||
// We need the full pub_keys to re-extract the right prefix bytes
|
||||
uint8_t newBuf[MAX_PATH_SIZE];
|
||||
memset(newBuf, 0, sizeof(newBuf));
|
||||
int newHopCount = 0;
|
||||
|
||||
for (int h = 0; h < _hopCount && newHopCount < 8; h++) {
|
||||
int oldOffset = h * _bytesPerHop;
|
||||
// Try to find the contact that matches this hop
|
||||
uint32_t numContacts = the_mesh.getNumContacts();
|
||||
ContactInfo c;
|
||||
bool found = false;
|
||||
for (uint32_t i = 0; i < numContacts; i++) {
|
||||
if (the_mesh.getContactByIdx(i, c)) {
|
||||
bool match = true;
|
||||
for (int b = 0; b < _bytesPerHop; b++) {
|
||||
if (c.id.pub_key[b] != _pathBuf[oldOffset + b]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
// Found the contact — copy new prefix size
|
||||
int newOffset = newHopCount * newBytesPerHop;
|
||||
for (int b = 0; b < newBytesPerHop; b++) {
|
||||
newBuf[newOffset + b] = c.id.pub_key[b];
|
||||
}
|
||||
newHopCount++;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// Contact not found — copy what we can
|
||||
int newOffset = newHopCount * newBytesPerHop;
|
||||
int oldOff = h * _bytesPerHop;
|
||||
for (int b = 0; b < newBytesPerHop; b++) {
|
||||
if (b < _bytesPerHop) {
|
||||
newBuf[newOffset + b] = _pathBuf[oldOff + b];
|
||||
} else {
|
||||
newBuf[newOffset + b] = 0x00; // pad with zero
|
||||
}
|
||||
}
|
||||
newHopCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_hopCount = newHopCount;
|
||||
memcpy(_pathBuf, newBuf, sizeof(newBuf));
|
||||
}
|
||||
|
||||
_bytesPerHop = newBytesPerHop;
|
||||
_pathLen = encodePath();
|
||||
}
|
||||
|
||||
void addHopFromContact(uint16_t contactTableIdx) {
|
||||
if (_hopCount >= 8) return;
|
||||
ContactInfo c;
|
||||
if (!the_mesh.getContactByIdx(contactTableIdx, c)) return;
|
||||
|
||||
int offset = _hopCount * _bytesPerHop;
|
||||
if (offset + _bytesPerHop > MAX_PATH_SIZE) return;
|
||||
|
||||
for (int b = 0; b < _bytesPerHop; b++) {
|
||||
_pathBuf[offset + b] = c.id.pub_key[b];
|
||||
}
|
||||
_hopCount++;
|
||||
_pathLen = encodePath();
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
void savePath() {
|
||||
if (_contactIdx < 0) return;
|
||||
|
||||
if (_directLocked) {
|
||||
// Set as direct (0 hops) with lock — prevents flood routing
|
||||
the_mesh.setCustomPath(_contactIdx, _pathBuf, 0, true);
|
||||
Serial.printf("PathEditor: set DIRECT path for contact %d (%s)\n",
|
||||
_contactIdx, _contactName);
|
||||
} else if (_hopCount > 0) {
|
||||
// Set custom path with lock
|
||||
the_mesh.setCustomPath(_contactIdx, _pathBuf, encodePath(), true);
|
||||
Serial.printf("PathEditor: saved %d-hop %dB/hop path for contact %d (%s)\n",
|
||||
_hopCount, _bytesPerHop, _contactIdx, _contactName);
|
||||
} else {
|
||||
// Clear custom path — revert to auto-discovery
|
||||
the_mesh.clearCustomPath(_contactIdx);
|
||||
Serial.printf("PathEditor: cleared custom path for contact %d (%s)\n",
|
||||
_contactIdx, _contactName);
|
||||
}
|
||||
|
||||
// Trigger contact save to SD
|
||||
the_mesh.saveContacts();
|
||||
_dirty = false;
|
||||
}
|
||||
};
|
||||
@@ -475,6 +475,7 @@ public:
|
||||
|
||||
int getContactIdx() const { return _contactIdx; }
|
||||
AdminState getState() const { return _state; }
|
||||
uint8_t getPermissions() const { return _permissions; }
|
||||
|
||||
void onLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
_waitingForLogin = false;
|
||||
@@ -561,7 +562,9 @@ public:
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
snprintf(tmp, sizeof(tmp), "Admin: %.16s", _repeaterName);
|
||||
const char* hdrPrefix = (_state == STATE_PASSWORD_ENTRY || _state == STATE_LOGGING_IN)
|
||||
? "Login" : "Admin";
|
||||
snprintf(tmp, sizeof(tmp), "%s: %.16s", hdrPrefix, _repeaterName);
|
||||
display.print(tmp);
|
||||
|
||||
if (_state >= STATE_CATEGORY_MENU && _state <= STATE_RESPONSE_VIEW) {
|
||||
@@ -598,41 +601,77 @@ public:
|
||||
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Exit");
|
||||
renderFooterRight(display, footerY, "Hold:Type");
|
||||
#else
|
||||
display.print("Sh+Del:Exit");
|
||||
renderFooterRight(display, footerY, "Ent:Login");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Cancel");
|
||||
#else
|
||||
display.print("Sh+Del:Cancel");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_CATEGORY_MENU:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Exit");
|
||||
renderFooterMidRight(display, footerY, "Back:Exit", "Tap:Open", "Swipe:Sel");
|
||||
#else
|
||||
display.print("Sh+Del:Exit");
|
||||
renderFooterMidRight(display, footerY, "Sh+Del:Exit", "Ent:Open", "W/S:Sel");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_COMMAND_MENU:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Back");
|
||||
renderFooterMidRight(display, footerY, "Back:Back", "Tap:Run", "Swipe:Sel");
|
||||
#else
|
||||
display.print("Sh+Del:Back");
|
||||
renderFooterMidRight(display, footerY, "Sh+Del:Back", "Ent:Run", "W/S:Sel");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_PARAM_ENTRY:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Cancel");
|
||||
renderFooterRight(display, footerY, "Tap:Send");
|
||||
#else
|
||||
display.print("Sh+Del:Cancel");
|
||||
renderFooterRight(display, footerY, "Ent:Send");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_CONFIRM:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:No");
|
||||
renderFooterRight(display, footerY, "Tap:Yes");
|
||||
#else
|
||||
display.print("Sh+Del:No");
|
||||
renderFooterRight(display, footerY, "Ent:Yes");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case STATE_RESPONSE_VIEW:
|
||||
case STATE_ERROR:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Back");
|
||||
if (_responseTotalLines > bodyHeight / 9) {
|
||||
renderFooterRight(display, footerY, "Swipe:Scroll");
|
||||
}
|
||||
#else
|
||||
display.print("Sh+Del:Back");
|
||||
if (_responseTotalLines > bodyHeight / 9) {
|
||||
renderFooterRight(display, footerY, "W/S:Scrll");
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -738,8 +777,8 @@ private:
|
||||
// =====================================================================
|
||||
|
||||
void renderCategoryMenu(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
|
||||
// Clock drift info line
|
||||
if (_serverTime > 0) {
|
||||
@@ -823,8 +862,8 @@ private:
|
||||
// =====================================================================
|
||||
|
||||
void renderCommandMenu(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
const AdminCategoryDef& cat = CATEGORIES[_catSel];
|
||||
|
||||
// Category title
|
||||
@@ -986,7 +1025,7 @@ private:
|
||||
if (_pendingCmd) display.print(_pendingCmd->label);
|
||||
|
||||
y += 14;
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Show the param value if one was collected
|
||||
@@ -994,14 +1033,18 @@ private:
|
||||
char preview[80];
|
||||
snprintf(preview, sizeof(preview), "Value: %s", _paramBuf);
|
||||
display.print(preview);
|
||||
y += 10;
|
||||
y += the_mesh.getNodePrefs()->smallLineH() + 1;
|
||||
display.setCursor(0, y);
|
||||
}
|
||||
|
||||
if (_pendingCmd && (_pendingCmd->flags & CMDF_EXPECT_TIMEOUT)) {
|
||||
display.print("Timeout response is normal.");
|
||||
} else {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap=Yes Back=No");
|
||||
#else
|
||||
display.print("Enter=Yes Sh+Del=No");
|
||||
#endif
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
@@ -1028,8 +1071,8 @@ private:
|
||||
// =====================================================================
|
||||
|
||||
void renderResponse(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
|
||||
display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT);
|
||||
|
||||
@@ -1120,7 +1163,11 @@ private:
|
||||
bool selected, const char* label, bool warn) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else if (warn) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
@@ -1169,9 +1216,10 @@ inline bool RepeaterAdminScreen::doLogin() {
|
||||
if (the_mesh.uiLoginToRepeater(_contactIdx, _password, timeout_ms)) {
|
||||
_state = STATE_LOGGING_IN;
|
||||
_cmdSentAt = millis();
|
||||
// Add a 1.5s buffer over the mesh estimate; fall back to ADMIN_TIMEOUT_MS
|
||||
// if the estimate came back zero for any reason.
|
||||
_loginTimeoutMs = (timeout_ms > 0) ? timeout_ms + 1500 : ADMIN_TIMEOUT_MS;
|
||||
// Add a 5s buffer over the mesh estimate to account for blocking e-ink
|
||||
// refreshes (FastEPD ~1-2s per frame, VKB dismiss + login render = 2-3 frames).
|
||||
// Fall back to ADMIN_TIMEOUT_MS if the estimate came back zero.
|
||||
_loginTimeoutMs = (timeout_ms > 0) ? timeout_ms + 5000 : ADMIN_TIMEOUT_MS;
|
||||
_waitingForLogin = true;
|
||||
return true;
|
||||
} else {
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
#include "ModemManager.h"
|
||||
#include "SMSStore.h"
|
||||
#include "SMSContacts.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Limits
|
||||
#define SMS_INBOX_PAGE_SIZE 4
|
||||
@@ -51,6 +52,7 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
SubView _view;
|
||||
|
||||
// App menu state
|
||||
@@ -117,8 +119,8 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
SMSScreen(UITask* task)
|
||||
: _task(task), _view(APP_MENU)
|
||||
SMSScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _view(APP_MENU)
|
||||
, _menuCursor(0)
|
||||
, _convCount(0), _inboxCursor(0), _inboxScrollTop(0)
|
||||
, _msgCount(0), _msgScrollPos(0)
|
||||
@@ -276,7 +278,7 @@ public:
|
||||
|
||||
// Show modem state text if not ready
|
||||
if (ms != ModemState::READY && ms != ModemState::SENDING_SMS) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
const char* label = ModemManager::stateToString(ms);
|
||||
uint16_t labelW = display.getTextWidth(label);
|
||||
@@ -356,7 +358,7 @@ public:
|
||||
|
||||
// Modem status indicator
|
||||
ModemState ms = modemManager.getState();
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setCursor(4, y + lineHeight + 8);
|
||||
if (ms == ModemState::OFF || ms == ModemState::POWERING_ON ||
|
||||
ms == ModemState::INITIALIZING) {
|
||||
@@ -483,7 +485,7 @@ public:
|
||||
bool isAction = (row == 4); // Bottom row has action buttons
|
||||
|
||||
if (isAction) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
if (col == 2 && _phoneInputPos > 0) {
|
||||
display.setColor(DisplayDriver::GREEN); // CALL
|
||||
} else if (col == 1) {
|
||||
@@ -544,7 +546,7 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (_convCount == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 20);
|
||||
display.print("No conversations");
|
||||
@@ -560,8 +562,8 @@ public:
|
||||
}
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 10;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int lineHeight = _prefs->smallLineH() + 1;
|
||||
int y = 14;
|
||||
|
||||
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
|
||||
@@ -643,14 +645,14 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (_msgCount == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 25);
|
||||
display.print("No messages");
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 10;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int lineHeight = _prefs->smallLineH() + 1;
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
|
||||
@@ -764,12 +766,13 @@ public:
|
||||
// Message body
|
||||
display.setCursor(0, 14);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM");
|
||||
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
|
||||
if (charsPerLine < 12) charsPerLine = 12;
|
||||
|
||||
int composeLH = _prefs->smallLineH() + 1;
|
||||
int y = 14;
|
||||
int x = 0;
|
||||
char cs[2] = {0, 0};
|
||||
@@ -780,7 +783,7 @@ public:
|
||||
x++;
|
||||
if (x >= charsPerLine) {
|
||||
x = 0;
|
||||
y += 10;
|
||||
y += composeLH;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,7 +830,7 @@ public:
|
||||
int cnt = smsContacts.count();
|
||||
|
||||
if (cnt == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 25);
|
||||
display.print("No contacts saved");
|
||||
@@ -837,8 +840,8 @@ public:
|
||||
display.print("and press A to add");
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 10;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int lineHeight = _prefs->smallLineH() + 1;
|
||||
int y = 14;
|
||||
|
||||
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
|
||||
@@ -900,7 +903,7 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// Phone number (read-only)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 16);
|
||||
display.print("Phone: ");
|
||||
@@ -956,7 +959,7 @@ public:
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
@@ -1011,7 +1014,7 @@ public:
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
@@ -1070,7 +1073,7 @@ public:
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
@@ -1090,7 +1093,7 @@ public:
|
||||
display.print(timeBuf);
|
||||
|
||||
// Volume (left-aligned)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
char volLabel[12];
|
||||
snprintf(volLabel, sizeof(volLabel), "Vol: %d/5", _callVolume);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,20 +2,25 @@
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <SD.h>
|
||||
#include <vector>
|
||||
#include "Utf8CP437.h"
|
||||
#include "EpubProcessor.h"
|
||||
#ifdef ESP32
|
||||
#include <SD.h>
|
||||
#include <vector>
|
||||
#include "Utf8CP437.h"
|
||||
#include "EpubProcessor.h"
|
||||
#endif
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
#define BOOKS_FOLDER "/books"
|
||||
#define INDEX_FOLDER "/.indexes"
|
||||
#define INDEX_VERSION 5 // v5: UTF-8 aware word wrap (accented char support)
|
||||
#define INDEX_VERSION 12 // v12: indexer breaks page BEFORE overflowing line (matches renderer pre-check)
|
||||
#define PREINDEX_PAGES 100
|
||||
#define READER_MAX_FILES 50
|
||||
#define READER_BUF_SIZE 4096
|
||||
@@ -97,16 +102,244 @@ inline WrapResult findLineBreak(const char* buffer, int bufLen, int lineStart, i
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pixel-width line breaking for proportional fonts (T5S3)
|
||||
//
|
||||
// Measures actual rendered text width via DisplayDriver::getTextWidth() at
|
||||
// each word boundary. This gives correct line breaks regardless of character
|
||||
// width variation in proportional fonts like FreeSans12pt.
|
||||
// maxChars is a safety upper bound to prevent runaway on spaceless lines.
|
||||
// ============================================================================
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
|
||||
inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineStart,
|
||||
DisplayDriver* display, int maxChars) {
|
||||
WrapResult result;
|
||||
result.lineEnd = lineStart;
|
||||
result.nextStart = lineStart;
|
||||
if (lineStart >= bufLen || !display) return result;
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
int rightMargin = 5; // Wider margin for T5S3 (portrait mode especially tight)
|
||||
#else
|
||||
int rightMargin = 3;
|
||||
#endif
|
||||
int displayW = display->width() - rightMargin;
|
||||
char measBuf[300]; // temp buffer for pixel measurement
|
||||
int measLen = 0;
|
||||
int lastBreakPoint = -1;
|
||||
int lastBreakMeasLen = 0; // measLen at lastBreakPoint (for mid-word fallback)
|
||||
bool inWord = false;
|
||||
int charCount = 0;
|
||||
|
||||
for (int i = lineStart; i < bufLen; i++) {
|
||||
char c = buffer[i];
|
||||
|
||||
// Newline handling (identical to char-count version)
|
||||
if (c == '\n') {
|
||||
result.lineEnd = i;
|
||||
result.nextStart = i + 1;
|
||||
if (result.nextStart < bufLen && buffer[result.nextStart] == '\r')
|
||||
result.nextStart++;
|
||||
return result;
|
||||
}
|
||||
if (c == '\r') {
|
||||
result.lineEnd = i;
|
||||
result.nextStart = i + 1;
|
||||
if (result.nextStart < bufLen && buffer[result.nextStart] == '\n')
|
||||
result.nextStart++;
|
||||
return result;
|
||||
}
|
||||
|
||||
if ((uint8_t)c >= 32) {
|
||||
// UTF-8 handling: decode multi-byte sequences to CP437 for accurate
|
||||
// width measurement. The renderer (renderPage) does this same conversion,
|
||||
// so the measurement must match or it underestimates line width.
|
||||
int charStartIdx = i; // buffer index where this character started
|
||||
if ((uint8_t)c >= 0xC0) {
|
||||
// UTF-8 lead byte — decode full sequence to CP437
|
||||
int decPos = i;
|
||||
uint32_t cp = decodeUtf8Char(buffer, bufLen, &decPos);
|
||||
uint8_t glyph = unicodeToCP437(cp);
|
||||
if (glyph >= 32 && measLen < 298) {
|
||||
measBuf[measLen++] = (char)glyph;
|
||||
charCount++;
|
||||
}
|
||||
i = decPos - 1; // -1 because the for loop will i++
|
||||
inWord = true;
|
||||
} else if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) {
|
||||
// Orphan continuation byte — treat as CP437 pass-through (same as renderer)
|
||||
if (measLen < 298) measBuf[measLen++] = c;
|
||||
charCount++;
|
||||
inWord = true;
|
||||
} else {
|
||||
// Plain ASCII
|
||||
charCount++;
|
||||
if (measLen < 298) measBuf[measLen++] = c;
|
||||
|
||||
if (c == ' ' || c == '\t') {
|
||||
if (inWord) {
|
||||
lastBreakPoint = i;
|
||||
lastBreakMeasLen = measLen;
|
||||
inWord = false;
|
||||
}
|
||||
} else if (c == '-') {
|
||||
if (inWord) {
|
||||
lastBreakPoint = i + 1;
|
||||
lastBreakMeasLen = measLen;
|
||||
}
|
||||
inWord = true;
|
||||
} else {
|
||||
inWord = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-character pixel width check — catches long words that exceed
|
||||
// displayW without ever hitting a space/hyphen break point.
|
||||
// Only measure every 3 chars to avoid excessive getTextWidth() calls.
|
||||
if ((charCount & 3) == 0 || c == ' ' || c == '-') {
|
||||
measBuf[measLen] = '\0';
|
||||
int pw = display->getTextWidth(measBuf);
|
||||
if (pw >= displayW) {
|
||||
if (lastBreakPoint > lineStart) {
|
||||
// Break at last word boundary
|
||||
result.lineEnd = lastBreakPoint;
|
||||
result.nextStart = lastBreakPoint;
|
||||
while (result.nextStart < bufLen &&
|
||||
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
|
||||
result.nextStart++;
|
||||
} else {
|
||||
// No word boundary found — break mid-word before this character
|
||||
result.lineEnd = charStartIdx;
|
||||
result.nextStart = charStartIdx;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: hard char limit (handles spaceless lines, URLs, etc.)
|
||||
if (charCount >= maxChars) {
|
||||
if (lastBreakPoint > lineStart) {
|
||||
result.lineEnd = lastBreakPoint;
|
||||
result.nextStart = lastBreakPoint;
|
||||
while (result.nextStart < bufLen &&
|
||||
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
|
||||
result.nextStart++;
|
||||
} else {
|
||||
result.lineEnd = i;
|
||||
result.nextStart = i;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.lineEnd = bufLen;
|
||||
result.nextStart = bufLen;
|
||||
return result;
|
||||
}
|
||||
#endif // LilyGo_T5S3_EPaper_Pro
|
||||
|
||||
// ============================================================================
|
||||
// Page Indexer (word-wrap aware, matches display rendering)
|
||||
// When textAreaHeight and lineHeight are provided (both > 0), uses height-based
|
||||
// pagination that accounts for blank lines getting 40% height (matching renderer).
|
||||
// Otherwise falls back to simple line counting.
|
||||
// ============================================================================
|
||||
inline int indexPagesWordWrap(File& file, long startPos,
|
||||
std::vector<long>& pagePositions,
|
||||
int linesPerPage, int charsPerLine,
|
||||
int maxPages) {
|
||||
const int BUF_SIZE = 2048;
|
||||
int maxPages,
|
||||
int textAreaHeight = 0, int lineHeight = 0) {
|
||||
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
|
||||
char buffer[BUF_SIZE];
|
||||
|
||||
bool heightAware = (textAreaHeight > 0 && lineHeight > 0);
|
||||
int blankLineH = heightAware ? max(2, lineHeight * 2 / 5) : 0;
|
||||
|
||||
file.seek(startPos);
|
||||
int pagesAdded = 0;
|
||||
int lineCount = 0;
|
||||
int accHeight = 0;
|
||||
int leftover = 0;
|
||||
long chunkFileStart = startPos;
|
||||
|
||||
while (file.available() && (maxPages <= 0 || pagesAdded < maxPages)) {
|
||||
int bytesRead = file.readBytes(buffer + leftover, BUF_SIZE - leftover);
|
||||
int bufLen = leftover + bytesRead;
|
||||
if (bufLen == 0) break;
|
||||
|
||||
int pos = 0;
|
||||
while (pos < bufLen) {
|
||||
int lineStart = pos;
|
||||
WrapResult wrap = findLineBreak(buffer, bufLen, pos, charsPerLine);
|
||||
if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;
|
||||
|
||||
// Blank line = newline at line start (no printable content before it)
|
||||
bool isBlankLine = (wrap.lineEnd == lineStart);
|
||||
|
||||
bool pageBreak = false;
|
||||
if (heightAware) {
|
||||
int thisH = isBlankLine ? blankLineH : lineHeight;
|
||||
// Check BEFORE adding: does this line fit on the current page?
|
||||
// The renderer checks y <= maxY before rendering each line,
|
||||
// so we must break the page BEFORE a line that won't fit.
|
||||
if (accHeight > 0 && accHeight + thisH > textAreaHeight) {
|
||||
// This line doesn't fit — start new page at this line's position
|
||||
long pageFilePos = chunkFileStart + lineStart;
|
||||
pagePositions.push_back(pageFilePos);
|
||||
pagesAdded++;
|
||||
accHeight = 0;
|
||||
if (maxPages > 0 && pagesAdded >= maxPages) break;
|
||||
}
|
||||
accHeight += thisH;
|
||||
} else {
|
||||
lineCount++;
|
||||
if (lineCount >= linesPerPage) {
|
||||
pageBreak = true;
|
||||
lineCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pos = wrap.nextStart;
|
||||
|
||||
if (pageBreak) {
|
||||
long pageFilePos = chunkFileStart + pos;
|
||||
pagePositions.push_back(pageFilePos);
|
||||
pagesAdded++;
|
||||
if (maxPages > 0 && pagesAdded >= maxPages) break;
|
||||
}
|
||||
if (pos >= bufLen) break;
|
||||
}
|
||||
|
||||
leftover = bufLen - pos;
|
||||
if (leftover > 0 && leftover < BUF_SIZE) {
|
||||
memmove(buffer, buffer + pos, leftover);
|
||||
} else {
|
||||
leftover = 0;
|
||||
}
|
||||
chunkFileStart = file.position() - leftover;
|
||||
}
|
||||
|
||||
return pagesAdded;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pixel-based Page Indexer for T5S3 (proportional font word wrap)
|
||||
// ============================================================================
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
inline int indexPagesWordWrapPixel(File& file, long startPos,
|
||||
std::vector<long>& pagePositions,
|
||||
int linesPerPage, int maxChars,
|
||||
DisplayDriver* display, int maxPages,
|
||||
NodePrefs* prefs = nullptr) {
|
||||
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
|
||||
char buffer[BUF_SIZE];
|
||||
|
||||
// Ensure body font is active for pixel measurement
|
||||
display->setTextSize(prefs ? prefs->smallTextSize() : 0);
|
||||
|
||||
file.seek(startPos);
|
||||
int pagesAdded = 0;
|
||||
int lineCount = 0;
|
||||
@@ -120,7 +353,7 @@ inline int indexPagesWordWrap(File& file, long startPos,
|
||||
|
||||
int pos = 0;
|
||||
while (pos < bufLen) {
|
||||
WrapResult wrap = findLineBreak(buffer, bufLen, pos, charsPerLine);
|
||||
WrapResult wrap = findLineBreakPixel(buffer, bufLen, pos, display, maxChars);
|
||||
if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;
|
||||
|
||||
lineCount++;
|
||||
@@ -145,8 +378,10 @@ inline int indexPagesWordWrap(File& file, long startPos,
|
||||
chunkFileStart = file.position() - leftover;
|
||||
}
|
||||
|
||||
display->setTextSize(1); // Restore
|
||||
return pagesAdded;
|
||||
}
|
||||
#endif // LilyGo_T5S3_EPaper_Pro
|
||||
|
||||
// ============================================================================
|
||||
// TextReaderScreen
|
||||
@@ -167,9 +402,11 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Mode _mode;
|
||||
bool _sdReady;
|
||||
bool _initialized; // Layout metrics calculated
|
||||
uint8_t _lastFontPref; // Font preference at last layout init (detect changes)
|
||||
bool _bootIndexed; // Boot-time pre-indexing done
|
||||
DisplayDriver* _display; // Stored reference for splash screens
|
||||
|
||||
@@ -177,6 +414,7 @@ private:
|
||||
int _charsPerLine;
|
||||
int _linesPerPage;
|
||||
int _lineHeight; // virtual coord units per text line
|
||||
int _textAreaHeight; // usable height for text (excluding header/footer)
|
||||
int _headerHeight;
|
||||
int _footerHeight;
|
||||
|
||||
@@ -200,6 +438,11 @@ private:
|
||||
int _pageBufLen;
|
||||
bool _contentDirty; // Need to re-read from SD
|
||||
|
||||
// Go-to-page input mode (Enter in reading view)
|
||||
bool _gotoMode = false;
|
||||
char _gotoBuf[6]; // Up to 5 digits + null
|
||||
int _gotoBufLen = 0;
|
||||
|
||||
// ---- Splash Screen Drawing ----
|
||||
// Draw directly to display outside the normal render cycle.
|
||||
// Matches the style of the standalone text reader firmware splash.
|
||||
@@ -700,11 +943,13 @@ private:
|
||||
// Cache had no pages (e.g. dummy entry) — full index from scratch
|
||||
_pagePositions.push_back(0);
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
} else {
|
||||
long lastPos = cache->pagePositions.back();
|
||||
indexPagesWordWrap(_file, lastPos, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
}
|
||||
} else {
|
||||
// No cache — full index from scratch
|
||||
@@ -723,7 +968,8 @@ private:
|
||||
|
||||
_pagePositions.push_back(0);
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0);
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
}
|
||||
|
||||
// Save complete index
|
||||
@@ -846,8 +1092,8 @@ private:
|
||||
display.setCursor(0, 42);
|
||||
display.print("/books/ on SD card");
|
||||
} else {
|
||||
display.setTextSize(0); // Tiny font for file list
|
||||
int listLineH = 8; // Approximate tiny font line height in virtual coords
|
||||
display.setTextSize(_prefs->smallTextSize()); // Tiny font for file list
|
||||
int listLineH = _prefs->smallLineH();
|
||||
int startY = 14;
|
||||
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
@@ -863,17 +1109,19 @@ private:
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), listLineH);
|
||||
#else
|
||||
// setCursor adds +5 to y internally, but fillRect does not.
|
||||
// Offset fillRect by +5 to align highlight bar with text.
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// Set cursor AFTER fillRect so text draws on top of highlight
|
||||
display.setCursor(0, y);
|
||||
|
||||
int type = itemTypeAt(i);
|
||||
String line = selected ? "> " : " ";
|
||||
|
||||
@@ -883,10 +1131,6 @@ private:
|
||||
} else if (type == 1) {
|
||||
// Subdirectory
|
||||
line += "/" + dirNameAt(i);
|
||||
// Truncate if needed
|
||||
if ((int)line.length() > _charsPerLine) {
|
||||
line = line.substring(0, _charsPerLine - 3) + "...";
|
||||
}
|
||||
} else {
|
||||
// File
|
||||
int fi = fileIndexAt(i);
|
||||
@@ -899,16 +1143,11 @@ private:
|
||||
suffix = " *";
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
int maxLen = _charsPerLine - 4 - suffix.length();
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name + suffix;
|
||||
}
|
||||
|
||||
display.print(line.c_str());
|
||||
// Pixel-aware ellipsis — small margin prevents GxEPD edge wrapping
|
||||
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
|
||||
y += listLineH;
|
||||
}
|
||||
display.setTextSize(1); // Restore
|
||||
@@ -918,18 +1157,24 @@ private:
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("Q:Back W/S:Nav");
|
||||
|
||||
const char* right = "Ent:Open";
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home");
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bk");
|
||||
|
||||
const char* right = "Tap/Ent:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
}
|
||||
|
||||
void renderPage(DisplayDriver& display) {
|
||||
// Use tiny font for maximum text density
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
int y = 0;
|
||||
@@ -940,7 +1185,7 @@ private:
|
||||
// Render all lines in the page buffer using word wrap.
|
||||
// The buffer contains exactly the bytes for this page (from indexed positions),
|
||||
// so we render everything in it.
|
||||
while (pos < _pageBufLen && lineCount < _linesPerPage && y <= maxY) {
|
||||
while (pos < _pageBufLen && y <= maxY) {
|
||||
int oldPos = pos;
|
||||
WrapResult wrap = findLineBreak(_pageBuf, _pageBufLen, pos, _charsPerLine);
|
||||
|
||||
@@ -950,6 +1195,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 +1211,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 +1221,7 @@ private:
|
||||
if (glyph) {
|
||||
charStr[0] = (char)glyph;
|
||||
display.print(charStr);
|
||||
lineHasContent = true;
|
||||
}
|
||||
// If unmappable (glyph==0), just skip the character
|
||||
} else {
|
||||
@@ -981,11 +1229,20 @@ private:
|
||||
// Treat as CP437 pass-through (e.g. from EPUB numeric entity decoding).
|
||||
charStr[0] = (char)b;
|
||||
display.print(charStr);
|
||||
lineHasContent = true;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
y += _lineHeight;
|
||||
// Blank lines (paragraph breaks) get reduced height for compact layout.
|
||||
// Full _lineHeight for blank lines wastes too much space — on T5S3 each
|
||||
// blank line is ~34px, making paragraph gaps 7-8× the normal line spacing.
|
||||
// Using 40% height gives a visible paragraph break without wasting space.
|
||||
if (lineHasContent) {
|
||||
y += _lineHeight;
|
||||
} else {
|
||||
y += max(2, _lineHeight * 2 / 5); // ~40% height for blank lines
|
||||
}
|
||||
lineCount++;
|
||||
pos = wrap.nextStart;
|
||||
if (pos >= _pageBufLen) break;
|
||||
@@ -1001,43 +1258,108 @@ private:
|
||||
|
||||
char status[30];
|
||||
int pct = _totalPages > 1 ? (_currentPage * 100) / (_totalPages - 1) : 100;
|
||||
sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct);
|
||||
|
||||
if (_gotoMode) {
|
||||
// Go-to-page input mode — show typed digits in footer
|
||||
snprintf(status, sizeof(status), "Go to: %.*s_", _gotoBufLen, _gotoBuf);
|
||||
} else {
|
||||
sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct);
|
||||
}
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setCursor(0, footerY);
|
||||
display.print(status);
|
||||
const char* right = "Swipe:Page Tap:GoTo Hold:Close";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print(status);
|
||||
|
||||
const char* right = "W/S:Nav Q:Back";
|
||||
const char* right = _gotoMode ? "Ent:Go Q:Cancel" : "Entr:Pg# Q:Bk";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#endif
|
||||
}
|
||||
|
||||
public:
|
||||
TextReaderScreen(UITask* task)
|
||||
: _task(task), _mode(FILE_LIST), _sdReady(false), _initialized(false),
|
||||
TextReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _mode(FILE_LIST), _sdReady(false), _initialized(false), _lastFontPref(0),
|
||||
_bootIndexed(false), _display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
|
||||
_headerHeight(14), _footerHeight(14),
|
||||
_textAreaHeight(100), _headerHeight(14), _footerHeight(14),
|
||||
_selectedFile(0), _currentPath(BOOKS_FOLDER),
|
||||
_fileOpen(false), _currentPage(0), _totalPages(0),
|
||||
_pageBufLen(0), _contentDirty(true) {
|
||||
}
|
||||
|
||||
// Reset layout so it recalculates on next render (orientation change).
|
||||
// If a book is open, forces full reindex with new layout params.
|
||||
void invalidateLayout() {
|
||||
_initialized = false;
|
||||
if (_fileOpen) {
|
||||
_pagePositions.clear();
|
||||
_totalPages = 0;
|
||||
_currentPage = 0;
|
||||
_pageBufLen = 0;
|
||||
_contentDirty = true;
|
||||
Serial.println("TextReader: Layout invalidated, will reindex on next enter");
|
||||
}
|
||||
}
|
||||
|
||||
// Call once after display is available to calculate layout metrics
|
||||
void initLayout(DisplayDriver& display) {
|
||||
// Re-init if font preference changed since last layout
|
||||
uint8_t curFont = _prefs ? _prefs->large_font : 0;
|
||||
if (_initialized && curFont != _lastFontPref) {
|
||||
_initialized = false;
|
||||
Serial.println("TextReader: font changed, recalculating layout");
|
||||
}
|
||||
if (_initialized) return;
|
||||
_lastFontPref = curFont;
|
||||
|
||||
// Store display reference for splash screens during openBook
|
||||
_display = &display;
|
||||
|
||||
// Measure tiny font metrics using the display driver
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
// Measure character width: use 10 M's to get accurate average
|
||||
// Measure character width: use 10 M's for monospace (T-Deck Pro tiny font).
|
||||
// Proportional fonts (T5S3 and T-Deck Pro large_font) override below with
|
||||
// average-width measurement since M is the widest glyph (~40% wider than average).
|
||||
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
|
||||
if (tenCharsW > 0) {
|
||||
_charsPerLine = (display.width() * 10) / tenCharsW;
|
||||
}
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3 uses proportional font (FreeSans12pt) — measure average character
|
||||
// width from a representative English sample. M-based measurement is far
|
||||
// too conservative (M is the widest glyph), leaving half the line empty.
|
||||
{
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
// 95% factor as small safety margin for slightly-wider-than-average text
|
||||
_charsPerLine = (display.width() * sampleLen * 95) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 80) _charsPerLine = 80;
|
||||
#else
|
||||
// T-Deck Pro: large_font uses FreeSans9pt (proportional) — same fix
|
||||
if (_prefs && _prefs->large_font) {
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 60) _charsPerLine = 60;
|
||||
#endif
|
||||
|
||||
// Line height for built-in 6x8 font:
|
||||
// setCursor adds +5 to y, so effective text top = (y+5)*scale_y
|
||||
@@ -1052,24 +1374,140 @@ public:
|
||||
_lineHeight = 5; // Safe fallback
|
||||
}
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3 uses FreeSans12pt/FreeSerif12pt for size 0 (yAdvance=29px).
|
||||
{
|
||||
extern DISPLAY_CLASS display;
|
||||
_lineHeight = display.isPortraitMode() ? 5 : 8;
|
||||
}
|
||||
#else
|
||||
// T-Deck Pro large_font uses FreeSans9pt (yAdvance=22px at scale 1.5625×).
|
||||
// The 6x8 formula above gives ~5-7 which is way too small — lines overlap.
|
||||
// Use smallLineH() which is already tuned for this font.
|
||||
if (_prefs && _prefs->large_font) {
|
||||
_lineHeight = _prefs->smallLineH();
|
||||
}
|
||||
#endif
|
||||
|
||||
_headerHeight = 0; // No header in reading mode (maximize text area)
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _headerHeight - _footerHeight;
|
||||
_linesPerPage = textAreaHeight / _lineHeight;
|
||||
_textAreaHeight = display.height() - _headerHeight - _footerHeight;
|
||||
_linesPerPage = _textAreaHeight / _lineHeight;
|
||||
if (_linesPerPage < 5) _linesPerPage = 5;
|
||||
if (_linesPerPage > 40) _linesPerPage = 40;
|
||||
|
||||
display.setTextSize(1); // Restore
|
||||
_initialized = true;
|
||||
|
||||
Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d (display %dx%d)\n",
|
||||
_charsPerLine, _linesPerPage, _lineHeight, display.width(), display.height());
|
||||
Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d, textH=%d (display %dx%d)\n",
|
||||
_charsPerLine, _linesPerPage, _lineHeight, _textAreaHeight, display.width(), display.height());
|
||||
}
|
||||
|
||||
// ---- Boot-time Indexing ----
|
||||
// Called from setup() after SD card init. Scans files, pre-indexes first
|
||||
// 100 pages of each, and shows progress on the e-ink display.
|
||||
|
||||
// Pre-index files inside one level of subdirectories so navigating
|
||||
// into them later is instant (idx files already on SD).
|
||||
void bootIndexSubfolders() {
|
||||
// Work from the root-level _dirList that scanFiles() already populated.
|
||||
// Copy it -- scanFiles() will overwrite _dirList when we scan each subfolder.
|
||||
std::vector<String> subDirs = _dirList;
|
||||
if (subDirs.empty()) return;
|
||||
|
||||
Serial.printf("TextReader: Pre-indexing %d subfolders\n", (int)subDirs.size());
|
||||
|
||||
int totalSubFiles = 0;
|
||||
int cachedSubFiles = 0;
|
||||
int indexedSubFiles = 0;
|
||||
|
||||
for (int d = 0; d < (int)subDirs.size(); d++) {
|
||||
String subPath = String(BOOKS_FOLDER) + "/" + subDirs[d];
|
||||
_currentPath = subPath;
|
||||
scanFiles(); // populates _fileList for this subfolder
|
||||
|
||||
// Also pick up previously converted EPUB cache files for this subfolder
|
||||
String epubCachePath = subPath + "/.epub_cache";
|
||||
if (SD.exists(epubCachePath.c_str())) {
|
||||
File cacheDir = SD.open(epubCachePath.c_str());
|
||||
if (cacheDir && cacheDir.isDirectory()) {
|
||||
File cf = cacheDir.openNextFile();
|
||||
while (cf && _fileList.size() < READER_MAX_FILES) {
|
||||
if (!cf.isDirectory()) {
|
||||
String cname = String(cf.name());
|
||||
int cslash = cname.lastIndexOf('/');
|
||||
if (cslash >= 0) cname = cname.substring(cslash + 1);
|
||||
if (cname.endsWith(".txt") || cname.endsWith(".TXT")) {
|
||||
bool dup = false;
|
||||
for (int k = 0; k < (int)_fileList.size(); k++) {
|
||||
if (_fileList[k] == cname) { dup = true; break; }
|
||||
}
|
||||
if (!dup) _fileList.push_back(cname);
|
||||
}
|
||||
}
|
||||
cf = cacheDir.openNextFile();
|
||||
}
|
||||
cacheDir.close();
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < (int)_fileList.size(); i++) {
|
||||
totalSubFiles++;
|
||||
|
||||
// Try loading existing .idx cache -- if hit, skip
|
||||
FileCache tempCache;
|
||||
if (loadIndex(_fileList[i], tempCache)) {
|
||||
cachedSubFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip .epub files (converted on first open)
|
||||
if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) continue;
|
||||
|
||||
// Index this .txt file
|
||||
String fullPath = _currentPath + "/" + _fileList[i];
|
||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!file) {
|
||||
// Try epub cache fallback
|
||||
String cacheFallback = epubCachePath + "/" + _fileList[i];
|
||||
file = SD.open(cacheFallback.c_str(), FILE_READ);
|
||||
}
|
||||
if (!file) continue;
|
||||
|
||||
indexedSubFiles++;
|
||||
String displayName = subDirs[d] + "/" + _fileList[i];
|
||||
drawBootSplash(indexedSubFiles, 0, displayName);
|
||||
|
||||
FileCache cache;
|
||||
cache.filename = _fileList[i];
|
||||
cache.fileSize = file.size();
|
||||
cache.fullyIndexed = false;
|
||||
cache.lastReadPage = 0;
|
||||
cache.pagePositions.clear();
|
||||
cache.pagePositions.push_back(0);
|
||||
|
||||
indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1,
|
||||
_textAreaHeight, _lineHeight);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
|
||||
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
|
||||
cache.fullyIndexed, 0);
|
||||
|
||||
Serial.printf("TextReader: %s/%s - indexed %d pages%s\n",
|
||||
subDirs[d].c_str(), _fileList[i].c_str(),
|
||||
(int)cache.pagePositions.size(),
|
||||
cache.fullyIndexed ? " (complete)" : "");
|
||||
yield(); // Feed WDT between files
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("TextReader: Subfolder pre-index: %d files (%d cached, %d newly indexed)\n",
|
||||
totalSubFiles, cachedSubFiles, indexedSubFiles);
|
||||
}
|
||||
|
||||
void bootIndex(DisplayDriver& display) {
|
||||
if (!_sdReady) return;
|
||||
|
||||
@@ -1111,20 +1549,24 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
if (_fileList.size() == 0) {
|
||||
Serial.println("TextReader: No files to index");
|
||||
if (_fileList.size() == 0 && _dirList.size() == 0) {
|
||||
Serial.println("TextReader: No files or folders to index");
|
||||
_bootIndexed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
int cachedCount = 0;
|
||||
int needsIndexCount = 0;
|
||||
|
||||
// --- Pass 1 & 2: Index root-level files ---
|
||||
if (_fileList.size() > 0) {
|
||||
|
||||
// --- Pass 1: Fast cache load (no per-file splash screens) ---
|
||||
// Try to load existing .idx files from SD for every file.
|
||||
// This is just SD reads — no indexing, no e-ink refreshes.
|
||||
_fileCache.clear();
|
||||
_fileCache.resize(_fileList.size()); // Pre-allocate slots to maintain alignment with _fileList
|
||||
|
||||
int cachedCount = 0;
|
||||
int needsIndexCount = 0;
|
||||
|
||||
for (int i = 0; i < (int)_fileList.size(); i++) {
|
||||
if (loadIndex(_fileList[i], _fileCache[i])) {
|
||||
@@ -1176,7 +1618,8 @@ public:
|
||||
|
||||
int added = indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1);
|
||||
PREINDEX_PAGES - 1,
|
||||
_textAreaHeight, _lineHeight);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
|
||||
@@ -1189,6 +1632,26 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
} // end if (_fileList.size() > 0)
|
||||
|
||||
// --- Pass 3: Pre-index files inside subfolders (one level deep) ---
|
||||
// Save root state -- bootIndexSubfolders() will overwrite _fileList/_dirList
|
||||
// via scanFiles() as it iterates each subdirectory.
|
||||
if (_dirList.size() > 0) {
|
||||
std::vector<String> savedFileList = _fileList;
|
||||
std::vector<String> savedDirList = _dirList;
|
||||
std::vector<FileCache> savedFileCache = _fileCache;
|
||||
|
||||
bootIndexSubfolders();
|
||||
|
||||
// Restore root state
|
||||
_currentPath = String(BOOKS_FOLDER);
|
||||
_fileList = savedFileList;
|
||||
_dirList = savedDirList;
|
||||
_fileCache = savedFileCache;
|
||||
}
|
||||
|
||||
|
||||
// Deselect SD to free SPI bus
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
@@ -1215,6 +1678,17 @@ public:
|
||||
if (!_fileOpen) {
|
||||
_selectedFile = 0;
|
||||
_mode = FILE_LIST;
|
||||
} else if (_pagePositions.empty()) {
|
||||
// Layout was invalidated (orientation change) — reindex the open book
|
||||
Serial.println("TextReader: Reindexing after layout change");
|
||||
_pagePositions.push_back(0);
|
||||
indexPagesWordWrap(_file, 0, _pagePositions,
|
||||
_linesPerPage, _charsPerLine, 0,
|
||||
_textAreaHeight, _lineHeight);
|
||||
_totalPages = _pagePositions.size();
|
||||
if (_currentPage >= _totalPages) _currentPage = 0;
|
||||
_mode = READING;
|
||||
loadPageContent();
|
||||
} else {
|
||||
_mode = READING;
|
||||
loadPageContent();
|
||||
@@ -1225,6 +1699,49 @@ public:
|
||||
bool isReading() const { return _mode == READING; }
|
||||
bool isInFileList() const { return _mode == FILE_LIST; }
|
||||
|
||||
// Jump to a specific page number (1-based for user-facing, converted to 0-based)
|
||||
void gotoPage(int pageNum) {
|
||||
if (!_fileOpen || _totalPages == 0) return;
|
||||
int target = pageNum - 1; // Convert 1-based input to 0-based
|
||||
if (target < 0) target = 0;
|
||||
if (target >= _totalPages) target = _totalPages - 1;
|
||||
_currentPage = target;
|
||||
loadPageContent();
|
||||
Serial.printf("TextReader: Go to page %d/%d\n", _currentPage + 1, _totalPages);
|
||||
}
|
||||
|
||||
int getTotalPages() const { return _totalPages; }
|
||||
int getCurrentPage() const { return _currentPage; }
|
||||
|
||||
// Tap-to-select: given virtual Y, select file list row.
|
||||
// Returns: 0=miss, 1=moved, 2=tapped current row.
|
||||
int selectRowAtVY(int vy) {
|
||||
if (_mode != FILE_LIST) return 0;
|
||||
const int startY = 14, footerH = 14;
|
||||
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = startY;
|
||||
#else
|
||||
const int bodyTop = startY + (_prefs ? _prefs->smallHighlightOff() : 5);
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
int totalItems = totalListItems();
|
||||
if (totalItems == 0) return 0;
|
||||
int maxVisible = (128 - startY - footerH) / listLineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
if (maxVisible > 15) maxVisible = 15;
|
||||
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
|
||||
totalItems - maxVisible));
|
||||
|
||||
int tappedRow = startIdx + (vy - bodyTop) / listLineH;
|
||||
if (tappedRow < 0 || tappedRow >= totalItems) return 0;
|
||||
|
||||
if (tappedRow == _selectedFile) return 2;
|
||||
_selectedFile = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
if (!_sdReady) {
|
||||
display.setCursor(0, 20);
|
||||
@@ -1249,6 +1766,7 @@ public:
|
||||
if (_mode == FILE_LIST) {
|
||||
return handleFileListInput(c);
|
||||
} else if (_mode == READING) {
|
||||
if (_gotoMode) return handleGotoInput(c);
|
||||
return handleReadingInput(c);
|
||||
}
|
||||
return false;
|
||||
@@ -1337,7 +1855,8 @@ public:
|
||||
cache.pagePositions.push_back(0);
|
||||
indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1);
|
||||
PREINDEX_PAGES - 1,
|
||||
_textAreaHeight, _lineHeight);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
|
||||
@@ -1363,9 +1882,9 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// S/D/Space/Enter - next page
|
||||
// S/D/Space - next page
|
||||
if (c == 's' || c == 'S' || c == 'd' || c == 'D' ||
|
||||
c == ' ' || c == '\r' || c == 13 || c == 0xF1) {
|
||||
c == ' ' || c == 0xF1) {
|
||||
if (_currentPage < _totalPages - 1) {
|
||||
_currentPage++;
|
||||
loadPageContent();
|
||||
@@ -1374,6 +1893,14 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter - go-to-page input mode
|
||||
if (c == '\r' || c == 13) {
|
||||
_gotoMode = true;
|
||||
_gotoBufLen = 0;
|
||||
_gotoBuf[0] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Q - close book, back to file list
|
||||
if (c == 'q' || c == 'Q') {
|
||||
closeBook();
|
||||
@@ -1384,9 +1911,83 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
bool handleGotoInput(char c) {
|
||||
// Enter — commit page number
|
||||
if (c == '\r' || c == 13) {
|
||||
if (_gotoBufLen > 0) {
|
||||
int pageNum = atoi(_gotoBuf);
|
||||
gotoPage(pageNum);
|
||||
}
|
||||
_gotoMode = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Q or Escape — cancel
|
||||
if (c == 'q' || c == 'Q' || c == 0x1B) {
|
||||
_gotoMode = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backspace — delete last digit
|
||||
if (c == '\b' || c == 0x7F) {
|
||||
if (_gotoBufLen > 0) {
|
||||
_gotoBufLen--;
|
||||
_gotoBuf[_gotoBufLen] = '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Digit — append (max 5 digits)
|
||||
if (c >= '0' && c <= '9' && _gotoBufLen < 5) {
|
||||
_gotoBuf[_gotoBufLen++] = c;
|
||||
_gotoBuf[_gotoBufLen] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
return true; // Consume all other keys while in goto mode
|
||||
}
|
||||
|
||||
// External close (called when leaving reader screen entirely)
|
||||
void exitReader() {
|
||||
if (_fileOpen) closeBook();
|
||||
_mode = FILE_LIST;
|
||||
}
|
||||
};
|
||||
};
|
||||
#else // !ESP32
|
||||
|
||||
// Non-ESP32 stub: Meshpocket / T-Echo Card have no SD card hardware, so the
|
||||
// full EPUB/text reader can't work here. This stub keeps UITask.cpp and
|
||||
// main.cpp compilable by providing the same public interface as no-ops.
|
||||
// Navigating to the reader on a non-SD board just shows a placeholder.
|
||||
class TextReaderScreen : public UIScreen {
|
||||
public:
|
||||
TextReaderScreen(UITask* task, NodePrefs* prefs = nullptr) {
|
||||
(void)task; (void)prefs;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 20);
|
||||
display.print("Reader: SD card required");
|
||||
display.setCursor(0, 30);
|
||||
display.print("(not available)");
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override { (void)c; return false; }
|
||||
|
||||
// No-op public API matching the ESP32 class for call-site compatibility
|
||||
void invalidateLayout() {}
|
||||
void bootIndex(DisplayDriver& display) { (void)display; }
|
||||
void setSDReady(bool ready) { (void)ready; }
|
||||
void enter(DisplayDriver& display) { (void)display; }
|
||||
bool isReading() const { return false; }
|
||||
bool isInFileList() const { return false; }
|
||||
void gotoPage(int pageNum) { (void)pageNum; }
|
||||
int getTotalPages() const { return 0; }
|
||||
int selectRowAtVY(int vy) { (void)vy; return -1; }
|
||||
void exitReader() {}
|
||||
};
|
||||
|
||||
#endif // ESP32
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,14 @@
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AlarmScreen.h"
|
||||
#endif
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include "VirtualKeyboard.h"
|
||||
#endif
|
||||
|
||||
// MapScreen.h included in UITask.cpp and main.cpp only (PNGdec headers
|
||||
// conflict with BLE if pulled into the global include chain)
|
||||
|
||||
@@ -52,6 +60,9 @@ class UITask : public AbstractUITask {
|
||||
NodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
unsigned long _alert_expiry;
|
||||
bool _hintActive = false; // Boot navigation hint overlay
|
||||
unsigned long _hintExpiry = 0; // Auto-dismiss time for hint
|
||||
bool _pendingBootHint = false; // Deferred hint — show after splash screen
|
||||
int _msgcount;
|
||||
unsigned long ui_started_at, next_batt_chck;
|
||||
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
|
||||
@@ -68,23 +79,81 @@ class UITask : public AbstractUITask {
|
||||
|
||||
UIScreen* splash;
|
||||
UIScreen* home;
|
||||
#ifndef HELTEC_MESH_POCKET
|
||||
UIScreen* msg_preview;
|
||||
#endif
|
||||
UIScreen* channel_screen; // Channel message history screen
|
||||
UIScreen* contacts_screen; // Contacts list screen
|
||||
UIScreen* text_reader; // *** NEW: Text reader screen ***
|
||||
UIScreen* notes_screen; // Notes editor screen
|
||||
UIScreen* settings_screen; // Settings/onboarding screen
|
||||
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
UIScreen* alarm_screen; // Alarm clock screen (audio variant only)
|
||||
UIScreen* voice_screen; // Voice message screen (audio variant only)
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
|
||||
#endif
|
||||
UIScreen* repeater_admin; // Repeater admin screen
|
||||
UIScreen* path_editor; // Custom path editor screen (lazy-init)
|
||||
UIScreen* discovery_screen; // Node discovery scan screen
|
||||
UIScreen* last_heard_screen; // Last heard passive advert list
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
|
||||
#endif
|
||||
UIScreen* map_screen; // Map tile screen (GPS + SD card tiles)
|
||||
UIScreen* curr;
|
||||
bool _homeShowingTiles = false; // Set by HomeScreen render when tile grid is visible
|
||||
int _tileGridVY = 44; // Virtual Y of tile grid top (updated each render)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
UIScreen* lock_screen; // Lock screen (big clock + battery + unread)
|
||||
UIScreen* _screenBeforeLock = nullptr;
|
||||
bool _locked = false;
|
||||
unsigned long _lastInputMillis = 0; // Auto-lock idle tracking
|
||||
unsigned long _lastLockRefresh = 0; // Periodic lock screen clock update
|
||||
|
||||
VirtualKeyboard _vkb;
|
||||
bool _vkbActive = false;
|
||||
UIScreen* _screenBeforeVKB = nullptr;
|
||||
unsigned long _vkbOpenedAt = 0;
|
||||
|
||||
// Powersaving: light sleep when locked + idle (standalone only — no BLE/WiFi)
|
||||
// Wakes on LoRa packet (DIO1), boot button (GPIO0), or 30-min timer
|
||||
#if !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
|
||||
unsigned long _psLastActive = 0; // millis() at last wake or lock entry
|
||||
unsigned long _psNextSleepSecs = 60; // Seconds before first sleep (60s), then 5s cycles
|
||||
#endif
|
||||
#ifdef MECK_CARDKB
|
||||
bool _cardkbDetected = false;
|
||||
#endif
|
||||
#elif defined(LilyGo_TDeck_Pro)
|
||||
UIScreen* lock_screen; // Lock screen (big clock + battery + unread)
|
||||
UIScreen* _screenBeforeLock = nullptr;
|
||||
bool _locked = false;
|
||||
unsigned long _lastInputMillis = 0; // Auto-lock idle tracking
|
||||
unsigned long _lastLockRefresh = 0; // Periodic lock screen clock update
|
||||
#endif
|
||||
|
||||
// --- Message dedup ring buffer (suppress retry spam at UI level) ---
|
||||
#define MSG_DEDUP_SIZE 8
|
||||
#define MSG_DEDUP_WINDOW_MS 60000 // 60 seconds
|
||||
struct MsgDedup {
|
||||
uint32_t name_hash;
|
||||
uint32_t text_hash;
|
||||
unsigned long millis;
|
||||
};
|
||||
MsgDedup _dedup[MSG_DEDUP_SIZE];
|
||||
int _dedupIdx = 0;
|
||||
|
||||
// --- Per-contact DM unread tracking ---
|
||||
uint8_t* _dmUnread = nullptr; // PSRAM-allocated, MAX_CONTACTS entries
|
||||
|
||||
static uint32_t simpleHash(const char* s) {
|
||||
uint32_t h = 5381;
|
||||
while (*s) { h = ((h << 5) + h) ^ (uint8_t)*s++; }
|
||||
return h;
|
||||
}
|
||||
|
||||
void userLedHandler();
|
||||
|
||||
@@ -113,15 +182,26 @@ public:
|
||||
|
||||
void gotoHomeScreen();
|
||||
void gotoChannelScreen(); // Navigate to channel message screen
|
||||
void gotoDMTab(); // Navigate directly to DM tab on channel screen
|
||||
void gotoDMConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0);
|
||||
void gotoContactsScreen(); // Navigate to contacts list
|
||||
void gotoTextReader(); // *** NEW: Navigate to text reader ***
|
||||
void gotoNotesScreen(); // Navigate to notes editor
|
||||
void gotoSettingsScreen(); // Navigate to settings
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
void gotoAlarmScreen(); // Navigate to alarm clock
|
||||
void gotoVoiceScreen(); // Navigate to voice message recorder
|
||||
#endif
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
void gotoRepeaterAdminDirect(int contactIdx); // Auto-login admin (L key from conversation)
|
||||
void gotoPathEditor(int contactIdx); // Navigate to custom path editor
|
||||
void gotoDiscoveryScreen(); // Navigate to node discovery scan
|
||||
void gotoLastHeardScreen(); // Navigate to last heard passive list
|
||||
#if HAS_GPS
|
||||
void gotoMapScreen(); // Navigate to map tile screen
|
||||
#endif
|
||||
#ifdef MECK_WEB_READER
|
||||
void gotoWebReader(); // Navigate to web reader (browser)
|
||||
#endif
|
||||
@@ -132,6 +212,9 @@ public:
|
||||
#endif
|
||||
void showAlert(const char* text, int duration_millis) override;
|
||||
void forceRefresh() override { _next_refresh = 100; }
|
||||
void showBootHint(bool immediate = false); // Show navigation hint overlay on first boot
|
||||
void dismissBootHint(); // Dismiss hint and save preference
|
||||
bool isHintActive() const { return _hintActive; }
|
||||
// Wake display and extend auto-off timer. Call this when handling keys
|
||||
// outside of injectKey() to prevent display auto-off during direct input.
|
||||
void keepAlive() {
|
||||
@@ -140,17 +223,54 @@ public:
|
||||
}
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
int getUnreadMsgCount() const; // Per-channel unread tracking (standalone)
|
||||
|
||||
// Per-contact DM unread tracking
|
||||
bool hasDMUnread(int contactIdx) const;
|
||||
int getDMUnreadCount(int contactIdx) const;
|
||||
void clearDMUnread(int contactIdx);
|
||||
|
||||
// Flag: suppress room→conversation redirect on next login (L key admin access)
|
||||
bool _skipRoomRedirect = false;
|
||||
bool hasDisplay() const { return _display != NULL; }
|
||||
bool isButtonPressed() const;
|
||||
bool isOnChannelScreen() const { return curr == channel_screen; }
|
||||
bool isOnContactsScreen() const { return curr == contacts_screen; }
|
||||
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
|
||||
bool isOnHomeScreen() const { return curr == home; }
|
||||
bool isHomeShowingTiles() const { return _homeShowingTiles; }
|
||||
void setHomeShowingTiles(bool v) { _homeShowingTiles = v; }
|
||||
int getTileGridVY() const { return _tileGridVY; }
|
||||
void setTileGridVY(int vy) { _tileGridVY = vy; }
|
||||
bool isOnNotesScreen() const { return curr == notes_screen; }
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
bool isOnAlarmScreen() const { return curr == alarm_screen; }
|
||||
bool isOnVoiceScreen() const { return curr == voice_screen; }
|
||||
#endif
|
||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||
bool isOnPathEditor() const { return curr == path_editor; }
|
||||
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
|
||||
bool isOnLastHeardScreen() const { return curr == last_heard_screen; }
|
||||
bool isOnMapScreen() const { return curr == map_screen; }
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
bool isLocked() const { return _locked; }
|
||||
void lockScreen();
|
||||
void unlockScreen();
|
||||
#endif
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
bool isVKBActive() const { return _vkbActive; }
|
||||
unsigned long vkbOpenedAt() const { return _vkbOpenedAt; }
|
||||
VirtualKeyboard& getVKB() { return _vkb; }
|
||||
void showVirtualKeyboard(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx = 0);
|
||||
void onVKBSubmit();
|
||||
void onVKBCancel();
|
||||
#ifdef MECK_CARDKB
|
||||
void setCardKBDetected(bool v) { _cardkbDetected = v; }
|
||||
bool hasCardKB() const { return _cardkbDetected; }
|
||||
void feedCardKBChar(char c);
|
||||
#endif
|
||||
#endif
|
||||
#ifdef MECK_WEB_READER
|
||||
bool isOnWebReader() const { return curr == web_reader; }
|
||||
#endif
|
||||
@@ -168,12 +288,15 @@ public:
|
||||
|
||||
// Check if home screen is in an editing mode (e.g. UTC offset editor)
|
||||
bool isEditingHomeScreen() const;
|
||||
// Check if home screen is showing the Recent Adverts page
|
||||
bool isHomeOnRecentPage() const;
|
||||
|
||||
// Inject a key press from external source (e.g., keyboard)
|
||||
void injectKey(char c);
|
||||
|
||||
// Add a sent message to the channel screen history
|
||||
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
|
||||
void addSentDM(const char* recipientName, const char* sender, const char* text);
|
||||
|
||||
// Mark channel as read when BLE companion app syncs messages
|
||||
void markChannelReadFromBLE(uint8_t channel_idx) override;
|
||||
@@ -185,16 +308,27 @@ public:
|
||||
|
||||
// Get current screen for checking state
|
||||
UIScreen* getCurrentScreen() const { return curr; }
|
||||
#ifndef HELTEC_MESH_POCKET
|
||||
UIScreen* getMsgPreviewScreen() const { return msg_preview; }
|
||||
#endif
|
||||
UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW ***
|
||||
UIScreen* getNotesScreen() const { return notes_screen; }
|
||||
UIScreen* getContactsScreen() const { return contacts_screen; }
|
||||
UIScreen* getChannelScreen() const { return channel_screen; }
|
||||
UIScreen* getSettingsScreen() const { return settings_screen; }
|
||||
NodePrefs* getNodePrefs() const { return _node_prefs; }
|
||||
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
UIScreen* getAlarmScreen() const { return alarm_screen; }
|
||||
void setAlarmScreen(UIScreen* s) { alarm_screen = s; }
|
||||
UIScreen* getVoiceScreen() const { return voice_screen; }
|
||||
void setVoiceScreen(UIScreen* s) { voice_screen = s; }
|
||||
#endif
|
||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||
UIScreen* getPathEditorScreen() const { return path_editor; }
|
||||
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
|
||||
UIScreen* getLastHeardScreen() const { return last_heard_screen; }
|
||||
UIScreen* getMapScreen() const { return map_screen; }
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* getWebReaderScreen() const { return web_reader; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@
|
||||
#include "ModemManager.h"
|
||||
#endif
|
||||
#include "Utf8CP437.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
@@ -1030,8 +1031,10 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Mode _mode;
|
||||
bool _initialized;
|
||||
uint8_t _lastFontPref;
|
||||
DisplayDriver* _display;
|
||||
|
||||
// Display layout (calculated once)
|
||||
@@ -1424,7 +1427,7 @@ private:
|
||||
_display->print("WiFi Setup");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Scanning for networks...");
|
||||
_display->endFrame();
|
||||
@@ -1524,7 +1527,7 @@ private:
|
||||
_display->print("Web Reader");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Connected!");
|
||||
_display->setCursor(0, 30);
|
||||
@@ -2306,7 +2309,7 @@ private:
|
||||
_display->print("Web Reader");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Fetch failed:");
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
@@ -2442,7 +2445,7 @@ private:
|
||||
_display->setTextSize(2);
|
||||
_display->setCursor(10, 20);
|
||||
_display->print("Logging in...");
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setCursor(10, 45);
|
||||
_display->print("Refreshing session...");
|
||||
@@ -2656,19 +2659,23 @@ private:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
if (_wifiState == WIFI_SCANNING) {
|
||||
display.setCursor(0, 18);
|
||||
display.print("Scanning for networks...");
|
||||
} else if (_wifiState == WIFI_SCAN_DONE) {
|
||||
int y = 14;
|
||||
int listLineH = 8;
|
||||
int listLineH = _prefs ? _prefs->smallLineH() : 9;
|
||||
for (int i = 0; i < _ssidCount && y < display.height() - 24; i++) {
|
||||
bool selected = (i == _selectedSSID);
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -2691,7 +2698,7 @@ private:
|
||||
y += 12;
|
||||
display.setCursor(0, y);
|
||||
display.print("Password:");
|
||||
y += 10;
|
||||
y += _prefs->smallLineH() + 1;
|
||||
display.setCursor(0, y);
|
||||
// Show masked password with brief reveal of last char
|
||||
char passBuf[WEB_WIFI_PASS_LEN + 2];
|
||||
@@ -2736,7 +2743,11 @@ private:
|
||||
}
|
||||
display.setCursor(0, 80);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap: Retry");
|
||||
#else
|
||||
display.print("Enter: Retry Q: Back");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Footer
|
||||
@@ -2745,7 +2756,14 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
if (_wifiState == WIFI_ENTERING_PASS)
|
||||
display.print("Tap: Enter Password Hold: Back");
|
||||
else
|
||||
display.print("Swipe: Navigate Tap: Select");
|
||||
#else
|
||||
display.print("Q:Back W/S:Nav Ent:Select");
|
||||
#endif
|
||||
}
|
||||
|
||||
void renderHome(DisplayDriver& display) {
|
||||
@@ -2756,7 +2774,7 @@ private:
|
||||
|
||||
if (isNetworkAvailable()) {
|
||||
display.print("Web Reader");
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
if (isWiFiConnected()) {
|
||||
IPAddress ip = WiFi.localIP();
|
||||
@@ -2782,7 +2800,7 @@ private:
|
||||
const int footerY = display.height() - 12;
|
||||
const int viewportH = display.height() - headerY - footerH;
|
||||
const int scrollbarW = 4;
|
||||
const int listLineH = 8;
|
||||
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
|
||||
const int sepH = 8; // Separator between IRC and web sections
|
||||
const int sectionH = listLineH; // Section header height
|
||||
int maxChars = _charsPerLine - 2; // Account for "> " prefix
|
||||
@@ -2860,7 +2878,7 @@ private:
|
||||
if (totalContentH <= viewportH) _homeScrollY = 0;
|
||||
|
||||
// ---- Render pass (with scroll offset) ----
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int y = headerY - _homeScrollY; // Start Y in screen coords
|
||||
itemIdx = 0;
|
||||
bool needsScroll = (totalContentH > viewportH);
|
||||
@@ -2877,7 +2895,11 @@ private:
|
||||
if (HOME_VISIBLE(y, ircH)) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -2912,7 +2934,11 @@ private:
|
||||
if (HOME_VISIBLE(y, urlBarH)) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -2945,7 +2971,11 @@ private:
|
||||
if (HOME_VISIBLE(y, searchBarH)) {
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -2994,7 +3024,11 @@ private:
|
||||
int contentW = display.width() - (needsScroll ? scrollbarW + 1 : 0);
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, contentW, itemH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, itemH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -3042,7 +3076,11 @@ private:
|
||||
int contentW = display.width() - (needsScroll ? scrollbarW + 1 : 0);
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, contentW, itemH);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, itemH);
|
||||
#else
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -3108,6 +3146,19 @@ private:
|
||||
display.print("Type query Ent:Search");
|
||||
} else {
|
||||
char footerBuf[48];
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
bool onBookmark = (_homeSelected >= 3 && _homeSelected < 3 + (int)_bookmarks.size());
|
||||
bool onUrl = (_homeSelected == 1);
|
||||
bool onSearch = (_homeSelected == 2);
|
||||
if (onUrl)
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Tap: Enter URL Hold: Back");
|
||||
else if (onSearch)
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Tap: Search Hold: Back");
|
||||
else if (onBookmark)
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Swipe: Navigate Tap: Open Hold: Delete");
|
||||
else
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Swipe: Navigate Tap: Open Hold: Exit");
|
||||
#else
|
||||
bool hasData = (_cookieCount > 0 || !_history.empty());
|
||||
bool onBookmark = (_homeSelected >= 3 && _homeSelected < 3 + (int)_bookmarks.size());
|
||||
if (onBookmark && hasData)
|
||||
@@ -3118,6 +3169,7 @@ private:
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S Ent:Go X:Clr Ckies");
|
||||
else
|
||||
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S:Nav Ent:Go");
|
||||
#endif
|
||||
display.print(footerBuf);
|
||||
}
|
||||
|
||||
@@ -3149,17 +3201,27 @@ private:
|
||||
display.setCursor(10, 20);
|
||||
display.print("Loading...");
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(10, 45);
|
||||
|
||||
// Show truncated URL
|
||||
char urlDisp[40];
|
||||
strncpy(urlDisp, _urlBuffer, 38);
|
||||
urlDisp[38] = '\0';
|
||||
display.print(urlDisp);
|
||||
// Word-wrap the URL across multiple lines
|
||||
int urlLen = strlen(_urlBuffer);
|
||||
int y = 45;
|
||||
int off = 0;
|
||||
int maxChars = _charsPerLine > 2 ? _charsPerLine - 2 : 30; // small margin
|
||||
while (off < urlLen && y < 85) {
|
||||
int lineLen = urlLen - off;
|
||||
if (lineLen > maxChars) lineLen = maxChars;
|
||||
char lineBuf[128];
|
||||
snprintf(lineBuf, sizeof(lineBuf), "%.*s", lineLen, _urlBuffer + off);
|
||||
display.setCursor(10, y);
|
||||
display.print(lineBuf);
|
||||
off += lineLen;
|
||||
y += 8;
|
||||
}
|
||||
|
||||
display.setCursor(10, 60);
|
||||
display.setCursor(10, y + 4);
|
||||
display.setTextSize(1);
|
||||
char progBuf[48];
|
||||
int elapsed = (int)((millis() - _fetchStartTime) / 1000);
|
||||
if (_fetchRetryCount > 0) {
|
||||
@@ -3184,7 +3246,7 @@ private:
|
||||
display.print("Download Complete");
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 16);
|
||||
display.print("Saved to /books/:");
|
||||
@@ -3206,15 +3268,19 @@ private:
|
||||
|
||||
display.setCursor(0, y + 6);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap: Open in Reader");
|
||||
#else
|
||||
display.print("Ent: Open in Reader");
|
||||
display.setCursor(0, y + 16);
|
||||
display.print("Q: Back to browser");
|
||||
#endif
|
||||
} else {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("Download Failed");
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 18);
|
||||
display.print(_fetchError.c_str());
|
||||
@@ -3223,7 +3289,11 @@ private:
|
||||
|
||||
display.setCursor(0, 56);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap: Back to browser");
|
||||
#else
|
||||
display.print("Q: Back to browser");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Footer
|
||||
@@ -3232,7 +3302,11 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print(_downloadOk ? "Tap: Open in Reader" : "Tap: Back");
|
||||
#else
|
||||
display.print(_downloadOk ? "Ent:Read Q:Back" : "Q:Back");
|
||||
#endif
|
||||
}
|
||||
|
||||
void renderReading(DisplayDriver& display) {
|
||||
@@ -3243,7 +3317,7 @@ private:
|
||||
return;
|
||||
}
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Determine page bounds
|
||||
@@ -3360,6 +3434,13 @@ private:
|
||||
if (_linkInputActive) {
|
||||
snprintf(linkBuf, sizeof(linkBuf), "#%d_ Ent:Go", _linkInput);
|
||||
hint = linkBuf;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
} else if (_linkCount > 0) {
|
||||
hint = "Tap: Page | Tap Footer Bar: Enter Link # | Hold: Back";
|
||||
} else {
|
||||
hint = "Tap: Page Hold: Back";
|
||||
}
|
||||
#else
|
||||
} else if (_formCount > 0 && _linkCount > 0) {
|
||||
hint = "L:Lnk F:Frm B:Bk Q:X";
|
||||
} else if (_formCount > 0) {
|
||||
@@ -3369,6 +3450,7 @@ private:
|
||||
} else {
|
||||
hint = "B:Bk Q:X";
|
||||
}
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(hint) - 2, footerY);
|
||||
display.print(hint);
|
||||
|
||||
@@ -3397,9 +3479,16 @@ private:
|
||||
// ---- Layout Initialization ----
|
||||
|
||||
void initLayout(DisplayDriver& display) {
|
||||
// Re-init if font preference changed since last layout
|
||||
uint8_t curFont = _prefs ? _prefs->large_font : 0;
|
||||
if (_initialized && curFont != _lastFontPref) {
|
||||
_initialized = false;
|
||||
Serial.println("WebReader: font changed, recalculating layout");
|
||||
}
|
||||
if (_initialized) return;
|
||||
_lastFontPref = curFont;
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
uint16_t mWidth = display.getTextWidth("M");
|
||||
if (mWidth > 0) {
|
||||
_charsPerLine = display.width() / mWidth;
|
||||
@@ -3408,6 +3497,19 @@ private:
|
||||
_charsPerLine = 40;
|
||||
_lineHeight = 5;
|
||||
}
|
||||
// Proportional font: use average-width measurement instead of M-width
|
||||
if (_prefs && _prefs->large_font && mWidth > 0) {
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
|
||||
if (_prefs && _prefs->large_font) {
|
||||
_lineHeight = _prefs->smallLineH();
|
||||
}
|
||||
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _footerHeight;
|
||||
@@ -3852,7 +3954,7 @@ private:
|
||||
if (_activeForm < 0 || _activeForm >= _formCount) return;
|
||||
WebForm& form = _forms[_activeForm];
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
// Header
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -3875,7 +3977,7 @@ private:
|
||||
display.drawRect(0, 9, display.width(), 1);
|
||||
|
||||
int y = 12;
|
||||
int lineH = 10; // Taller lines for form fields
|
||||
int lineH = _prefs->smallLineH() + 1; // Taller lines for form fields
|
||||
int visCount = getVisibleFieldCount(form);
|
||||
|
||||
// Render each visible field
|
||||
@@ -3895,7 +3997,11 @@ private:
|
||||
// Field value
|
||||
if (isActive) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), 9);
|
||||
#else
|
||||
display.fillRect(0, y + 4, display.width(), 9);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -3965,10 +4071,14 @@ private:
|
||||
display.print("Type text Ent:Next Q:Undo");
|
||||
} else {
|
||||
const char* hint;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
hint = "Swipe: Navigate Tap: Edit Hold: Back";
|
||||
#else
|
||||
if (_formCount > 1)
|
||||
hint = "W/S:Nav Ent:Edit </>:Form Q:Back";
|
||||
else
|
||||
hint = "W/S:Nav Ent:Edit/Go Q:Back";
|
||||
#endif
|
||||
display.print(hint);
|
||||
}
|
||||
}
|
||||
@@ -4575,9 +4685,9 @@ private:
|
||||
display.print("IRC Setup");
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int y = 16;
|
||||
int lineH = 10;
|
||||
int lineH = _prefs->smallLineH() + 1;
|
||||
|
||||
const char* labels[] = {"Server:", "Port:", "Nick:", "Channel:", "[ Connect ]"};
|
||||
const char* chanDisp = (_ircChannel[0] != '\0') ? _ircChannel : "(none)";
|
||||
@@ -4589,7 +4699,11 @@ private:
|
||||
bool sel = (_ircSetupField == i);
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineH);
|
||||
#else
|
||||
display.fillRect(0, y + 4, display.width(), lineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -4637,7 +4751,11 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Swipe: Navigate Tap: Edit Hold: Back");
|
||||
#else
|
||||
display.print("W/S:Nav Ent:Edit/Go Q:Back");
|
||||
#endif
|
||||
}
|
||||
|
||||
bool handleIRCSetupInput(char c) {
|
||||
@@ -4727,7 +4845,7 @@ private:
|
||||
display.print(header);
|
||||
|
||||
// Connection indicator on right
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
if (!_ircConnected) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(display.width() - 42, -3);
|
||||
@@ -4753,7 +4871,7 @@ private:
|
||||
|
||||
if (_ircComposing) {
|
||||
// Compose text just above separator (tiny font to match messages)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, footerY - 12);
|
||||
char compDisp[IRC_COMPOSE_MAX + 4];
|
||||
@@ -4767,18 +4885,26 @@ private:
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap: Send Hold: Exit");
|
||||
#else
|
||||
display.print("Ent:Send Del:Exit");
|
||||
#endif
|
||||
} else {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Tap: Compose Swipe: Scroll Hold: Back");
|
||||
#else
|
||||
display.print("Ent:Msg W/S:Scrl Q:Bk");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Message area
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int msgAreaTop = 14;
|
||||
int msgAreaBottom = _ircComposing ? footerY - 16 : footerY - 4;
|
||||
int lineH = 8;
|
||||
int lineH = _prefs->smallLineH() - 1;
|
||||
int scrollBarW = 4;
|
||||
int lineW = _charsPerLine - 1; // Reserve space for scroll bar
|
||||
_ircLinesPerPage = (msgAreaBottom - msgAreaTop) / lineH;
|
||||
@@ -4962,8 +5088,8 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
WebReaderScreen(UITask* task)
|
||||
: _task(task), _mode(HOME), _initialized(false), _display(nullptr),
|
||||
WebReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _mode(HOME), _initialized(false), _lastFontPref(0), _display(nullptr),
|
||||
_charsPerLine(40), _linesPerPage(15), _lineHeight(5), _footerHeight(14),
|
||||
_wifiState(WIFI_IDLE), _ssidCount(0), _selectedSSID(0), _wifiPassLen(0),
|
||||
_urlLen(0), _urlCursor(0),
|
||||
@@ -5047,7 +5173,7 @@ public:
|
||||
_display->print("Web Reader");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Connecting to WiFi...");
|
||||
_display->endFrame();
|
||||
@@ -5159,6 +5285,33 @@ public:
|
||||
return (_mode == IRC_CHAT && _ircComposing) ||
|
||||
(_mode == IRC_SETUP && _ircSetupEditing);
|
||||
}
|
||||
|
||||
// ---- Accessors for T5S3 touch mapping and VKB integration ----
|
||||
int getHomeSelected() const { return _homeSelected; }
|
||||
int getLinkCount() const { return _linkCount; }
|
||||
int getBookmarkCount() const { return (int)_bookmarks.size(); }
|
||||
const char* getUrlText() const { return _urlBuffer; }
|
||||
|
||||
// Set URL text and activate editing mode (for VKB submit)
|
||||
void setUrlText(const char* text) {
|
||||
strncpy(_urlBuffer, text, WEB_MAX_URL_LEN - 1);
|
||||
_urlBuffer[WEB_MAX_URL_LEN - 1] = '\0';
|
||||
_urlLen = strlen(_urlBuffer);
|
||||
_urlEditing = true;
|
||||
}
|
||||
// Set search text and activate editing mode (for VKB submit)
|
||||
void setSearchText(const char* text) {
|
||||
strncpy(_searchBuffer, text, sizeof(_searchBuffer) - 1);
|
||||
_searchBuffer[sizeof(_searchBuffer) - 1] = '\0';
|
||||
_searchLen = strlen(_searchBuffer);
|
||||
_searchEditing = true;
|
||||
}
|
||||
// Set WiFi password text (for VKB submit)
|
||||
void setWifiPassText(const char* text) {
|
||||
strncpy(_wifiPass, text, WEB_WIFI_PASS_LEN - 1);
|
||||
_wifiPass[WEB_WIFI_PASS_LEN - 1] = '\0';
|
||||
_wifiPassLen = strlen(_wifiPass);
|
||||
}
|
||||
// Returns true if a password reveal is active and needs a refresh after expiry
|
||||
bool needsRevealRefresh() const {
|
||||
if (_formLastCharAt > 0 && (millis() - _formLastCharAt) < 900) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
// Emoji Picker with scrolling grid and scroll bar
|
||||
// 5 columns, 4 visible rows, scrollable through all 65 emoji
|
||||
// 5 columns, 4 visible rows, scrollable through all 76 emoji
|
||||
// WASD navigation, Enter to select, $/Q/Backspace to cancel
|
||||
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
@@ -12,71 +12,85 @@
|
||||
#define EMOJI_PICKER_TOTAL_ROWS ((EMOJI_COUNT + EMOJI_PICKER_COLS - 1) / EMOJI_PICKER_COLS)
|
||||
|
||||
static const char* EMOJI_LABELS[EMOJI_COUNT] = {
|
||||
// Faces/emotion
|
||||
"Lol", // 0 joy
|
||||
"Like", // 1 thumbsup
|
||||
"Sad", // 2 frown
|
||||
"WiFi", // 3 wireless
|
||||
"Inf", // 4 infinity
|
||||
"Rex", // 5 trex
|
||||
"Skul", // 6 skull
|
||||
"Cros", // 7 cross
|
||||
"Bolt", // 8 lightning
|
||||
"Hat", // 9 tophat
|
||||
"Moto", // 10 motorcycle
|
||||
"Leaf", // 11 seedling
|
||||
"AU", // 12 flag_au
|
||||
"Umbr", // 13 umbrella
|
||||
"Eye", // 14 nazar
|
||||
"Glob", // 15 globe
|
||||
"Rad", // 16 radioactive
|
||||
"Cow", // 17 cow
|
||||
"ET", // 18 alien
|
||||
"Inv", // 19 invader
|
||||
"Dagr", // 20 dagger
|
||||
"Grim", // 21 grimace
|
||||
"Mtn", // 22 mountain
|
||||
"End", // 23 end_arrow
|
||||
"Ring", // 24 hollow_circle
|
||||
"Drag", // 25 dragon
|
||||
"Web", // 26 globe_meridians
|
||||
"Eggp", // 27 eggplant
|
||||
"Shld", // 28 shield
|
||||
"Gogl", // 29 goggles
|
||||
"Lzrd", // 30 lizard
|
||||
"Zany", // 31 zany_face
|
||||
"Roo", // 32 kangaroo
|
||||
"Fthr", // 33 feather
|
||||
"Sun", // 34 bright
|
||||
"Wave", // 35 part_alt
|
||||
"Boat", // 36 motorboat
|
||||
"Domi", // 37 domino
|
||||
"Dish", // 38 satellite
|
||||
"Pass", // 39 customs
|
||||
"Cowb", // 40 cowboy
|
||||
"Whl", // 41 wheel
|
||||
"Koal", // 42 koala
|
||||
"Knob", // 43 control_knobs
|
||||
"Pch", // 44 peach
|
||||
"Race", // 45 racing_car
|
||||
"Mous", // 46 mouse
|
||||
"Shrm", // 47 mushroom
|
||||
"Bio", // 48 biohazard
|
||||
"Pnda", // 49 panda
|
||||
"Bang", // 50 anger
|
||||
"DrgF", // 51 dragon_face
|
||||
"Pagr", // 52 pager
|
||||
"Bee", // 53 bee
|
||||
"Bulb", // 54 bulb
|
||||
"Cat", // 55 cat
|
||||
"Flur", // 56 fleur
|
||||
"Moon", // 57 moon
|
||||
"Cafe", // 58 coffee
|
||||
"Toth", // 59 tooth
|
||||
"Prtz", // 60 pretzel
|
||||
"Abac", // 61 abacus
|
||||
"Moai", // 62 moai
|
||||
"Hiii", // 63 tipping
|
||||
"Hedg", // 64 hedgehog
|
||||
"Sad", // 1 frown
|
||||
"Cry", // 2 loudly_crying
|
||||
"Grim", // 3 grimace
|
||||
"Zany", // 4 zany_face
|
||||
"Cowb", // 5 cowboy
|
||||
// Thumbsup + heart
|
||||
"Like", // 6 thumbsup
|
||||
"Love", // 7 heart
|
||||
// Everything else
|
||||
"WiFi", // 8 wireless
|
||||
"Inf", // 9 infinity
|
||||
"Rex", // 10 trex
|
||||
"Skul", // 11 skull
|
||||
"Cros", // 12 cross
|
||||
"Bolt", // 13 lightning
|
||||
"Hat", // 14 tophat
|
||||
"Moto", // 15 motorcycle
|
||||
"Leaf", // 16 seedling
|
||||
"AU", // 17 flag_au
|
||||
"Umbr", // 18 umbrella
|
||||
"Eye", // 19 nazar
|
||||
"Glob", // 20 globe
|
||||
"Rad", // 21 radioactive
|
||||
"Cow", // 22 cow
|
||||
"ET", // 23 alien
|
||||
"Inv", // 24 invader
|
||||
"Dagr", // 25 dagger
|
||||
"Mtn", // 26 mountain
|
||||
"End", // 27 end_arrow
|
||||
"Ring", // 28 hollow_circle
|
||||
"Drag", // 29 dragon
|
||||
"Web", // 30 globe_meridians
|
||||
"Eggp", // 31 eggplant
|
||||
"Shld", // 32 shield
|
||||
"Gogl", // 33 goggles
|
||||
"Lzrd", // 34 lizard
|
||||
"Roo", // 35 kangaroo
|
||||
"Fthr", // 36 feather
|
||||
"Sun", // 37 bright
|
||||
"Wave", // 38 part_alt
|
||||
"Boat", // 39 motorboat
|
||||
"Domi", // 40 domino
|
||||
"Dish", // 41 satellite
|
||||
"Pass", // 42 customs
|
||||
"Whl", // 43 wheel
|
||||
"Koal", // 44 koala
|
||||
"Knob", // 45 control_knobs
|
||||
"Pch", // 46 peach
|
||||
"Race", // 47 racing_car
|
||||
"Mous", // 48 mouse
|
||||
"Shrm", // 49 mushroom
|
||||
"Bio", // 50 biohazard
|
||||
"Pnda", // 51 panda
|
||||
"Bang", // 52 anger
|
||||
"DrgF", // 53 dragon_face
|
||||
"Pagr", // 54 pager
|
||||
"Bee", // 55 bee
|
||||
"Bulb", // 56 bulb
|
||||
"Cat", // 57 cat
|
||||
"Flur", // 58 fleur
|
||||
"Moon", // 59 moon
|
||||
"Cafe", // 60 coffee
|
||||
"Toth", // 61 tooth
|
||||
"Prtz", // 62 pretzel
|
||||
"Abac", // 63 abacus
|
||||
"Moai", // 64 moai
|
||||
"Hiii", // 65 tipping
|
||||
"Hedg", // 66 hedgehog
|
||||
"Diam", // 67 diamond_suit
|
||||
"Spde", // 68 spade_suit
|
||||
"Piza", // 69 pizza
|
||||
"Luck", // 70 four_leaf_clover
|
||||
"Cld", // 71 cloud
|
||||
"Rckt", // 72 rocket
|
||||
"HFC", // 73 passport_control
|
||||
"Star", // 74 eight_spoked_asterisk
|
||||
"Sig", // 75 signal_strength
|
||||
};
|
||||
|
||||
struct EmojiPicker {
|
||||
@@ -105,13 +119,23 @@ struct EmojiPicker {
|
||||
|
||||
switch (key) {
|
||||
case 'w': case 'W': case 0xF2:
|
||||
if (row > 0) cursor -= EMOJI_PICKER_COLS;
|
||||
if (row > 0) {
|
||||
cursor -= EMOJI_PICKER_COLS;
|
||||
} else {
|
||||
// Wrap to last row, same column
|
||||
int target = (EMOJI_PICKER_TOTAL_ROWS - 1) * EMOJI_PICKER_COLS + col;
|
||||
cursor = (target >= EMOJI_COUNT) ? EMOJI_COUNT - 1 : target;
|
||||
}
|
||||
break;
|
||||
case 's': case 'S': case 0xF1:
|
||||
if (cursor + EMOJI_PICKER_COLS < EMOJI_COUNT)
|
||||
if (cursor + EMOJI_PICKER_COLS < EMOJI_COUNT) {
|
||||
cursor += EMOJI_PICKER_COLS;
|
||||
else if (row < EMOJI_PICKER_TOTAL_ROWS - 1)
|
||||
} else if (row < EMOJI_PICKER_TOTAL_ROWS - 1) {
|
||||
cursor = EMOJI_COUNT - 1;
|
||||
} else {
|
||||
// Wrap to first row, same column
|
||||
cursor = col;
|
||||
}
|
||||
break;
|
||||
case 'a': case 'A':
|
||||
if (cursor > 0) cursor--;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// HomeIcons — 12x12 icon sprites for T5S3 home screen tiles
|
||||
// MSB-first, 2 bytes per row (same format as emoji sprites)
|
||||
// =============================================================================
|
||||
|
||||
#include <stdint.h>
|
||||
#ifdef ESP32
|
||||
#include <pgmspace.h>
|
||||
#endif
|
||||
|
||||
#define HOME_ICON_W 12
|
||||
#define HOME_ICON_H 12
|
||||
|
||||
// ✉️ Envelope (Messages)
|
||||
static const uint8_t icon_envelope[] PROGMEM = {
|
||||
0xFF,0xF0, 0x80,0x10, 0xC0,0x30, 0xA0,0x50, 0x90,0x90, 0x89,0x10,
|
||||
0x86,0x10, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0xFF,0xF0,
|
||||
};
|
||||
|
||||
// 👥 People (Contacts)
|
||||
static const uint8_t icon_people[] PROGMEM = {
|
||||
0x31,0x80, 0x7B,0xC0, 0x7B,0xC0, 0x31,0x80, 0x00,0x00, 0x7B,0xC0,
|
||||
0xFD,0xE0, 0xFD,0xE0, 0x7B,0xC0, 0x00,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
|
||||
// 🎚 Sliders (Settings)
|
||||
static const uint8_t icon_gear[] PROGMEM = {
|
||||
0x22,0x20, 0x22,0x20, 0x72,0x70, 0x72,0x70, 0x27,0x20, 0x27,0x20,
|
||||
0x22,0x20, 0x72,0x20, 0x72,0x70, 0x22,0x70, 0x22,0x20, 0x22,0x20,
|
||||
};
|
||||
|
||||
// 📖 Book (Reader)
|
||||
static const uint8_t icon_book[] PROGMEM = {
|
||||
0x7F,0xC0, 0x41,0x40, 0x5D,0x40, 0x5D,0x40, 0x41,0x40, 0x5D,0x40,
|
||||
0x5D,0x40, 0x41,0x40, 0x5D,0x40, 0x41,0x40, 0x7F,0xC0, 0x00,0x00,
|
||||
};
|
||||
|
||||
// 🗒 Notepad (Notes)
|
||||
static const uint8_t icon_notepad[] PROGMEM = {
|
||||
0x3F,0xC0, 0x20,0x40, 0x2F,0x40, 0x20,0x40, 0x2F,0x40, 0x20,0x40,
|
||||
0x2F,0x40, 0x20,0x40, 0x2F,0x40, 0x20,0x40, 0x3F,0xC0, 0x00,0x00,
|
||||
};
|
||||
|
||||
// 🔍 Magnifying glass (Discover)
|
||||
static const uint8_t icon_search[] PROGMEM = {
|
||||
0x3C,0x00, 0x42,0x00, 0x81,0x00, 0x81,0x00, 0x81,0x00, 0x42,0x00,
|
||||
0x3C,0x00, 0x03,0x00, 0x01,0x80, 0x00,0xC0, 0x00,0x40, 0x00,0x00,
|
||||
};
|
||||
|
||||
// ⏰ Alarm Clock (AlarmScreen) — 12x12 home tile icon
|
||||
static const uint8_t icon_alarm[] PROGMEM = {
|
||||
0x40,0x40, 0x9E,0x20, 0x20,0x80, 0x44,0x40, 0x44,0x40, 0x46,0x40,
|
||||
0x40,0x40, 0x20,0x80, 0x1F,0x00, 0x00,0x00, 0x20,0x40, 0x40,0x20,
|
||||
};
|
||||
|
||||
// 🔔 Bell — 7x8 status bar indicator (alarm enabled)
|
||||
// MSB-first, 1 byte per row
|
||||
#define BELL_ICON_W 7
|
||||
#define BELL_ICON_H 8
|
||||
static const uint8_t icon_bell_small[] PROGMEM = {
|
||||
0x10, 0x38, 0x7C, 0x7C, 0x7C, 0xFE, 0x00, 0x10,
|
||||
};
|
||||
@@ -0,0 +1,538 @@
|
||||
#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>
|
||||
#include "EmojiSprites.h"
|
||||
|
||||
enum VKBStatus { VKB_EDITING, VKB_SUBMITTED, VKB_CANCELLED };
|
||||
|
||||
// What the keyboard is being used for (dispatch on submit)
|
||||
enum VKBPurpose {
|
||||
VKB_CHANNEL_MSG, // Send to channel
|
||||
VKB_DM, // Direct message to contact
|
||||
VKB_ADMIN_PASSWORD, // Repeater admin login
|
||||
VKB_ADMIN_CLI, // Repeater admin CLI command
|
||||
VKB_NOTES, // Insert text into notes
|
||||
VKB_SETTINGS_NAME, // Edit node name
|
||||
VKB_SETTINGS_TEXT, // Generic settings text edit (channel name, freq, APN)
|
||||
VKB_WIFI_PASSWORD, // WiFi password entry (settings screen)
|
||||
#ifdef MECK_WEB_READER
|
||||
VKB_WEB_URL, // Web reader URL entry
|
||||
VKB_WEB_SEARCH, // Web reader DuckDuckGo search query
|
||||
VKB_WEB_WIFI_PASS, // Web reader WiFi password
|
||||
VKB_WEB_LINK, // Web reader link number entry
|
||||
#endif
|
||||
VKB_TEXT_PAGE, // Text reader: go to page number
|
||||
};
|
||||
|
||||
class VirtualKeyboard {
|
||||
public:
|
||||
static const int MAX_TEXT = 140;
|
||||
|
||||
VirtualKeyboard() : _status(VKB_CANCELLED), _purpose(VKB_CHANNEL_MSG),
|
||||
_contextIdx(0), _textLen(0), _shifted(false), _symbols(false),
|
||||
_emojiMode(false), _emojiScroll(0) {
|
||||
_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;
|
||||
_emojiMode = false;
|
||||
_emojiScroll = 0;
|
||||
_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
|
||||
|
||||
// Render text with inline emoji sprites
|
||||
renderTextField(display);
|
||||
|
||||
// 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);
|
||||
|
||||
if (_emojiMode) {
|
||||
renderEmojiGrid(display);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
|
||||
if (_emojiMode) return handleEmojiTap(vx, vy);
|
||||
|
||||
// Check keyboard rows 0-2
|
||||
const char* const* layout = getLayout();
|
||||
|
||||
for (int row = 0; row < 3; row++) {
|
||||
int numKeys = strlen(layout[row]);
|
||||
int rowY = KEY_START_Y + row * (KEY_H + KEY_GAP);
|
||||
if (vy < rowY || vy >= rowY + KEY_H) continue;
|
||||
|
||||
int totalW = numKeys * KEY_W + (numKeys - 1) * KEY_GAP;
|
||||
int startX = (128 - totalW) / 2;
|
||||
|
||||
for (int k = 0; k < numKeys; k++) {
|
||||
int kx = startX + k * (KEY_W + KEY_GAP);
|
||||
if (vx >= kx && vx < kx + KEY_W) {
|
||||
char ch = layout[row][k];
|
||||
processKey(ch);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true; // Tap was in row area but between keys — consume
|
||||
}
|
||||
|
||||
// Check row 4 (variable width keys)
|
||||
int r4y = KEY_START_Y + 3 * (KEY_H + KEY_GAP);
|
||||
if (vy >= r4y && vy < r4y + KEY_H) {
|
||||
return handleRow4Tap(vx);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Swipe up on keyboard = cancel
|
||||
void cancel() { _status = VKB_CANCELLED; }
|
||||
|
||||
// --- Feed a raw ASCII character from an external physical keyboard ---
|
||||
// Maps standard ASCII control chars to internal VKB actions.
|
||||
// Returns true if the character was consumed.
|
||||
#ifdef MECK_CARDKB
|
||||
bool feedChar(char c) {
|
||||
if (_status != VKB_EDITING) return false;
|
||||
switch (c) {
|
||||
case '\r': processKey('>'); return true; // Enter → submit
|
||||
case '\b': processKey('<'); return true; // Backspace
|
||||
case 0x7F: processKey('<'); return true; // Delete → backspace
|
||||
case 0x1B: _status = VKB_CANCELLED; return true; // ESC → cancel
|
||||
case ' ': processKey('~'); return true; // Space
|
||||
default:
|
||||
// Printable ASCII → insert directly
|
||||
if (c >= 0x20 && c <= 0x7E) {
|
||||
if (_textLen < _maxLen) {
|
||||
_text[_textLen++] = c;
|
||||
_text[_textLen] = '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false; // Non-printable / nav keys — not consumed
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private:
|
||||
VKBStatus _status;
|
||||
VKBPurpose _purpose;
|
||||
int _contextIdx;
|
||||
char _text[MAX_TEXT + 1];
|
||||
int _textLen;
|
||||
int _maxLen;
|
||||
char _label[40];
|
||||
bool _shifted;
|
||||
bool _symbols;
|
||||
bool _emojiMode;
|
||||
int _emojiScroll;
|
||||
|
||||
// Emoji grid constants (virtual coords)
|
||||
static const int EMJ_COLS = 8;
|
||||
static const int EMJ_CELL = 15; // 12px sprite + 3px gap
|
||||
static const int EMJ_GRID_X = 4;
|
||||
static const int EMJ_GRID_Y = 34;
|
||||
static const int EMJ_VIS_ROWS = 5;
|
||||
|
||||
int emojiTotalRows() const { return (EMOJI_COUNT + EMJ_COLS - 1) / EMJ_COLS; }
|
||||
int emojiMaxScroll() const { int m = emojiTotalRows() - EMJ_VIS_ROWS; return m < 0 ? 0 : m; }
|
||||
|
||||
void renderEmojiGrid(DisplayDriver& display) {
|
||||
display.setTextSize(0);
|
||||
|
||||
for (int vr = 0; vr < EMJ_VIS_ROWS; vr++) {
|
||||
int absRow = _emojiScroll + vr;
|
||||
if (absRow >= emojiTotalRows()) break;
|
||||
|
||||
for (int col = 0; col < EMJ_COLS; col++) {
|
||||
int idx = absRow * EMJ_COLS + col;
|
||||
if (idx >= EMOJI_COUNT) break;
|
||||
|
||||
int cx = EMJ_GRID_X + col * EMJ_CELL;
|
||||
int cy = EMJ_GRID_Y + vr * EMJ_CELL;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
const uint8_t* sprite = (const uint8_t*)pgm_read_ptr(&EMOJI_SPRITES_LG[idx]);
|
||||
if (sprite) {
|
||||
display.drawXbm(cx + 1, cy + 1, sprite, EMOJI_LG_W, EMOJI_LG_H);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer: [Back] [▲] page/total [▼]
|
||||
int fy = EMJ_GRID_Y + EMJ_VIS_ROWS * EMJ_CELL + 2;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, fy - 1, 128, 1);
|
||||
|
||||
// Back button (inverted)
|
||||
display.fillRect(4, fy + 1, 30, 12);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
int bw = display.getTextWidth("Back");
|
||||
display.setCursor(4 + (30 - bw) / 2, fy + 2);
|
||||
display.print("Back");
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Scroll arrows (only if scrollable)
|
||||
if (emojiTotalRows() > EMJ_VIS_ROWS) {
|
||||
// Up arrow
|
||||
if (_emojiScroll > 0) {
|
||||
display.fillRect(50, fy + 1, 12, 12);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.setCursor(53, fy + 2);
|
||||
display.print("^");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// Page info
|
||||
char pg[8];
|
||||
snprintf(pg, sizeof(pg), "%d/%d", _emojiScroll + 1, emojiMaxScroll() + 1);
|
||||
int pw = display.getTextWidth(pg);
|
||||
display.setCursor(75 - pw / 2, fy + 2);
|
||||
display.print(pg);
|
||||
|
||||
// Down arrow
|
||||
if (_emojiScroll < emojiMaxScroll()) {
|
||||
display.fillRect(90, fy + 1, 12, 12);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.setCursor(93, fy + 2);
|
||||
display.print("v");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool handleEmojiTap(int vx, int vy) {
|
||||
int fy = EMJ_GRID_Y + EMJ_VIS_ROWS * EMJ_CELL + 2;
|
||||
|
||||
// Footer area
|
||||
if (vy >= fy) {
|
||||
if (vx >= 4 && vx < 34) {
|
||||
// Back button
|
||||
_emojiMode = false;
|
||||
return true;
|
||||
}
|
||||
if (vx >= 50 && vx < 62 && _emojiScroll > 0) {
|
||||
_emojiScroll--;
|
||||
return true;
|
||||
}
|
||||
if (vx >= 90 && vx < 102 && _emojiScroll < emojiMaxScroll()) {
|
||||
_emojiScroll++;
|
||||
return true;
|
||||
}
|
||||
return true; // Consume tap in footer
|
||||
}
|
||||
|
||||
// Grid area
|
||||
if (vy >= EMJ_GRID_Y && vy < EMJ_GRID_Y + EMJ_VIS_ROWS * EMJ_CELL) {
|
||||
int col = (vx - EMJ_GRID_X) / EMJ_CELL;
|
||||
int vr = (vy - EMJ_GRID_Y) / EMJ_CELL;
|
||||
if (col < 0 || col >= EMJ_COLS || vr < 0 || vr >= EMJ_VIS_ROWS) return true;
|
||||
|
||||
int idx = (_emojiScroll + vr) * EMJ_COLS + col;
|
||||
if (idx >= 0 && idx < EMOJI_COUNT) {
|
||||
insertEmoji(idx);
|
||||
_emojiMode = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true; // Consume any tap while in emoji mode
|
||||
}
|
||||
|
||||
void insertEmoji(int idx) {
|
||||
// Insert as UTF-8 directly (not escape bytes) so sent messages are valid
|
||||
uint8_t utf8[8];
|
||||
int len = emojiEncodeUtf8(EMOJI_CODEPOINTS[idx].cp, utf8);
|
||||
if (EMOJI_CODEPOINTS[idx].cp2 != 0)
|
||||
len += emojiEncodeUtf8(EMOJI_CODEPOINTS[idx].cp2, utf8 + len);
|
||||
if (_textLen + len > _maxLen) return;
|
||||
memcpy(_text + _textLen, utf8, len);
|
||||
_textLen += len;
|
||||
_text[_textLen] = '\0';
|
||||
}
|
||||
|
||||
// Render text field with inline emoji sprites (10×10)
|
||||
void renderTextField(DisplayDriver& display) {
|
||||
// Convert UTF-8 emoji to escape bytes for sprite lookup
|
||||
char sanitized[MAX_TEXT + 1];
|
||||
emojiSanitize(_text, sanitized, sizeof(sanitized));
|
||||
|
||||
int x = 2;
|
||||
int maxX = 124;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0);
|
||||
|
||||
for (int i = 0; sanitized[i] && x < maxX; i++) {
|
||||
uint8_t b = (uint8_t)sanitized[i];
|
||||
if (b == EMOJI_PAD_BYTE) continue;
|
||||
|
||||
if (isEmojiEscape(b)) {
|
||||
const uint8_t* sprite = getEmojiSpriteSm(b);
|
||||
if (sprite && x + EMOJI_SM_W < maxX) {
|
||||
display.drawXbm(x, 14, sprite, EMOJI_SM_W, EMOJI_SM_H);
|
||||
x += EMOJI_SM_W + 1;
|
||||
}
|
||||
} else {
|
||||
char ch[2] = { (char)b, '\0' };
|
||||
display.setCursor(x, 12);
|
||||
display.print(ch);
|
||||
x += display.getTextWidth(ch);
|
||||
}
|
||||
}
|
||||
|
||||
// Blinking cursor
|
||||
if (x < maxX) {
|
||||
display.setCursor(x, 12);
|
||||
display.print("_");
|
||||
}
|
||||
}
|
||||
|
||||
// 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, \x01 = sym toggle, \x02 = emoji, > = 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) {
|
||||
const R4Key keys[] = {
|
||||
{ 4, 20, '\x01', _symbols ? "ABC" : "123" },
|
||||
{ 26, 11, ',', "," },
|
||||
{ 39, 11, '\x02', "$" },
|
||||
{ 52, 37, '~', "space" },
|
||||
{ 91, 11, '.', "." },
|
||||
{ 104, 20, '>', "Send" }
|
||||
};
|
||||
|
||||
for (int i = 0; i < 6; i++) {
|
||||
bool special = (keys[i].ch == '\x01' || keys[i].ch == '>' || keys[i].ch == '\x02');
|
||||
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, 11, '\x02', nullptr },
|
||||
{ 52, 37, '~', nullptr },
|
||||
{ 91, 11, '.', nullptr },
|
||||
{ 104, 20, '>', nullptr }
|
||||
};
|
||||
for (int i = 0; i < 6; 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 == '\x02') {
|
||||
// Emoji picker toggle
|
||||
_emojiMode = !_emojiMode;
|
||||
_emojiScroll = 0;
|
||||
} else if (ch == '<') {
|
||||
// Backspace — UTF-8 aware (walk back past continuation bytes 10xxxxxx)
|
||||
if (_textLen > 0) {
|
||||
_textLen--;
|
||||
while (_textLen > 0 && ((uint8_t)_text[_textLen] & 0xC0) == 0x80) {
|
||||
_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
|
||||
@@ -0,0 +1,372 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// ApnDatabase.h - Embedded APN Lookup Table
|
||||
//
|
||||
// Maps MCC/MNC (Mobile Country Code / Mobile Network Code) to default APN
|
||||
// settings for common carriers worldwide. Compiled directly into flash (~3KB)
|
||||
// so users never need to manually install a lookup file.
|
||||
//
|
||||
// The modem queries IMSI via AT+CIMI to extract MCC (3 digits) + MNC (2-3
|
||||
// digits), then looks up the APN here. If not found, falls back to the
|
||||
// modem's existing PDP context (AT+CGDCONT?) or user-configured APN.
|
||||
//
|
||||
// To add a carrier: append to APN_DATABASE[] with the MCC+MNC as a single
|
||||
// integer. MNC can be 2 or 3 digits:
|
||||
// MCC=310, MNC=260 → mccmnc = 310260
|
||||
// MCC=505, MNC=01 → mccmnc = 50501
|
||||
//
|
||||
// Guard: HAS_4G_MODEM
|
||||
// =============================================================================
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#ifndef APN_DATABASE_H
|
||||
#define APN_DATABASE_H
|
||||
|
||||
struct ApnEntry {
|
||||
uint32_t mccmnc; // MCC+MNC as integer (e.g. 310260 for T-Mobile US)
|
||||
const char* apn; // APN string
|
||||
const char* carrier; // Human-readable carrier name (for debug/display)
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// APN Database — sorted by MCC for binary search potential (not required)
|
||||
//
|
||||
// Sources: carrier documentation, GSMA databases, community wikis.
|
||||
// This covers ~120 major carriers across key regions. Users with less
|
||||
// common carriers can set APN manually in Settings.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static const ApnEntry APN_DATABASE[] = {
|
||||
// =========================================================================
|
||||
// Australia (MCC 505)
|
||||
// =========================================================================
|
||||
{ 50501, "telstra.internet", "Telstra" },
|
||||
{ 50502, "yesinternet", "Optus" },
|
||||
{ 50503, "vfinternet.au", "Vodafone AU" },
|
||||
{ 50506, "3netaccess", "Three AU" },
|
||||
{ 50507, "telstra.internet", "Vodafone AU (MVNO)" }, // Many MVNOs on Telstra
|
||||
{ 50510, "telstra.internet", "Norfolk Tel" },
|
||||
{ 50512, "3netaccess", "Amaysim" }, // Optus MVNO
|
||||
{ 50514, "yesinternet", "Aussie Broadband" }, // Optus MVNO
|
||||
{ 50590, "yesinternet", "Optus MVNO" },
|
||||
|
||||
// =========================================================================
|
||||
// New Zealand (MCC 530)
|
||||
// =========================================================================
|
||||
{ 53001, "internet", "Vodafone NZ" },
|
||||
{ 53005, "internet", "Spark NZ" },
|
||||
{ 53024, "internet", "2degrees" },
|
||||
|
||||
// =========================================================================
|
||||
// United States (MCC 310, 311, 312, 313, 316)
|
||||
// =========================================================================
|
||||
{ 310012, "fast.t-mobile.com", "Verizon (old)" },
|
||||
{ 310026, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310030, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310032, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310060, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310160, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310200, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310210, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310220, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310230, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310240, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310250, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310260, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310270, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310310, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310490, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310530, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310580, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310660, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 310800, "fast.t-mobile.com", "T-Mobile US" },
|
||||
{ 311480, "vzwinternet", "Verizon" },
|
||||
{ 311481, "vzwinternet", "Verizon" },
|
||||
{ 311482, "vzwinternet", "Verizon" },
|
||||
{ 311483, "vzwinternet", "Verizon" },
|
||||
{ 311484, "vzwinternet", "Verizon" },
|
||||
{ 311489, "vzwinternet", "Verizon" },
|
||||
{ 310410, "fast.t-mobile.com", "AT&T (migrated)" },
|
||||
{ 310120, "att.mvno", "AT&T (Sprint)" },
|
||||
{ 312530, "iot.1nce.net", "1NCE IoT" },
|
||||
{ 310120, "tfdata", "Tracfone" },
|
||||
|
||||
// =========================================================================
|
||||
// Canada (MCC 302)
|
||||
// =========================================================================
|
||||
{ 30220, "internet.com", "Rogers" },
|
||||
{ 30221, "internet.com", "Rogers" },
|
||||
{ 30237, "internet.com", "Rogers" },
|
||||
{ 30272, "internet.com", "Rogers" },
|
||||
{ 30234, "sp.telus.com", "Telus" },
|
||||
{ 30286, "sp.telus.com", "Telus" },
|
||||
{ 30236, "sp.telus.com", "Telus" },
|
||||
{ 30261, "sp.bell.ca", "Bell" },
|
||||
{ 30263, "sp.bell.ca", "Bell" },
|
||||
{ 30267, "sp.bell.ca", "Bell" },
|
||||
{ 30268, "fido-core-appl1.apn", "Fido" },
|
||||
{ 30278, "internet.com", "SaskTel" },
|
||||
{ 30266, "sp.mb.com", "MTS" },
|
||||
|
||||
// =========================================================================
|
||||
// United Kingdom (MCC 234, 235)
|
||||
// =========================================================================
|
||||
{ 23410, "o2-internet", "O2 UK" },
|
||||
{ 23415, "three.co.uk", "Vodafone UK" },
|
||||
{ 23420, "three.co.uk", "Three UK" },
|
||||
{ 23430, "everywhere", "EE" },
|
||||
{ 23431, "everywhere", "EE" },
|
||||
{ 23432, "everywhere", "EE" },
|
||||
{ 23433, "everywhere", "EE" },
|
||||
{ 23450, "data.lycamobile.co.uk","Lycamobile UK" },
|
||||
{ 23486, "three.co.uk", "Three UK" },
|
||||
|
||||
// =========================================================================
|
||||
// Germany (MCC 262)
|
||||
// =========================================================================
|
||||
{ 26201, "internet.t-mobile", "Telekom DE" },
|
||||
{ 26202, "web.vodafone.de", "Vodafone DE" },
|
||||
{ 26203, "internet", "O2 DE" },
|
||||
{ 26207, "internet", "O2 DE" },
|
||||
|
||||
// =========================================================================
|
||||
// France (MCC 208)
|
||||
// =========================================================================
|
||||
{ 20801, "orange", "Orange FR" },
|
||||
{ 20810, "sl2sfr", "SFR" },
|
||||
{ 20815, "free", "Free Mobile" },
|
||||
{ 20820, "ofnew.fr", "Bouygues" },
|
||||
|
||||
// =========================================================================
|
||||
// Italy (MCC 222)
|
||||
// =========================================================================
|
||||
{ 22201, "mobile.vodafone.it", "TIM" },
|
||||
{ 22210, "mobile.vodafone.it", "Vodafone IT" },
|
||||
{ 22250, "internet.it", "Iliad IT" },
|
||||
{ 22288, "internet.wind", "WindTre" },
|
||||
{ 22299, "internet.wind", "WindTre" },
|
||||
|
||||
// =========================================================================
|
||||
// Spain (MCC 214)
|
||||
// =========================================================================
|
||||
{ 21401, "internet", "Vodafone ES" },
|
||||
{ 21403, "internet", "Orange ES" },
|
||||
{ 21404, "internet", "Yoigo" },
|
||||
{ 21407, "internet", "Movistar" },
|
||||
|
||||
// =========================================================================
|
||||
// Netherlands (MCC 204)
|
||||
// =========================================================================
|
||||
{ 20404, "internet", "Vodafone NL" },
|
||||
{ 20408, "internet", "KPN" },
|
||||
{ 20412, "internet", "Telfort" },
|
||||
{ 20416, "internet", "T-Mobile NL" },
|
||||
{ 20420, "internet", "T-Mobile NL" },
|
||||
|
||||
// =========================================================================
|
||||
// Sweden (MCC 240)
|
||||
// =========================================================================
|
||||
{ 24001, "internet.telia.se", "Telia SE" },
|
||||
{ 24002, "tre.se", "Three SE" },
|
||||
{ 24007, "internet.telenor.se", "Telenor SE" },
|
||||
|
||||
// =========================================================================
|
||||
// Norway (MCC 242)
|
||||
// =========================================================================
|
||||
{ 24201, "internet.telenor.no", "Telenor NO" },
|
||||
{ 24202, "internet.netcom.no", "Telia NO" },
|
||||
|
||||
// =========================================================================
|
||||
// Denmark (MCC 238)
|
||||
// =========================================================================
|
||||
{ 23801, "internet", "TDC" },
|
||||
{ 23802, "internet", "Telenor DK" },
|
||||
{ 23806, "internet", "Three DK" },
|
||||
{ 23820, "internet", "Telia DK" },
|
||||
|
||||
// =========================================================================
|
||||
// Switzerland (MCC 228)
|
||||
// =========================================================================
|
||||
{ 22801, "gprs.swisscom.ch", "Swisscom" },
|
||||
{ 22802, "internet", "Sunrise" },
|
||||
{ 22803, "internet", "Salt" },
|
||||
|
||||
// =========================================================================
|
||||
// Austria (MCC 232)
|
||||
// =========================================================================
|
||||
{ 23201, "a1.net", "A1" },
|
||||
{ 23203, "web.one.at", "Three AT" },
|
||||
{ 23205, "web", "T-Mobile AT" },
|
||||
|
||||
// =========================================================================
|
||||
// Japan (MCC 440, 441)
|
||||
// =========================================================================
|
||||
{ 44010, "spmode.ne.jp", "NTT Docomo" },
|
||||
{ 44020, "plus.4g", "SoftBank" },
|
||||
{ 44051, "au.au-net.ne.jp", "KDDI au" },
|
||||
|
||||
// =========================================================================
|
||||
// South Korea (MCC 450)
|
||||
// =========================================================================
|
||||
{ 45005, "lte.sktelecom.com", "SK Telecom" },
|
||||
{ 45006, "lte.ktfwing.com", "KT" },
|
||||
{ 45008, "lte.lguplus.co.kr", "LG U+" },
|
||||
|
||||
// =========================================================================
|
||||
// India (MCC 404, 405)
|
||||
// =========================================================================
|
||||
{ 40445, "airtelgprs.com", "Airtel" },
|
||||
{ 40410, "airtelgprs.com", "Airtel" },
|
||||
{ 40411, "www", "Vodafone IN (Vi)" },
|
||||
{ 40413, "www", "Vodafone IN (Vi)" },
|
||||
{ 40486, "www", "Vodafone IN (Vi)" },
|
||||
{ 40553, "jionet", "Jio" },
|
||||
{ 40554, "jionet", "Jio" },
|
||||
{ 40512, "bsnlnet", "BSNL" },
|
||||
|
||||
// =========================================================================
|
||||
// Singapore (MCC 525)
|
||||
// =========================================================================
|
||||
{ 52501, "internet", "Singtel" },
|
||||
{ 52503, "internet", "M1" },
|
||||
{ 52505, "internet", "StarHub" },
|
||||
|
||||
// =========================================================================
|
||||
// Hong Kong (MCC 454)
|
||||
// =========================================================================
|
||||
{ 45400, "internet", "CSL" },
|
||||
{ 45406, "internet", "SmarTone" },
|
||||
{ 45412, "internet", "CMHK" },
|
||||
|
||||
// =========================================================================
|
||||
// Brazil (MCC 724)
|
||||
// =========================================================================
|
||||
{ 72405, "claro.com.br", "Claro BR" },
|
||||
{ 72406, "wap.oi.com.br", "Vivo" },
|
||||
{ 72410, "wap.oi.com.br", "Vivo" },
|
||||
{ 72411, "wap.oi.com.br", "Vivo" },
|
||||
{ 72415, "internet.tim.br", "TIM BR" },
|
||||
{ 72431, "gprs.oi.com.br", "Oi" },
|
||||
|
||||
// =========================================================================
|
||||
// Mexico (MCC 334)
|
||||
// =========================================================================
|
||||
{ 33402, "internet.itelcel.com","Telcel" },
|
||||
{ 33403, "internet.movistar.mx","Movistar MX" },
|
||||
{ 33404, "internet.att.net.mx", "AT&T MX" },
|
||||
|
||||
// =========================================================================
|
||||
// South Africa (MCC 655)
|
||||
// =========================================================================
|
||||
{ 65501, "internet", "Vodacom" },
|
||||
{ 65502, "internet", "Telkom ZA" },
|
||||
{ 65507, "internet", "Cell C" },
|
||||
{ 65510, "internet", "MTN ZA" },
|
||||
|
||||
// =========================================================================
|
||||
// Philippines (MCC 515)
|
||||
// =========================================================================
|
||||
{ 51502, "internet.globe.com.ph","Globe" },
|
||||
{ 51503, "internet", "Smart" },
|
||||
{ 51505, "internet", "Sun Cellular" },
|
||||
|
||||
// =========================================================================
|
||||
// Thailand (MCC 520)
|
||||
// =========================================================================
|
||||
{ 52001, "internet", "AIS" },
|
||||
{ 52004, "internet", "TrueMove" },
|
||||
{ 52005, "internet", "dtac" },
|
||||
|
||||
// =========================================================================
|
||||
// Indonesia (MCC 510)
|
||||
// =========================================================================
|
||||
{ 51001, "internet", "Telkomsel" },
|
||||
{ 51010, "internet", "Telkomsel" },
|
||||
{ 51011, "3gprs", "XL Axiata" },
|
||||
{ 51028, "3gprs", "XL Axiata (Axis)" },
|
||||
|
||||
// =========================================================================
|
||||
// Malaysia (MCC 502)
|
||||
// =========================================================================
|
||||
{ 50212, "celcom3g", "Celcom" },
|
||||
{ 50213, "celcom3g", "Celcom" },
|
||||
{ 50216, "internet", "Digi" },
|
||||
{ 50219, "celcom3g", "Celcom" },
|
||||
|
||||
// =========================================================================
|
||||
// Czech Republic (MCC 230)
|
||||
// =========================================================================
|
||||
{ 23001, "internet.t-mobile.cz","T-Mobile CZ" },
|
||||
{ 23002, "internet", "O2 CZ" },
|
||||
{ 23003, "internet.vodafone.cz","Vodafone CZ" },
|
||||
|
||||
// =========================================================================
|
||||
// Poland (MCC 260)
|
||||
// =========================================================================
|
||||
{ 26001, "internet", "Plus PL" },
|
||||
{ 26002, "internet", "T-Mobile PL" },
|
||||
{ 26003, "internet", "Orange PL" },
|
||||
{ 26006, "internet", "Play" },
|
||||
|
||||
// =========================================================================
|
||||
// Portugal (MCC 268)
|
||||
// =========================================================================
|
||||
{ 26801, "internet", "Vodafone PT" },
|
||||
{ 26803, "internet", "NOS" },
|
||||
{ 26806, "internet", "MEO" },
|
||||
|
||||
// =========================================================================
|
||||
// Ireland (MCC 272)
|
||||
// =========================================================================
|
||||
{ 27201, "internet", "Vodafone IE" },
|
||||
{ 27202, "open.internet", "Three IE" },
|
||||
{ 27205, "three.ie", "Three IE" },
|
||||
|
||||
// =========================================================================
|
||||
// IoT / Global SIMs
|
||||
// =========================================================================
|
||||
{ 901028, "iot.1nce.net", "1NCE (IoT)" },
|
||||
{ 90143, "hologram", "Hologram" },
|
||||
};
|
||||
|
||||
#define APN_DATABASE_SIZE (sizeof(APN_DATABASE) / sizeof(APN_DATABASE[0]))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lookup function — returns nullptr if not found
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
inline const ApnEntry* apnLookup(uint32_t mccmnc) {
|
||||
for (int i = 0; i < (int)APN_DATABASE_SIZE; i++) {
|
||||
if (APN_DATABASE[i].mccmnc == mccmnc) {
|
||||
return &APN_DATABASE[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Parse IMSI string into MCC+MNC. Tries 3-digit MNC first (6-digit mccmnc),
|
||||
// falls back to 2-digit MNC (5-digit mccmnc) if not found.
|
||||
inline const ApnEntry* apnLookupFromIMSI(const char* imsi) {
|
||||
if (!imsi || strlen(imsi) < 5) return nullptr;
|
||||
|
||||
// Extract MCC (always 3 digits)
|
||||
uint32_t mcc = (imsi[0] - '0') * 100 + (imsi[1] - '0') * 10 + (imsi[2] - '0');
|
||||
|
||||
// Try 3-digit MNC first (more specific)
|
||||
if (strlen(imsi) >= 6) {
|
||||
uint32_t mnc3 = (imsi[3] - '0') * 100 + (imsi[4] - '0') * 10 + (imsi[5] - '0');
|
||||
uint32_t mccmnc6 = mcc * 1000 + mnc3;
|
||||
const ApnEntry* entry = apnLookup(mccmnc6);
|
||||
if (entry) return entry;
|
||||
}
|
||||
|
||||
// Fall back to 2-digit MNC
|
||||
uint32_t mnc2 = (imsi[3] - '0') * 10 + (imsi[4] - '0');
|
||||
uint32_t mccmnc5 = mcc * 100 + mnc2;
|
||||
return apnLookup(mccmnc5);
|
||||
}
|
||||
|
||||
#endif // APN_DATABASE_H
|
||||
#endif // HAS_4G_MODEM
|
||||
@@ -0,0 +1,227 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// CellularMQTT — A7682E Modem + MQTT via native AT commands
|
||||
// =============================================================================
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#ifndef CELLULAR_MQTT_H
|
||||
#define CELLULAR_MQTT_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <freertos/queue.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include "variant.h"
|
||||
#include "ApnDatabase.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
#define MQTT_TOPIC_MAX 80
|
||||
#define MQTT_PAYLOAD_MAX 512
|
||||
#define MQTT_CLIENT_ID_MAX 32
|
||||
|
||||
#define CMD_QUEUE_SIZE 4
|
||||
#define RSP_QUEUE_SIZE 4
|
||||
|
||||
#define TELEMETRY_INTERVAL 60000
|
||||
|
||||
#define CELL_TASK_PRIORITY 1
|
||||
#define CELL_TASK_STACK_SIZE 8192
|
||||
#define CELL_TASK_CORE 0
|
||||
|
||||
#define MQTT_RECONNECT_MIN 5000
|
||||
#define MQTT_RECONNECT_MAX 300000
|
||||
|
||||
#define MQTT_PUB_FAIL_MAX 5
|
||||
|
||||
#define OTA_CHUNK_SIZE 1024
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State machine
|
||||
// ---------------------------------------------------------------------------
|
||||
enum class CellState : uint8_t {
|
||||
OFF,
|
||||
POWERING_ON,
|
||||
INITIALIZING,
|
||||
REGISTERING,
|
||||
DATA_ACTIVATING,
|
||||
MQTT_STARTING,
|
||||
MQTT_CONNECTING,
|
||||
CONNECTED,
|
||||
RECONNECTING,
|
||||
OTA_IN_PROGRESS,
|
||||
ERROR
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue message types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MQTTCommand {
|
||||
char cmd[MQTT_PAYLOAD_MAX];
|
||||
};
|
||||
|
||||
struct MQTTResponse {
|
||||
char topic[MQTT_TOPIC_MAX];
|
||||
char payload[MQTT_PAYLOAD_MAX];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MQTT config (loaded from SD: /remote/mqtt.cfg)
|
||||
// ---------------------------------------------------------------------------
|
||||
struct MQTTConfig {
|
||||
char broker[80];
|
||||
uint16_t port;
|
||||
char username[40];
|
||||
char password[40];
|
||||
char deviceId[MQTT_CLIENT_ID_MAX];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telemetry snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
struct TelemetryData {
|
||||
uint32_t uptime_secs;
|
||||
uint16_t battery_mv;
|
||||
uint8_t battery_pct;
|
||||
int16_t temperature;
|
||||
int csq;
|
||||
uint8_t neighbor_count;
|
||||
float freq;
|
||||
float bw;
|
||||
uint8_t sf;
|
||||
uint8_t cr;
|
||||
uint8_t tx_power;
|
||||
char node_name[32];
|
||||
char apn[40];
|
||||
char oper[24];
|
||||
bool mqtt_connected;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CellularMQTT class
|
||||
// ---------------------------------------------------------------------------
|
||||
class CellularMQTT {
|
||||
public:
|
||||
void begin();
|
||||
void stop();
|
||||
|
||||
// --- Queue API (called from main loop) ---
|
||||
bool recvCommand(MQTTCommand& out);
|
||||
bool sendResponse(const char* topic, const char* payload);
|
||||
|
||||
// --- Telemetry ---
|
||||
void updateTelemetry(const TelemetryData& data);
|
||||
|
||||
// --- OTA ---
|
||||
void requestOTA(const char* url);
|
||||
bool isOTAInProgress() const { return _state == CellState::OTA_IN_PROGRESS; }
|
||||
|
||||
// --- State queries ---
|
||||
CellState getState() const { return _state; }
|
||||
bool isConnected() const { return _state == CellState::CONNECTED; }
|
||||
int getCSQ() const { return _csq; }
|
||||
int getSignalBars() const;
|
||||
const char* getOperator() const { return _operator; }
|
||||
const char* getIPAddress() const { return _ipAddr; }
|
||||
const char* getBroker() const { return _config.broker; }
|
||||
const char* getAPN() const { return _apn; }
|
||||
const char* getRspTopic() const { return _topicRsp; }
|
||||
const char* stateString() const;
|
||||
uint32_t getLastCmdTime() const { return _lastCmdTime; }
|
||||
|
||||
static bool loadConfig(MQTTConfig& cfg);
|
||||
|
||||
private:
|
||||
volatile CellState _state = CellState::OFF;
|
||||
volatile int _csq = 99;
|
||||
volatile uint32_t _lastCmdTime = 0;
|
||||
|
||||
char _operator[24] = {0};
|
||||
char _ipAddr[20] = {0};
|
||||
char _imei[20] = {0};
|
||||
char _imsi[20] = {0};
|
||||
char _apn[64] = {0};
|
||||
|
||||
MQTTConfig _config = {};
|
||||
TelemetryData _telemetry = {};
|
||||
SemaphoreHandle_t _telemetryMutex = nullptr;
|
||||
|
||||
char _topicCmd[MQTT_TOPIC_MAX] = {0};
|
||||
char _topicRsp[MQTT_TOPIC_MAX] = {0};
|
||||
char _topicTelem[MQTT_TOPIC_MAX] = {0};
|
||||
char _topicOta[MQTT_TOPIC_MAX] = {0};
|
||||
|
||||
TaskHandle_t _taskHandle = nullptr;
|
||||
QueueHandle_t _cmdQueue = nullptr;
|
||||
QueueHandle_t _rspQueue = nullptr;
|
||||
SemaphoreHandle_t _uartMutex = nullptr;
|
||||
|
||||
uint8_t _pubFailCount = 0;
|
||||
|
||||
static const int AT_BUF_SIZE = 512;
|
||||
char _atBuf[AT_BUF_SIZE];
|
||||
|
||||
static const int URC_BUF_SIZE = 600;
|
||||
char _urcBuf[URC_BUF_SIZE];
|
||||
int _urcPos = 0;
|
||||
|
||||
enum MqttRxState { RX_IDLE, RX_WAIT_TOPIC, RX_WAIT_PAYLOAD };
|
||||
MqttRxState _rxState = RX_IDLE;
|
||||
int _rxTopicLen = 0;
|
||||
int _rxPayloadLen = 0;
|
||||
char _rxTopic[MQTT_TOPIC_MAX];
|
||||
char _rxPayload[MQTT_PAYLOAD_MAX];
|
||||
|
||||
uint32_t _reconnectDelay = MQTT_RECONNECT_MIN;
|
||||
|
||||
// OTA state
|
||||
volatile bool _otaPending = false;
|
||||
char _otaUrl[256] = {0};
|
||||
|
||||
// --- Modem UART helpers ---
|
||||
bool modemPowerOn();
|
||||
bool sendAT(const char* cmd, const char* expect, uint32_t timeout_ms = 2000);
|
||||
bool waitResponse(const char* expect, uint32_t timeout_ms, char* buf = nullptr, size_t bufLen = 0);
|
||||
bool waitPrompt(uint32_t timeout_ms = 5000);
|
||||
void drainURCs();
|
||||
void processURCLine(const char* line);
|
||||
|
||||
// --- Data connection ---
|
||||
void resolveAPN();
|
||||
bool activateData();
|
||||
|
||||
// --- MQTT operations ---
|
||||
bool mqttStart();
|
||||
bool mqttConnect();
|
||||
bool mqttSubscribe(const char* topic);
|
||||
bool mqttPublish(const char* topic, const char* payload);
|
||||
void mqttDisconnect();
|
||||
|
||||
// --- URC handlers ---
|
||||
void handleMqttRxStart(const char* line);
|
||||
void handleMqttRxTopic(const char* data, int len);
|
||||
void handleMqttRxPayload(const char* data, int len);
|
||||
void handleMqttRxEnd();
|
||||
void handleMqttConnLost(const char* line);
|
||||
|
||||
// --- OTA operations (modem task only) ---
|
||||
void performOTA();
|
||||
int httpGet(const char* url);
|
||||
bool httpReadChunk(int offset, int len, uint8_t* dest, int* bytesRead);
|
||||
void httpTerm();
|
||||
int readRawBytes(uint8_t* dest, int count, uint32_t timeout_ms);
|
||||
|
||||
// --- Task ---
|
||||
static void taskEntry(void* param);
|
||||
void taskLoop();
|
||||
};
|
||||
|
||||
extern CellularMQTT cellularMQTT;
|
||||
|
||||
#endif // CELLULAR_MQTT_H
|
||||
#endif // HAS_4G_MODEM
|
||||
+1076
-1927
File diff suppressed because it is too large
Load Diff
@@ -68,11 +68,11 @@ struct NeighbourInfo {
|
||||
};
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "30 Nov 2025"
|
||||
#define FIRMWARE_BUILD_DATE "3 April 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "v1.11.0"
|
||||
#define FIRMWARE_VERSION "v0.2"
|
||||
#endif
|
||||
|
||||
#define FIRMWARE_ROLE "repeater"
|
||||
|
||||
@@ -2,7 +2,21 @@
|
||||
#include <Arduino.h>
|
||||
#include <helpers/CommonCLI.h>
|
||||
|
||||
#define AUTO_OFF_MILLIS 20000 // 20 seconds
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "CellularMQTT.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WIFI_REMOTE
|
||||
#include "WiFiMQTT.h"
|
||||
#endif
|
||||
|
||||
#if defined(HAS_4G_MODEM) || defined(MECK_WIFI_REMOTE)
|
||||
#define AUTO_OFF_DISABLED true
|
||||
#else
|
||||
#define AUTO_OFF_DISABLED false
|
||||
#endif
|
||||
|
||||
#define AUTO_OFF_MILLIS 20000 // 20 seconds (ignored when AUTO_OFF_DISABLED)
|
||||
#define BOOT_SCREEN_MILLIS 4000 // 4 seconds
|
||||
|
||||
// 'meshcore', 128x13px
|
||||
@@ -28,55 +42,144 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
|
||||
_node_prefs = node_prefs;
|
||||
_display->turnOn();
|
||||
|
||||
// strip off dash and commit hash by changing dash to null terminator
|
||||
// e.g: v1.2.3-abcdef -> v1.2.3
|
||||
char *version = strdup(firmware_version);
|
||||
char *dash = strchr(version, '-');
|
||||
if(dash){
|
||||
*dash = 0;
|
||||
}
|
||||
if (dash) *dash = 0;
|
||||
|
||||
// v1.2.3 (1 Jan 2025)
|
||||
sprintf(_version_info, "%s (%s)", version, build_date);
|
||||
snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date);
|
||||
free(version);
|
||||
}
|
||||
|
||||
void UITask::renderCurrScreen() {
|
||||
char tmp[80];
|
||||
if (millis() < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
// meshcore logo
|
||||
if (millis() < BOOT_SCREEN_MILLIS) {
|
||||
// Boot screen — logo + version
|
||||
_display->setColor(DisplayDriver::BLUE);
|
||||
int logoWidth = 128;
|
||||
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||||
|
||||
// version info
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(1);
|
||||
uint16_t versionWidth = _display->getTextWidth(_version_info);
|
||||
_display->setCursor((_display->width() - versionWidth) / 2, 22);
|
||||
_display->print(_version_info);
|
||||
|
||||
// node type
|
||||
#if defined(HAS_4G_MODEM)
|
||||
const char* node_type = "< Remote Repeater >";
|
||||
#elif defined(MECK_WIFI_REMOTE)
|
||||
const char* node_type = "< WiFi Repeater >";
|
||||
#else
|
||||
const char* node_type = "< Repeater >";
|
||||
#endif
|
||||
uint16_t typeWidth = _display->getTextWidth(node_type);
|
||||
_display->setCursor((_display->width() - typeWidth) / 2, 35);
|
||||
_display->print(node_type);
|
||||
} else { // home screen
|
||||
// node name
|
||||
} else {
|
||||
// Home screen — node info + connection status
|
||||
_display->setCursor(0, 0);
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->print(_node_prefs->node_name);
|
||||
|
||||
// freq / sf
|
||||
_display->setCursor(0, 20);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
||||
_display->print(tmp);
|
||||
|
||||
// bw / cr
|
||||
_display->setCursor(0, 30);
|
||||
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
||||
_display->print(tmp);
|
||||
|
||||
// --- Cellular status (4G variant) ---
|
||||
#ifdef HAS_4G_MODEM
|
||||
int y = 44;
|
||||
|
||||
_display->setCursor(0, y);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
sprintf(tmp, "4G: %s", cellularMQTT.stateString());
|
||||
_display->print(tmp);
|
||||
y += 10;
|
||||
|
||||
_display->setCursor(0, y);
|
||||
sprintf(tmp, "CSQ: %d (%d bars)", cellularMQTT.getCSQ(), cellularMQTT.getSignalBars());
|
||||
_display->print(tmp);
|
||||
y += 10;
|
||||
|
||||
const char* oper = cellularMQTT.getOperator();
|
||||
if (oper[0]) {
|
||||
_display->setCursor(0, y);
|
||||
sprintf(tmp, "Op: %.16s", oper);
|
||||
_display->print(tmp);
|
||||
y += 10;
|
||||
}
|
||||
|
||||
_display->setCursor(0, y);
|
||||
_display->setColor(cellularMQTT.isConnected() ? DisplayDriver::GREEN : DisplayDriver::YELLOW);
|
||||
sprintf(tmp, "MQTT: %s", cellularMQTT.isConnected() ? "Connected" : "---");
|
||||
_display->print(tmp);
|
||||
y += 10;
|
||||
|
||||
const char* ip4g = cellularMQTT.getIPAddress();
|
||||
if (ip4g[0]) {
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setCursor(0, y);
|
||||
sprintf(tmp, "IP: %s", ip4g);
|
||||
_display->print(tmp);
|
||||
y += 10;
|
||||
}
|
||||
|
||||
uint32_t upSec = millis() / 1000;
|
||||
uint32_t upH = upSec / 3600;
|
||||
uint32_t upM = (upSec % 3600) / 60;
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setCursor(0, y);
|
||||
sprintf(tmp, "Up: %luh %lum Heap:%dk", upH, upM, ESP.getFreeHeap() / 1024);
|
||||
_display->print(tmp);
|
||||
#endif
|
||||
|
||||
// --- WiFi status (WiFi variant) ---
|
||||
#ifdef MECK_WIFI_REMOTE
|
||||
int y = 44;
|
||||
|
||||
_display->setCursor(0, y);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
sprintf(tmp, "WiFi: %s", wifiMQTT.stateString());
|
||||
_display->print(tmp);
|
||||
y += 10;
|
||||
|
||||
_display->setCursor(0, y);
|
||||
sprintf(tmp, "RSSI: %d (%d bars)", wifiMQTT.getRSSI(), wifiMQTT.getSignalBars());
|
||||
_display->print(tmp);
|
||||
y += 10;
|
||||
|
||||
_display->setCursor(0, y);
|
||||
sprintf(tmp, "SSID: %.16s", wifiMQTT.getSSID());
|
||||
_display->print(tmp);
|
||||
y += 10;
|
||||
|
||||
_display->setCursor(0, y);
|
||||
_display->setColor(wifiMQTT.isConnected() ? DisplayDriver::GREEN : DisplayDriver::YELLOW);
|
||||
sprintf(tmp, "MQTT: %s", wifiMQTT.isConnected() ? "Connected" : "---");
|
||||
_display->print(tmp);
|
||||
y += 10;
|
||||
|
||||
const char* ipWifi = wifiMQTT.getIPAddress();
|
||||
if (ipWifi[0]) {
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setCursor(0, y);
|
||||
sprintf(tmp, "IP: %s", ipWifi);
|
||||
_display->print(tmp);
|
||||
y += 10;
|
||||
}
|
||||
|
||||
uint32_t upSec = millis() / 1000;
|
||||
uint32_t upH = upSec / 3600;
|
||||
uint32_t upM = (upSec % 3600) / 60;
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setCursor(0, y);
|
||||
sprintf(tmp, "Up: %luh %lum Heap:%dk", upH, upM, ESP.getFreeHeap() / 1024);
|
||||
_display->print(tmp);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,17 +188,15 @@ void UITask::loop() {
|
||||
if (millis() >= _next_read) {
|
||||
int btnState = digitalRead(PIN_USER_BTN);
|
||||
if (btnState != _prevBtnState) {
|
||||
if (btnState == LOW) { // pressed?
|
||||
if (_display->isOn()) {
|
||||
// TODO: any action ?
|
||||
} else {
|
||||
if (btnState == LOW) {
|
||||
if (!_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
}
|
||||
_prevBtnState = btnState;
|
||||
}
|
||||
_next_read = millis() + 200; // 5 reads per second
|
||||
_next_read = millis() + 200;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -105,10 +206,10 @@ void UITask::loop() {
|
||||
renderCurrScreen();
|
||||
_display->endFrame();
|
||||
|
||||
_next_refresh = millis() + 1000; // refresh every second
|
||||
_next_refresh = millis() + 10000;
|
||||
}
|
||||
if (millis() > _auto_off) {
|
||||
if (!AUTO_OFF_DISABLED && millis() > _auto_off) {
|
||||
_display->turnOff();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ class UITask {
|
||||
unsigned long _next_read, _next_refresh, _auto_off;
|
||||
int _prevBtnState;
|
||||
NodePrefs* _node_prefs;
|
||||
char _version_info[32];
|
||||
char _version_info[48];
|
||||
|
||||
void renderCurrScreen();
|
||||
public:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,20 @@
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
#include <Mesh.h>
|
||||
|
||||
#include <time.h>
|
||||
#include "MyMesh.h"
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include <SD.h>
|
||||
#include "CellularMQTT.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WIFI_REMOTE
|
||||
#if defined(HAS_SDCARD) || defined(SDCARD_CS)
|
||||
#include <SD.h>
|
||||
#endif
|
||||
#include "WiFiMQTT.h"
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include "UITask.h"
|
||||
static UITask ui_task(display);
|
||||
@@ -23,6 +35,10 @@ static char command[160];
|
||||
unsigned long lastActive = 0; // mark last active time
|
||||
unsigned long nextSleepinSecs = 120; // next sleep in seconds. The first sleep (if enabled) is after 2 minutes from boot
|
||||
|
||||
#if (defined(HAS_4G_MODEM) || defined(MECK_WIFI_REMOTE)) && (defined(HAS_SDCARD) || defined(SDCARD_CS))
|
||||
static bool sdCardReady = false;
|
||||
#endif
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(1000);
|
||||
@@ -83,6 +99,72 @@ void setup() {
|
||||
|
||||
the_mesh.begin(fs);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SD card init — needed for MQTT config on devices with SD slots.
|
||||
// T-Deck Pro: SD shares display SPI bus (HSPI via displaySpi)
|
||||
// T5S3: SD shares LoRa SPI bus (SCK=14, MOSI=13, MISO=21)
|
||||
// Heltec V4 and others without SD: config lives in SPIFFS (already init'd)
|
||||
// ---------------------------------------------------------------------------
|
||||
#if (defined(HAS_4G_MODEM) || defined(MECK_WIFI_REMOTE)) && (defined(HAS_SDCARD) || defined(SDCARD_CS))
|
||||
{
|
||||
// Deselect all SPI devices before SD init to prevent bus contention
|
||||
#ifdef SDCARD_CS
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
#endif
|
||||
#ifdef PIN_DISPLAY_CS
|
||||
pinMode(PIN_DISPLAY_CS, OUTPUT);
|
||||
digitalWrite(PIN_DISPLAY_CS, HIGH);
|
||||
#endif
|
||||
#ifdef P_LORA_NSS
|
||||
pinMode(P_LORA_NSS, OUTPUT);
|
||||
digitalWrite(P_LORA_NSS, HIGH);
|
||||
#endif
|
||||
delay(100);
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3: SD shares LoRa SPI bus — create local HSPI reference
|
||||
static SPIClass sdSpi(HSPI);
|
||||
sdSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, SDCARD_CS);
|
||||
if (SD.begin(SDCARD_CS, sdSpi, 4000000)) { sdCardReady = true; break; }
|
||||
#elif defined(SDCARD_CS)
|
||||
extern SPIClass displaySpi;
|
||||
if (SD.begin(SDCARD_CS, displaySpi)) { sdCardReady = true; break; }
|
||||
#else
|
||||
if (SD.begin(SPI_CS)) { sdCardReady = true; break; }
|
||||
#endif
|
||||
delay(200);
|
||||
}
|
||||
Serial.printf("SD card: %s\n", sdCardReady ? "ready" : "FAILED");
|
||||
}
|
||||
#endif
|
||||
|
||||
// Start MQTT backhaul
|
||||
#ifdef HAS_4G_MODEM
|
||||
if (sdCardReady) {
|
||||
cellularMQTT.begin();
|
||||
Serial.println("Cellular MQTT starting...");
|
||||
} else {
|
||||
Serial.println("Cellular MQTT skipped — no SD card for config");
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WIFI_REMOTE
|
||||
#if defined(HAS_SDCARD) || defined(SDCARD_CS)
|
||||
if (sdCardReady) {
|
||||
wifiMQTT.begin();
|
||||
Serial.println("WiFi MQTT starting...");
|
||||
} else {
|
||||
Serial.println("WiFi MQTT skipped — no SD card for config");
|
||||
}
|
||||
#else
|
||||
// No SD card slot — config lives in SPIFFS (already initialized above)
|
||||
wifiMQTT.begin();
|
||||
Serial.println("WiFi MQTT starting (SPIFFS config)...");
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
|
||||
#endif
|
||||
@@ -118,6 +200,112 @@ void loop() {
|
||||
command[0] = 0; // reset command buffer
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MQTT → CLI bridge: process incoming commands from MQTT (cellular)
|
||||
// ---------------------------------------------------------------------------
|
||||
#ifdef HAS_4G_MODEM
|
||||
{
|
||||
MQTTCommand mqttCmd;
|
||||
while (cellularMQTT.recvCommand(mqttCmd)) {
|
||||
Serial.printf("[MQTT] CLI: %s\n", mqttCmd.cmd);
|
||||
char reply[512];
|
||||
reply[0] = '\0';
|
||||
the_mesh.handleCommand((uint32_t)time(nullptr), mqttCmd.cmd, reply);
|
||||
|
||||
if (reply[0] == '\0') strcpy(reply, "OK");
|
||||
|
||||
cellularMQTT.sendResponse(cellularMQTT.getRspTopic(), reply);
|
||||
Serial.printf("[MQTT] Reply: %.80s\n", reply);
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic telemetry snapshot for cellular MQTT
|
||||
{
|
||||
static unsigned long lastTelemUpdate = 0;
|
||||
if (millis() - lastTelemUpdate > 10000) {
|
||||
NodePrefs* p = the_mesh.getNodePrefs();
|
||||
TelemetryData td;
|
||||
memset(&td, 0, sizeof(td));
|
||||
td.uptime_secs = millis() / 1000;
|
||||
td.battery_mv = board.getBattMilliVolts();
|
||||
#ifdef HAS_BQ27220
|
||||
td.battery_pct = board.getBatteryPercent();
|
||||
td.temperature = board.getBattTemperature();
|
||||
#else
|
||||
td.battery_pct = 0;
|
||||
td.temperature = 0;
|
||||
#endif
|
||||
td.csq = cellularMQTT.getCSQ();
|
||||
td.freq = p->freq;
|
||||
td.bw = p->bw;
|
||||
td.sf = p->sf;
|
||||
td.cr = p->cr;
|
||||
td.tx_power = p->tx_power_dbm;
|
||||
strncpy(td.node_name, p->node_name, sizeof(td.node_name) - 1);
|
||||
strncpy(td.apn, cellularMQTT.getAPN(), sizeof(td.apn) - 1);
|
||||
strncpy(td.oper, cellularMQTT.getOperator(), sizeof(td.oper) - 1);
|
||||
td.mqtt_connected = cellularMQTT.isConnected();
|
||||
td.neighbor_count = 0; // TODO: expose from MyMesh
|
||||
|
||||
cellularMQTT.updateTelemetry(td);
|
||||
lastTelemUpdate = millis();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MQTT → CLI bridge: process incoming commands from MQTT (WiFi)
|
||||
// ---------------------------------------------------------------------------
|
||||
#ifdef MECK_WIFI_REMOTE
|
||||
wifiMQTT.loop();
|
||||
|
||||
{
|
||||
MQTTCommand mqttCmd;
|
||||
while (wifiMQTT.recvCommand(mqttCmd)) {
|
||||
Serial.printf("[MQTT] CLI: %s\n", mqttCmd.cmd);
|
||||
char reply[512];
|
||||
reply[0] = '\0';
|
||||
the_mesh.handleCommand((uint32_t)time(nullptr), mqttCmd.cmd, reply);
|
||||
|
||||
if (reply[0] == '\0') strcpy(reply, "OK");
|
||||
|
||||
wifiMQTT.sendResponse(wifiMQTT.getRspTopic(), reply);
|
||||
Serial.printf("[MQTT] Reply: %.80s\n", reply);
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic telemetry snapshot for WiFi MQTT
|
||||
{
|
||||
static unsigned long lastTelemUpdate = 0;
|
||||
if (millis() - lastTelemUpdate > 10000) {
|
||||
NodePrefs* p = the_mesh.getNodePrefs();
|
||||
TelemetryData td;
|
||||
memset(&td, 0, sizeof(td));
|
||||
td.uptime_secs = millis() / 1000;
|
||||
td.battery_mv = board.getBattMilliVolts();
|
||||
#ifdef HAS_BQ27220
|
||||
td.battery_pct = board.getBatteryPercent();
|
||||
td.temperature = board.getBattTemperature();
|
||||
#else
|
||||
td.battery_pct = 0;
|
||||
td.temperature = 0;
|
||||
#endif
|
||||
td.rssi = wifiMQTT.getRSSI();
|
||||
td.freq = p->freq;
|
||||
td.bw = p->bw;
|
||||
td.sf = p->sf;
|
||||
td.cr = p->cr;
|
||||
td.tx_power = p->tx_power_dbm;
|
||||
strncpy(td.node_name, p->node_name, sizeof(td.node_name) - 1);
|
||||
td.mqtt_connected = wifiMQTT.isConnected();
|
||||
td.neighbor_count = 0;
|
||||
|
||||
wifiMQTT.updateTelemetry(td);
|
||||
lastTelemUpdate = millis();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
the_mesh.loop();
|
||||
sensors.loop();
|
||||
#ifdef DISPLAY_CLASS
|
||||
@@ -125,14 +313,16 @@ void loop() {
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
|
||||
if (the_mesh.getNodePrefs()->powersaving_enabled && // To check if power saving is enabled
|
||||
the_mesh.millisHasNowPassed(lastActive + nextSleepinSecs * 1000)) { // To check if it is time to sleep
|
||||
if (!the_mesh.hasPendingWork()) { // No pending work. Safe to sleep
|
||||
board.sleep(1800); // To sleep. Wake up after 30 minutes or when receiving a LoRa packet
|
||||
#if !defined(HAS_4G_MODEM) && !defined(MECK_WIFI_REMOTE)
|
||||
if (the_mesh.getNodePrefs()->powersaving_enabled &&
|
||||
the_mesh.millisHasNowPassed(lastActive + nextSleepinSecs * 1000)) {
|
||||
if (!the_mesh.hasPendingWork()) {
|
||||
board.sleep(1800);
|
||||
lastActive = millis();
|
||||
nextSleepinSecs = 5; // Default: To work for 5s and sleep again
|
||||
nextSleepinSecs = 5;
|
||||
} else {
|
||||
nextSleepinSecs += 5; // When there is pending work, to work another 5s
|
||||
nextSleepinSecs += 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
#ifdef MECK_WIFI_REMOTE
|
||||
|
||||
#include "WiFiMQTT.h"
|
||||
#include <esp_mac.h>
|
||||
#include <Update.h>
|
||||
#include <HTTPClient.h>
|
||||
#include "target.h"
|
||||
|
||||
WiFiMQTT wifiMQTT;
|
||||
|
||||
#define WIFI_CONFIG_FILE "/remote/wifi.cfg"
|
||||
#define MQTT_CONFIG_FILE "/remote/mqtt.cfg"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void WiFiMQTT::begin() {
|
||||
Serial.println("[WiFi] begin()");
|
||||
|
||||
_state = WiFiMQTTState::OFF;
|
||||
_cmdHead = _cmdTail = 0;
|
||||
_rspHead = _rspTail = 0;
|
||||
_activeNetwork = 0;
|
||||
|
||||
if (!loadConfig(_config)) {
|
||||
Serial.println("[WiFi] ERROR: Missing config files — cannot start");
|
||||
_state = WiFiMQTTState::ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[WiFi] Config: %d network(s), broker=%s:%d id=%s\n",
|
||||
_config.networkCount, _config.broker, _config.port, _config.deviceId);
|
||||
for (int i = 0; i < _config.networkCount; i++) {
|
||||
Serial.printf("[WiFi] %d: %s\n", i + 1, _config.networks[i].ssid);
|
||||
}
|
||||
|
||||
snprintf(_topicCmd, sizeof(_topicCmd), "meck/%s/cmd", _config.deviceId);
|
||||
snprintf(_topicRsp, sizeof(_topicRsp), "meck/%s/rsp", _config.deviceId);
|
||||
snprintf(_topicTelem, sizeof(_topicTelem), "meck/%s/telemetry", _config.deviceId);
|
||||
snprintf(_topicOta, sizeof(_topicOta), "meck/%s/ota", _config.deviceId);
|
||||
|
||||
// Configure TLS — skip server cert verification (same as cellular)
|
||||
_wifiClient.setInsecure();
|
||||
|
||||
_mqttClient.setClient(_wifiClient);
|
||||
_mqttClient.setServer(_config.broker, _config.port);
|
||||
_mqttClient.setCallback(mqttCallback);
|
||||
_mqttClient.setBufferSize(MQTT_PAYLOAD_MAX + MQTT_TOPIC_MAX);
|
||||
|
||||
_state = WiFiMQTTState::WIFI_CONNECTING;
|
||||
}
|
||||
|
||||
void WiFiMQTT::loop() {
|
||||
if (_state == WiFiMQTTState::OFF || _state == WiFiMQTTState::ERROR) return;
|
||||
|
||||
// Check for pending OTA
|
||||
if (_otaPending && _state == WiFiMQTTState::CONNECTED) {
|
||||
performOTA();
|
||||
return;
|
||||
}
|
||||
|
||||
// WiFi connection management
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
if (_state == WiFiMQTTState::CONNECTED || _state == WiFiMQTTState::MQTT_CONNECTING) {
|
||||
Serial.println("[WiFi] Connection lost");
|
||||
_state = WiFiMQTTState::WIFI_CONNECTING;
|
||||
}
|
||||
if (millis() - _lastWifiAttempt > WIFI_RECONNECT_MS) {
|
||||
connectWiFi();
|
||||
_lastWifiAttempt = millis();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// WiFi is up — check MQTT
|
||||
if (!_mqttClient.connected()) {
|
||||
if (_state == WiFiMQTTState::CONNECTED) {
|
||||
Serial.println("[WiFi] MQTT disconnected");
|
||||
}
|
||||
_state = WiFiMQTTState::MQTT_CONNECTING;
|
||||
if (millis() - _lastMqttAttempt > MQTT_RECONNECT_MS) {
|
||||
connectMQTT();
|
||||
_lastMqttAttempt = millis();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Connected — run MQTT loop
|
||||
_mqttClient.loop();
|
||||
|
||||
// Publish queued responses
|
||||
publishQueuedResponses();
|
||||
|
||||
// Periodic RSSI
|
||||
if (millis() - _lastRSSI > 30000) {
|
||||
_rssi = WiFi.RSSI();
|
||||
_lastRSSI = millis();
|
||||
}
|
||||
|
||||
// Periodic telemetry
|
||||
if (millis() - _lastTelem > TELEMETRY_INTERVAL) {
|
||||
publishTelemetry();
|
||||
_lastTelem = millis();
|
||||
}
|
||||
}
|
||||
|
||||
bool WiFiMQTT::recvCommand(MQTTCommand& out) {
|
||||
if (_cmdHead == _cmdTail) return false;
|
||||
memcpy(&out, &_cmdBuf[_cmdTail], sizeof(MQTTCommand));
|
||||
_cmdTail = (_cmdTail + 1) % CMD_QUEUE_SIZE;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WiFiMQTT::sendResponse(const char* topic, const char* payload) {
|
||||
int next = (_rspHead + 1) % RSP_QUEUE_SIZE;
|
||||
if (next == _rspTail) return false; // Full
|
||||
memset(&_rspBuf[_rspHead], 0, sizeof(MQTTResponse));
|
||||
strncpy(_rspBuf[_rspHead].topic, topic, MQTT_TOPIC_MAX - 1);
|
||||
strncpy(_rspBuf[_rspHead].payload, payload, MQTT_PAYLOAD_MAX - 1);
|
||||
_rspHead = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WiFiMQTT::updateTelemetry(const TelemetryData& data) {
|
||||
memcpy(&_telemetry, &data, sizeof(data));
|
||||
}
|
||||
|
||||
void WiFiMQTT::requestOTA(const char* url) {
|
||||
if (_state == WiFiMQTTState::OTA_IN_PROGRESS) return;
|
||||
strncpy(_otaUrl, url, sizeof(_otaUrl) - 1);
|
||||
_otaUrl[sizeof(_otaUrl) - 1] = '\0';
|
||||
_otaPending = true;
|
||||
Serial.printf("[OTA] Requested: %s\n", url);
|
||||
}
|
||||
|
||||
int WiFiMQTT::getSignalBars() const {
|
||||
if (_rssi == 0) return 0;
|
||||
if (_rssi > -50) return 5;
|
||||
if (_rssi > -60) return 4;
|
||||
if (_rssi > -70) return 3;
|
||||
if (_rssi > -80) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char* WiFiMQTT::stateString() const {
|
||||
switch (_state) {
|
||||
case WiFiMQTTState::OFF: return "OFF";
|
||||
case WiFiMQTTState::WIFI_CONNECTING: return "WiFi...";
|
||||
case WiFiMQTTState::WIFI_CONNECTED: return "WiFi OK";
|
||||
case WiFiMQTTState::MQTT_CONNECTING: return "MQTT...";
|
||||
case WiFiMQTTState::CONNECTED: return "CONNECTED";
|
||||
case WiFiMQTTState::OTA_IN_PROGRESS: return "OTA";
|
||||
case WiFiMQTTState::ERROR: return "ERROR";
|
||||
default: return "???";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config files
|
||||
//
|
||||
// /remote/wifi.cfg — SSID/password pairs, two lines each:
|
||||
// HomeNetwork
|
||||
// HomePassword
|
||||
// BackupNetwork
|
||||
// BackupPassword
|
||||
//
|
||||
// /remote/mqtt.cfg — same format as cellular variant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool WiFiMQTT::loadConfig(WiFiMQTTConfig& cfg) {
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
|
||||
// Determine filesystem: SD if available, otherwise SPIFFS
|
||||
// Heltec V4 and other headless boards have no SD slot — config lives in SPIFFS.
|
||||
// Upload config files via: pio run -t uploadfs (with data/ folder)
|
||||
#if defined(HAS_SDCARD) || defined(SDCARD_CS)
|
||||
fs::FS& configFS = SD;
|
||||
Serial.println("[WiFi] Config source: SD card");
|
||||
#else
|
||||
fs::FS& configFS = SPIFFS;
|
||||
Serial.println("[WiFi] Config source: SPIFFS");
|
||||
#endif
|
||||
|
||||
// WiFi config: read SSID/password pairs
|
||||
File wf = configFS.open(WIFI_CONFIG_FILE, FILE_READ);
|
||||
if (!wf) {
|
||||
Serial.printf("[WiFi] No %s\n", WIFI_CONFIG_FILE);
|
||||
return false;
|
||||
}
|
||||
|
||||
cfg.networkCount = 0;
|
||||
while (wf.available() && cfg.networkCount < MAX_WIFI_NETWORKS) {
|
||||
String ssid = wf.readStringUntil('\n'); ssid.trim();
|
||||
if (ssid.length() == 0) break;
|
||||
String pass = wf.readStringUntil('\n'); pass.trim();
|
||||
strncpy(cfg.networks[cfg.networkCount].ssid, ssid.c_str(), sizeof(cfg.networks[0].ssid) - 1);
|
||||
strncpy(cfg.networks[cfg.networkCount].password, pass.c_str(), sizeof(cfg.networks[0].password) - 1);
|
||||
cfg.networkCount++;
|
||||
}
|
||||
wf.close();
|
||||
|
||||
if (cfg.networkCount == 0) {
|
||||
Serial.println("[WiFi] No networks in wifi.cfg");
|
||||
return false;
|
||||
}
|
||||
|
||||
// MQTT config: /remote/mqtt.cfg (same format as cellular)
|
||||
File mf = configFS.open(MQTT_CONFIG_FILE, FILE_READ);
|
||||
if (!mf) {
|
||||
Serial.printf("[WiFi] No %s\n", MQTT_CONFIG_FILE);
|
||||
return false;
|
||||
}
|
||||
String line;
|
||||
line = mf.readStringUntil('\n'); line.trim();
|
||||
strncpy(cfg.broker, line.c_str(), sizeof(cfg.broker) - 1);
|
||||
line = mf.readStringUntil('\n'); line.trim();
|
||||
cfg.port = line.length() > 0 ? line.toInt() : 8883;
|
||||
line = mf.readStringUntil('\n'); line.trim();
|
||||
strncpy(cfg.username, line.c_str(), sizeof(cfg.username) - 1);
|
||||
line = mf.readStringUntil('\n'); line.trim();
|
||||
strncpy(cfg.password, line.c_str(), sizeof(cfg.password) - 1);
|
||||
if (mf.available()) {
|
||||
line = mf.readStringUntil('\n'); line.trim();
|
||||
if (line.length() > 0) {
|
||||
strncpy(cfg.deviceId, line.c_str(), sizeof(cfg.deviceId) - 1);
|
||||
}
|
||||
}
|
||||
mf.close();
|
||||
|
||||
// Auto-generate device ID if not provided
|
||||
if (cfg.deviceId[0] == '\0') {
|
||||
uint8_t mac[6];
|
||||
esp_efuse_mac_get_default(mac);
|
||||
snprintf(cfg.deviceId, sizeof(cfg.deviceId), "meck-%02x%02x%02x%02x",
|
||||
mac[2], mac[3], mac[4], mac[5]);
|
||||
}
|
||||
|
||||
return cfg.broker[0] != '\0';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WiFi connection — tries each configured network in order
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool WiFiMQTT::connectWiFi() {
|
||||
WiFi.mode(WIFI_STA);
|
||||
|
||||
for (int n = 0; n < _config.networkCount; n++) {
|
||||
Serial.printf("[WiFi] Trying %s (%d/%d)...\n",
|
||||
_config.networks[n].ssid, n + 1, _config.networkCount);
|
||||
WiFi.begin(_config.networks[n].ssid, _config.networks[n].password);
|
||||
|
||||
unsigned long start = millis();
|
||||
while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) {
|
||||
delay(100);
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(_ipAddr, sizeof(_ipAddr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
_rssi = WiFi.RSSI();
|
||||
_activeNetwork = n;
|
||||
Serial.printf("[WiFi] Connected to %s — IP: %s RSSI: %d\n",
|
||||
_config.networks[n].ssid, _ipAddr, _rssi);
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(_ipAddr, sizeof(_ipAddr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
_rssi = WiFi.RSSI();
|
||||
_activeNetwork = n;
|
||||
Serial.printf("[WiFi] Connected to %s — IP: %s RSSI: %d\n",
|
||||
_config.networks[n].ssid, _ipAddr, _rssi);
|
||||
|
||||
// Sync clock via NTP
|
||||
configTime(0, 0, "pool.ntp.org", "time.google.com");
|
||||
Serial.print("[WiFi] NTP sync...");
|
||||
int tries = 0;
|
||||
while (time(nullptr) < 1700000000 && tries < 20) {
|
||||
delay(500);
|
||||
tries++;
|
||||
}
|
||||
time_t now = time(nullptr);
|
||||
if (now > 1700000000) {
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
rtc_clock.setCurrentTime((uint32_t)now);
|
||||
Serial.printf(" OK (%lu)\n", (unsigned long)now);
|
||||
} else {
|
||||
Serial.println(" timeout");
|
||||
}
|
||||
|
||||
_state = WiFiMQTTState::WIFI_CONNECTED;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
WiFi.disconnect();
|
||||
delay(500);
|
||||
}
|
||||
|
||||
Serial.println("[WiFi] All networks failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MQTT connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool WiFiMQTT::connectMQTT() {
|
||||
Serial.printf("[WiFi] MQTT connecting to %s:%d...\n", _config.broker, _config.port);
|
||||
|
||||
char clientId[48];
|
||||
snprintf(clientId, sizeof(clientId), "%s-%lu", _config.deviceId, millis() & 0xFFFF);
|
||||
|
||||
if (_mqttClient.connect(clientId, _config.username, _config.password)) {
|
||||
Serial.println("[WiFi] MQTT connected!");
|
||||
|
||||
_mqttClient.subscribe(_topicCmd, 1);
|
||||
_mqttClient.subscribe(_topicOta, 1);
|
||||
|
||||
_state = WiFiMQTTState::CONNECTED;
|
||||
|
||||
// Publish boot event
|
||||
_mqttClient.publish(_topicTelem, "{\"event\":\"boot\",\"state\":\"connected\"}", true);
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.printf("[WiFi] MQTT connect failed, rc=%d\n", _mqttClient.state());
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MQTT message callback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void WiFiMQTT::mqttCallback(char* topic, byte* payload, unsigned int length) {
|
||||
wifiMQTT.onMessage(topic, payload, length);
|
||||
}
|
||||
|
||||
void WiFiMQTT::onMessage(char* topic, byte* payload, unsigned int length) {
|
||||
char buf[MQTT_PAYLOAD_MAX];
|
||||
int len = (length < MQTT_PAYLOAD_MAX - 1) ? length : MQTT_PAYLOAD_MAX - 1;
|
||||
memcpy(buf, payload, len);
|
||||
buf[len] = '\0';
|
||||
|
||||
Serial.printf("[WiFi] RX [%s]: %.80s\n", topic, buf);
|
||||
|
||||
if (strstr(topic, "/cmd")) {
|
||||
int next = (_cmdHead + 1) % CMD_QUEUE_SIZE;
|
||||
if (next != _cmdTail) {
|
||||
memset(&_cmdBuf[_cmdHead], 0, sizeof(MQTTCommand));
|
||||
strncpy(_cmdBuf[_cmdHead].cmd, buf, MQTT_PAYLOAD_MAX - 1);
|
||||
_cmdHead = next;
|
||||
Serial.printf("[WiFi] Queued CLI: %s\n", buf);
|
||||
} else {
|
||||
Serial.println("[WiFi] Command queue full");
|
||||
}
|
||||
} else if (strstr(topic, "/ota")) {
|
||||
requestOTA(buf);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publish helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void WiFiMQTT::publishQueuedResponses() {
|
||||
while (_rspHead != _rspTail) {
|
||||
_mqttClient.publish(_rspBuf[_rspTail].topic, _rspBuf[_rspTail].payload);
|
||||
_rspTail = (_rspTail + 1) % RSP_QUEUE_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
void WiFiMQTT::publishTelemetry() {
|
||||
_rssi = WiFi.RSSI();
|
||||
|
||||
char json[400];
|
||||
snprintf(json, sizeof(json),
|
||||
"{\"uptime\":%lu,\"batt_mv\":%d,\"batt_pct\":%d,\"temp\":%.1f,"
|
||||
"\"rssi\":%d,\"bars\":%d,\"neighbors\":%d,"
|
||||
"\"freq\":%.3f,\"bw\":%.1f,\"sf\":%d,\"cr\":%d,\"tx\":%d,"
|
||||
"\"name\":\"%s\",\"ip\":\"%s\",\"ssid\":\"%s\","
|
||||
"\"heap\":%d}",
|
||||
_telemetry.uptime_secs, _telemetry.battery_mv, _telemetry.battery_pct,
|
||||
_telemetry.temperature / 10.0f,
|
||||
_rssi, getSignalBars(), _telemetry.neighbor_count,
|
||||
_telemetry.freq, _telemetry.bw, _telemetry.sf, _telemetry.cr, _telemetry.tx_power,
|
||||
_telemetry.node_name, _ipAddr, _config.networks[_activeNetwork].ssid,
|
||||
ESP.getFreeHeap());
|
||||
|
||||
_mqttClient.publish(_topicTelem, json);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OTA — HTTP download over WiFi + ESP32 flash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void WiFiMQTT::performOTA() {
|
||||
_otaPending = false;
|
||||
_state = WiFiMQTTState::OTA_IN_PROGRESS;
|
||||
|
||||
Serial.printf("[OTA] URL: %s\n", _otaUrl);
|
||||
|
||||
_mqttClient.publish(_topicRsp, "OTA: Starting download...");
|
||||
_mqttClient.loop();
|
||||
|
||||
HTTPClient http;
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.setTimeout(180000);
|
||||
|
||||
if (!http.begin(_wifiClient, _otaUrl)) {
|
||||
Serial.println("[OTA] HTTP begin failed");
|
||||
_mqttClient.publish(_topicRsp, "OTA: HTTP begin failed");
|
||||
_state = WiFiMQTTState::CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
int httpCode = http.GET();
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
Serial.printf("[OTA] HTTP error: %d\n", httpCode);
|
||||
char msg[60];
|
||||
snprintf(msg, sizeof(msg), "OTA: HTTP error %d", httpCode);
|
||||
_mqttClient.publish(_topicRsp, msg);
|
||||
http.end();
|
||||
_state = WiFiMQTTState::CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
int fileSize = http.getSize();
|
||||
if (fileSize <= 0) {
|
||||
Serial.println("[OTA] Unknown content length");
|
||||
_mqttClient.publish(_topicRsp, "OTA: Unknown file size");
|
||||
http.end();
|
||||
_state = WiFiMQTTState::CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[OTA] File size: %d bytes\n", fileSize);
|
||||
|
||||
if (!Update.begin(fileSize)) {
|
||||
Serial.printf("[OTA] Update.begin failed: %s\n", Update.errorString());
|
||||
_mqttClient.publish(_topicRsp, "OTA: Flash init failed");
|
||||
http.end();
|
||||
_state = WiFiMQTTState::CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
WiFiClient* stream = http.getStreamPtr();
|
||||
uint8_t buf[1024];
|
||||
int offset = 0;
|
||||
int lastPct = -1;
|
||||
|
||||
while (offset < fileSize) {
|
||||
int avail = stream->available();
|
||||
if (avail <= 0) {
|
||||
if (!stream->connected()) break;
|
||||
delay(10);
|
||||
continue;
|
||||
}
|
||||
|
||||
int toRead = (avail < (int)sizeof(buf)) ? avail : sizeof(buf);
|
||||
int got = stream->readBytes(buf, toRead);
|
||||
if (got <= 0) break;
|
||||
|
||||
size_t written = Update.write(buf, got);
|
||||
if (written != (size_t)got) {
|
||||
Serial.printf("[OTA] Write failed: %d of %d\n", written, got);
|
||||
break;
|
||||
}
|
||||
|
||||
offset += got;
|
||||
|
||||
int pct = (offset * 100) / fileSize;
|
||||
if (pct / 10 != lastPct / 10) {
|
||||
Serial.printf("[OTA] Progress: %d%% (%d/%d)\n", pct, offset, fileSize);
|
||||
char msg[60];
|
||||
snprintf(msg, sizeof(msg), "OTA: Flashing %d%%", pct);
|
||||
_mqttClient.publish(_topicRsp, msg);
|
||||
_mqttClient.loop();
|
||||
lastPct = pct;
|
||||
}
|
||||
|
||||
delay(1);
|
||||
}
|
||||
|
||||
http.end();
|
||||
|
||||
if (offset < fileSize) {
|
||||
Serial.printf("[OTA] Incomplete: %d of %d\n", offset, fileSize);
|
||||
Update.abort();
|
||||
_mqttClient.publish(_topicRsp, "OTA: Download incomplete");
|
||||
_state = WiFiMQTTState::CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Update.end(true)) {
|
||||
Serial.printf("[OTA] Update.end failed: %s\n", Update.errorString());
|
||||
_mqttClient.publish(_topicRsp, "OTA: Verification failed");
|
||||
_state = WiFiMQTTState::CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.println("[OTA] SUCCESS — rebooting in 3 seconds");
|
||||
_mqttClient.publish(_topicRsp, "OTA: Success! Rebooting...");
|
||||
_mqttClient.loop();
|
||||
delay(3000);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
#endif // MECK_WIFI_REMOTE
|
||||
@@ -0,0 +1,191 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// WiFiMQTT — WiFi + MQTT for audio variant remote repeater
|
||||
//
|
||||
// Same interface as CellularMQTT but uses ESP32 native WiFi + PubSubClient.
|
||||
// No modem, no AT commands, no FreeRTOS task — runs in the main loop.
|
||||
//
|
||||
// Supports multiple WiFi networks in wifi.cfg (SSID/password pairs).
|
||||
// Tries each in order on connect/reconnect.
|
||||
//
|
||||
// Guard: MECK_WIFI_REMOTE (set in platformio env build_flags)
|
||||
// =============================================================================
|
||||
|
||||
#ifdef MECK_WIFI_REMOTE
|
||||
|
||||
#ifndef WIFI_MQTT_H
|
||||
#define WIFI_MQTT_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <PubSubClient.h>
|
||||
#if defined(HAS_SDCARD) || defined(SDCARD_CS)
|
||||
#include <SD.h>
|
||||
#endif
|
||||
#include <SPIFFS.h>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
#define MQTT_TOPIC_MAX 80
|
||||
#define MQTT_PAYLOAD_MAX 512
|
||||
#define MQTT_CLIENT_ID_MAX 32
|
||||
|
||||
#define CMD_QUEUE_SIZE 8
|
||||
#define RSP_QUEUE_SIZE 8
|
||||
|
||||
#define MAX_WIFI_NETWORKS 4
|
||||
|
||||
#define TELEMETRY_INTERVAL 60000 // 60 seconds
|
||||
#define WIFI_RECONNECT_MS 10000 // 10 seconds between WiFi reconnect attempts
|
||||
#define MQTT_RECONNECT_MS 5000 // 5 seconds between MQTT reconnect attempts
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State machine
|
||||
// ---------------------------------------------------------------------------
|
||||
enum class WiFiMQTTState : uint8_t {
|
||||
OFF,
|
||||
WIFI_CONNECTING,
|
||||
WIFI_CONNECTED,
|
||||
MQTT_CONNECTING,
|
||||
CONNECTED,
|
||||
OTA_IN_PROGRESS,
|
||||
ERROR
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue message types (same as CellularMQTT for compatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MQTTCommand {
|
||||
char cmd[MQTT_PAYLOAD_MAX];
|
||||
};
|
||||
|
||||
struct MQTTResponse {
|
||||
char topic[MQTT_TOPIC_MAX];
|
||||
char payload[MQTT_PAYLOAD_MAX];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config (loaded from SD)
|
||||
// ---------------------------------------------------------------------------
|
||||
struct WiFiNetwork {
|
||||
char ssid[40];
|
||||
char password[64];
|
||||
};
|
||||
|
||||
struct WiFiMQTTConfig {
|
||||
WiFiNetwork networks[MAX_WIFI_NETWORKS];
|
||||
int networkCount;
|
||||
char broker[80];
|
||||
uint16_t port; // 8883 for MQTT TLS
|
||||
char username[40];
|
||||
char password[40];
|
||||
char deviceId[MQTT_CLIENT_ID_MAX];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telemetry snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
struct TelemetryData {
|
||||
uint32_t uptime_secs;
|
||||
uint16_t battery_mv;
|
||||
uint8_t battery_pct;
|
||||
int16_t temperature;
|
||||
int rssi;
|
||||
uint8_t neighbor_count;
|
||||
float freq;
|
||||
float bw;
|
||||
uint8_t sf;
|
||||
uint8_t cr;
|
||||
uint8_t tx_power;
|
||||
char node_name[32];
|
||||
bool mqtt_connected;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WiFiMQTT class
|
||||
// ---------------------------------------------------------------------------
|
||||
class WiFiMQTT {
|
||||
public:
|
||||
void begin();
|
||||
void loop(); // Call from main loop — handles WiFi, MQTT, publish/subscribe
|
||||
|
||||
// --- Queue API (called from main loop) ---
|
||||
bool recvCommand(MQTTCommand& out);
|
||||
bool sendResponse(const char* topic, const char* payload);
|
||||
|
||||
// --- Telemetry ---
|
||||
void updateTelemetry(const TelemetryData& data);
|
||||
|
||||
// --- OTA ---
|
||||
void requestOTA(const char* url);
|
||||
bool isOTAInProgress() const { return _state == WiFiMQTTState::OTA_IN_PROGRESS; }
|
||||
|
||||
// --- State queries ---
|
||||
WiFiMQTTState getState() const { return _state; }
|
||||
bool isConnected() const { return _state == WiFiMQTTState::CONNECTED; }
|
||||
int getRSSI() const { return _rssi; }
|
||||
int getSignalBars() const;
|
||||
const char* getSSID() const { return _config.networks[_activeNetwork].ssid; }
|
||||
const char* getIPAddress() const { return _ipAddr; }
|
||||
const char* getBroker() const { return _config.broker; }
|
||||
const char* getRspTopic() const { return _topicRsp; }
|
||||
const char* stateString() const;
|
||||
|
||||
static bool loadConfig(WiFiMQTTConfig& cfg);
|
||||
|
||||
private:
|
||||
WiFiMQTTState _state = WiFiMQTTState::OFF;
|
||||
int _rssi = 0;
|
||||
int _activeNetwork = 0;
|
||||
|
||||
char _ipAddr[20] = {0};
|
||||
WiFiMQTTConfig _config = {};
|
||||
TelemetryData _telemetry = {};
|
||||
|
||||
// Topic strings
|
||||
char _topicCmd[MQTT_TOPIC_MAX] = {0};
|
||||
char _topicRsp[MQTT_TOPIC_MAX] = {0};
|
||||
char _topicTelem[MQTT_TOPIC_MAX] = {0};
|
||||
char _topicOta[MQTT_TOPIC_MAX] = {0};
|
||||
|
||||
// Command/response ring buffers (no FreeRTOS queues needed — single-threaded)
|
||||
MQTTCommand _cmdBuf[CMD_QUEUE_SIZE];
|
||||
int _cmdHead = 0, _cmdTail = 0;
|
||||
|
||||
MQTTResponse _rspBuf[RSP_QUEUE_SIZE];
|
||||
int _rspHead = 0, _rspTail = 0;
|
||||
|
||||
// MQTT client stack
|
||||
WiFiClientSecure _wifiClient;
|
||||
PubSubClient _mqttClient;
|
||||
|
||||
// Timers
|
||||
unsigned long _lastWifiAttempt = 0;
|
||||
unsigned long _lastMqttAttempt = 0;
|
||||
unsigned long _lastTelem = 0;
|
||||
unsigned long _lastRSSI = 0;
|
||||
|
||||
// OTA state
|
||||
bool _otaPending = false;
|
||||
char _otaUrl[256] = {0};
|
||||
|
||||
// --- Internal ---
|
||||
bool connectWiFi();
|
||||
bool connectMQTT();
|
||||
void publishTelemetry();
|
||||
void publishQueuedResponses();
|
||||
void performOTA();
|
||||
|
||||
// PubSubClient callback (static → instance)
|
||||
static void mqttCallback(char* topic, byte* payload, unsigned int length);
|
||||
void onMessage(char* topic, byte* payload, unsigned int length);
|
||||
};
|
||||
|
||||
extern WiFiMQTT wifiMQTT;
|
||||
|
||||
#endif // WIFI_MQTT_H
|
||||
#endif // MECK_WIFI_REMOTE
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
PlatformIO post-build script: merge bootloader + partitions + firmware + SPIFFS
|
||||
into a single flashable binary.
|
||||
|
||||
Includes a pre-formatted empty SPIFFS image so first-boot doesn't need to
|
||||
format the partition (which takes 1-2 minutes on 16MB flash).
|
||||
|
||||
Output: .pio/build/<env>/firmware_merged.bin
|
||||
Flash: esptool.py --chip esp32s3 write_flash 0x0 firmware_merged.bin
|
||||
|
||||
Place this file in the project root alongside platformio.ini.
|
||||
Add to each environment (or the base section):
|
||||
extra_scripts = post:merge_firmware.py
|
||||
"""
|
||||
|
||||
Import("env")
|
||||
|
||||
def find_spiffs_partition(partitions_bin):
|
||||
"""Parse compiled partitions.bin to find SPIFFS partition offset and size.
|
||||
|
||||
ESP32 partition entry format (32 bytes each):
|
||||
0xAA50 magic, type, subtype, offset(u32le), size(u32le), label(16), flags(u32le)
|
||||
SPIFFS: type=0x01(data), subtype=0x82(spiffs)
|
||||
"""
|
||||
import struct
|
||||
|
||||
with open(partitions_bin, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
for i in range(0, len(data) - 32, 32):
|
||||
magic = struct.unpack_from("<H", data, i)[0]
|
||||
if magic != 0xAA50:
|
||||
continue
|
||||
ptype = data[i + 2]
|
||||
subtype = data[i + 3]
|
||||
offset = struct.unpack_from("<I", data, i + 4)[0]
|
||||
size = struct.unpack_from("<I", data, i + 8)[0]
|
||||
label = data[i + 12:i + 28].split(b'\x00')[0].decode("ascii", errors="ignore")
|
||||
if ptype == 0x01 and subtype == 0x82: # data/spiffs
|
||||
return offset, size, label
|
||||
return None, None, None
|
||||
|
||||
|
||||
def build_spiffs_image(env, size):
|
||||
"""Generate an empty formatted SPIFFS image using mkspiffs."""
|
||||
import subprocess, os, tempfile, glob
|
||||
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
spiffs_bin = os.path.join(build_dir, "spiffs_empty.bin")
|
||||
|
||||
# If already generated for this build, reuse it
|
||||
if os.path.isfile(spiffs_bin) and os.path.getsize(spiffs_bin) == size:
|
||||
return spiffs_bin
|
||||
|
||||
# Find mkspiffs in PlatformIO packages
|
||||
pio_home = os.path.expanduser("~/.platformio")
|
||||
mkspiffs_paths = glob.glob(os.path.join(pio_home, "packages", "tool-mkspiffs*", "mkspiffs*"))
|
||||
if not mkspiffs_paths:
|
||||
# Also check platform-specific tool paths
|
||||
mkspiffs_paths = glob.glob(os.path.join(pio_home, "packages", "tool-mklittlefs*", "mkspiffs*"))
|
||||
|
||||
mkspiffs = None
|
||||
for p in mkspiffs_paths:
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
mkspiffs = p
|
||||
break
|
||||
|
||||
if not mkspiffs:
|
||||
print("[merge] WARNING: mkspiffs not found, skipping SPIFFS image")
|
||||
return None
|
||||
|
||||
# Create empty data directory for mkspiffs
|
||||
data_dir = os.path.join(build_dir, "_empty_spiffs_data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
# SPIFFS block/page sizes — ESP32 Arduino defaults
|
||||
block_size = 4096
|
||||
page_size = 256
|
||||
|
||||
cmd = [
|
||||
mkspiffs,
|
||||
"-c", data_dir,
|
||||
"-b", str(block_size),
|
||||
"-p", str(page_size),
|
||||
"-s", str(size),
|
||||
spiffs_bin,
|
||||
]
|
||||
|
||||
print(f"[merge] Generating empty SPIFFS image ({size // 1024} KB)...")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0 and os.path.isfile(spiffs_bin):
|
||||
print(f"[merge] SPIFFS image OK: {spiffs_bin}")
|
||||
return spiffs_bin
|
||||
else:
|
||||
print(f"[merge] mkspiffs failed: {result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def merge_bin(source, target, env):
|
||||
import subprocess, os
|
||||
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
env_name = env.subst("$PIOENV")
|
||||
|
||||
bootloader = os.path.join(build_dir, "bootloader.bin")
|
||||
partitions = os.path.join(build_dir, "partitions.bin")
|
||||
firmware = os.path.join(build_dir, "firmware.bin")
|
||||
output = os.path.join(build_dir, "firmware-merged.bin")
|
||||
|
||||
# Verify all inputs exist
|
||||
for f in [bootloader, partitions, firmware]:
|
||||
if not os.path.isfile(f):
|
||||
print(f"[merge] WARNING: {f} not found, skipping merge")
|
||||
return
|
||||
|
||||
# Read flash settings from board config
|
||||
flash_mode = env.BoardConfig().get("build.flash_mode", "qio")
|
||||
flash_freq = env.BoardConfig().get("build.f_flash", "80000000L").rstrip("L")
|
||||
flash_size = env.BoardConfig().get("upload.flash_size", "16MB")
|
||||
mcu = env.BoardConfig().get("build.mcu", "esp32s3")
|
||||
|
||||
# Convert numeric frequency to esptool format
|
||||
freq_map = {"80000000": "80m", "40000000": "40m", "26000000": "26m", "20000000": "20m"}
|
||||
flash_freq_str = freq_map.get(flash_freq, "80m")
|
||||
|
||||
cmd = [
|
||||
env.subst("$PYTHONEXE"), "-m", "esptool",
|
||||
"--chip", mcu,
|
||||
"merge_bin",
|
||||
"-o", output,
|
||||
"--flash_mode", flash_mode,
|
||||
"--flash_freq", flash_freq_str,
|
||||
"--flash_size", flash_size,
|
||||
"0x0", bootloader,
|
||||
"0x8000", partitions,
|
||||
"0x10000", firmware,
|
||||
]
|
||||
|
||||
# Try to include a pre-formatted SPIFFS image (eliminates 1-2 min first-boot format)
|
||||
spiffs_offset, spiffs_size, spiffs_label = find_spiffs_partition(partitions)
|
||||
if spiffs_offset and spiffs_size:
|
||||
spiffs_bin = build_spiffs_image(env, spiffs_size)
|
||||
if spiffs_bin:
|
||||
cmd.extend([f"0x{spiffs_offset:x}", spiffs_bin])
|
||||
print(f"[merge] Including SPIFFS image at 0x{spiffs_offset:x} ({spiffs_size // 1024} KB)")
|
||||
else:
|
||||
print("[merge] No SPIFFS partition found in partition table, skipping SPIFFS image")
|
||||
|
||||
print(f"\n[merge] Creating merged firmware for {env_name}...")
|
||||
print(f"[merge] {' '.join(cmd[-8:])}")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
size_kb = os.path.getsize(output) / 1024
|
||||
print(f"[merge] OK: {output} ({size_kb:.0f} KB)")
|
||||
else:
|
||||
print(f"[merge] FAILED: {result.stderr}")
|
||||
|
||||
env.AddPostAction("$BUILD_DIR/firmware.bin", merge_bin)
|
||||
+1
-1
@@ -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
|
||||
|
||||
Binary file not shown.
+39
-4
@@ -36,7 +36,7 @@ uint32_t Dispatcher::getCADFailRetryDelay() const {
|
||||
return 200;
|
||||
}
|
||||
uint32_t Dispatcher::getCADFailMaxDuration() const {
|
||||
return 4000; // 4 seconds
|
||||
return 6000; // 6 seconds
|
||||
}
|
||||
|
||||
void Dispatcher::loop() {
|
||||
@@ -52,10 +52,28 @@ void Dispatcher::loop() {
|
||||
prev_isrecv_mode = is_recv;
|
||||
if (!is_recv) {
|
||||
radio_nonrx_start = _ms->getMillis();
|
||||
} else {
|
||||
rx_stuck_count = 0; // radio recovered — reset counter
|
||||
}
|
||||
}
|
||||
if (!is_recv && _ms->getMillis() - radio_nonrx_start > 8000) { // radio has not been in Rx mode for 8 seconds!
|
||||
_err_flags |= ERR_EVENT_STARTRX_TIMEOUT;
|
||||
|
||||
rx_stuck_count++;
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): RX stuck (attempt %d), calling onRxStuck()", getLogDateTime(), rx_stuck_count);
|
||||
onRxStuck();
|
||||
|
||||
uint8_t reboot_threshold = getRxFailRebootThreshold();
|
||||
if (reboot_threshold > 0 && rx_stuck_count >= reboot_threshold) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): RX unrecoverable after %d attempts", getLogDateTime(), rx_stuck_count);
|
||||
onRxUnrecoverable();
|
||||
}
|
||||
|
||||
// Reset state to give recovery the full 8s window before re-triggering
|
||||
radio_nonrx_start = _ms->getMillis();
|
||||
prev_isrecv_mode = true;
|
||||
cad_busy_start = 0;
|
||||
next_agc_reset_time = futureMillis(getAGCResetInterval());
|
||||
}
|
||||
|
||||
if (outbound) { // waiting for outbound send to be completed
|
||||
@@ -273,14 +291,31 @@ void Dispatcher::checkSend() {
|
||||
outbound_start = _ms->getMillis();
|
||||
bool success = _radio->startSendRaw(raw, len);
|
||||
if (!success) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): ERROR: send start failed!", getLogDateTime());
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): ERROR: send start failed!", getLogDateTime());
|
||||
|
||||
logTxFail(outbound, outbound->getRawLength());
|
||||
|
||||
releasePacket(outbound); // return to pool
|
||||
|
||||
// re-queue packet for retry instead of dropping it
|
||||
int retry_delay = getCADFailRetryDelay();
|
||||
unsigned long retry_time = futureMillis(retry_delay);
|
||||
_mgr->queueOutbound(outbound, 0, retry_time);
|
||||
outbound = NULL;
|
||||
next_tx_time = retry_time;
|
||||
|
||||
// count consecutive failures and reset radio if stuck
|
||||
uint8_t threshold = getTxFailResetThreshold();
|
||||
if (threshold > 0) {
|
||||
tx_fail_count++;
|
||||
if (tx_fail_count >= threshold) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): TX stuck (%d failures), resetting radio", getLogDateTime(), tx_fail_count);
|
||||
onTxStuck();
|
||||
tx_fail_count = 0;
|
||||
next_tx_time = futureMillis(2000);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
tx_fail_count = 0; // clear counter on successful TX start
|
||||
outbound_expiry = futureMillis(max_airtime);
|
||||
|
||||
#if MESH_PACKET_LOGGING
|
||||
|
||||
+10
-1
@@ -122,6 +122,8 @@ class Dispatcher {
|
||||
bool prev_isrecv_mode;
|
||||
uint32_t n_sent_flood, n_sent_direct;
|
||||
uint32_t n_recv_flood, n_recv_direct;
|
||||
uint8_t tx_fail_count;
|
||||
uint8_t rx_stuck_count;
|
||||
|
||||
void processRecvPacket(Packet* pkt);
|
||||
|
||||
@@ -142,6 +144,8 @@ protected:
|
||||
_err_flags = 0;
|
||||
radio_nonrx_start = 0;
|
||||
prev_isrecv_mode = true;
|
||||
tx_fail_count = 0;
|
||||
rx_stuck_count = 0;
|
||||
}
|
||||
|
||||
virtual DispatcherAction onRecvPacket(Packet* pkt) = 0;
|
||||
@@ -159,6 +163,11 @@ protected:
|
||||
virtual uint32_t getCADFailMaxDuration() const;
|
||||
virtual int getInterferenceThreshold() const { return 0; } // disabled by default
|
||||
virtual int getAGCResetInterval() const { return 0; } // disabled by default
|
||||
virtual uint8_t getTxFailResetThreshold() const { return 3; } // reset radio after N consecutive TX failures; 0=disabled
|
||||
virtual void onTxStuck() { _radio->resetAGC(); } // override to use doFullRadioReset() when available
|
||||
virtual uint8_t getRxFailRebootThreshold() const { return 3; } // reboot after N failed RX recovery attempts; 0=disabled
|
||||
virtual void onRxStuck() { _radio->resetAGC(); } // called each time RX stuck for 8s; override for deeper reset
|
||||
virtual void onRxUnrecoverable() { } // called when reboot threshold exceeded; override to call _board->reboot()
|
||||
|
||||
public:
|
||||
void begin();
|
||||
@@ -188,4 +197,4 @@ private:
|
||||
void checkSend();
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,10 @@
|
||||
#endif
|
||||
|
||||
void BaseChatMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
sendFlood(pkt, delay_millis);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
}
|
||||
void BaseChatMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
sendFlood(pkt, delay_millis);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
}
|
||||
|
||||
mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) {
|
||||
@@ -56,6 +56,14 @@ void BaseChatMesh::sendAckTo(const ContactInfo& dest, uint32_t ack_hash) {
|
||||
}
|
||||
|
||||
void BaseChatMesh::bootstrapRTCfromContacts() {
|
||||
// If the RTC already has a sane time (e.g. hardware RTC like PCF8563, or
|
||||
// GPS-synced), don't overwrite it with a potentially stale contact lastmod.
|
||||
// This bootstrap is only useful for boards with no hardware RTC at all.
|
||||
uint32_t current = getRTCClock()->getCurrentTime();
|
||||
if (current > 1704067200UL) { // Jan 1 2024 — matches EPOCH_MIN_SANE
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t latest = 0;
|
||||
for (int i = 0; i < num_contacts; i++) {
|
||||
if (contacts[i].lastmod > latest) {
|
||||
|
||||
@@ -130,6 +130,7 @@ protected:
|
||||
virtual void onContactResponse(const ContactInfo& contact, const uint8_t* data, uint8_t len) = 0;
|
||||
virtual void handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len);
|
||||
|
||||
virtual uint8_t getPathHashSize() const = 0;
|
||||
virtual void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0);
|
||||
virtual void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0);
|
||||
|
||||
|
||||
@@ -81,7 +81,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
|
||||
file.read((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162
|
||||
file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166
|
||||
file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170
|
||||
// 290
|
||||
file.read((uint8_t *)&_prefs->path_hash_mode, sizeof(_prefs->path_hash_mode)); // 290
|
||||
// 291
|
||||
|
||||
// sanitise bad pref values
|
||||
_prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f);
|
||||
@@ -107,6 +108,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
|
||||
|
||||
_prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1);
|
||||
_prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2);
|
||||
_prefs->path_hash_mode = constrain(_prefs->path_hash_mode, 0, 2);
|
||||
|
||||
file.close();
|
||||
}
|
||||
@@ -165,7 +167,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
|
||||
file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162
|
||||
file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166
|
||||
file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170
|
||||
// 290
|
||||
file.write((uint8_t *)&_prefs->path_hash_mode, sizeof(_prefs->path_hash_mode)); // 290
|
||||
// 291
|
||||
|
||||
file.close();
|
||||
}
|
||||
@@ -285,7 +288,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
|
||||
sprintf(reply, "> %d", ((uint32_t) _prefs->advert_interval) * 2);
|
||||
} else if (memcmp(config, "guest.password", 14) == 0) {
|
||||
sprintf(reply, "> %s", _prefs->guest_password);
|
||||
} else if (sender_timestamp == 0 && memcmp(config, "prv.key", 7) == 0) { // from serial command line only
|
||||
} else if (memcmp(config, "prv.key", 7) == 0) { // from serial command line only
|
||||
uint8_t prv_key[PRV_KEY_SIZE];
|
||||
int len = _callbacks->getSelfId().writeTo(prv_key, PRV_KEY_SIZE);
|
||||
mesh::Utils::toHex(tmp, prv_key, len);
|
||||
@@ -545,7 +548,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
|
||||
savePrefs();
|
||||
_callbacks->setTxPower(_prefs->tx_power_dbm);
|
||||
strcpy(reply, "OK");
|
||||
} else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) {
|
||||
} else if (memcmp(config, "freq ", 5) == 0) {
|
||||
_prefs->freq = atof(&config[5]);
|
||||
savePrefs();
|
||||
strcpy(reply, "OK - reboot to apply");
|
||||
@@ -767,13 +770,13 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
|
||||
} else if (sender_timestamp == 0 && memcmp(command, "log", 3) == 0) {
|
||||
_callbacks->dumpLogFile();
|
||||
strcpy(reply, " EOF");
|
||||
} else if (sender_timestamp == 0 && memcmp(command, "stats-packets", 13) == 0 && (command[13] == 0 || command[13] == ' ')) {
|
||||
} else if (memcmp(command, "stats-packets", 13) == 0 && (command[13] == 0 || command[13] == ' ')) {
|
||||
_callbacks->formatPacketStatsReply(reply);
|
||||
} else if (sender_timestamp == 0 && memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) {
|
||||
} else if (memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) {
|
||||
_callbacks->formatRadioStatsReply(reply);
|
||||
} else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) {
|
||||
} else if (memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) {
|
||||
_callbacks->formatStatsReply(reply);
|
||||
} else {
|
||||
strcpy(reply, "Unknown command");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ struct NodePrefs { // persisted to file
|
||||
uint32_t discovery_mod_timestamp;
|
||||
float adc_multiplier;
|
||||
char owner_info[120];
|
||||
// Multi-byte path hash support (added for Meck remote repeater)
|
||||
uint8_t path_hash_mode; // 0=1-byte (legacy), 1=2-byte, 2=3-byte path hashes
|
||||
};
|
||||
|
||||
class CommonCLICallbacks {
|
||||
@@ -110,4 +112,4 @@ public:
|
||||
void savePrefs(FILESYSTEM* _fs);
|
||||
void handleCommand(uint32_t sender_timestamp, const char* command, char* reply);
|
||||
uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data);
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <Wire.h>
|
||||
#include "esp_wifi.h"
|
||||
#include "driver/rtc_io.h"
|
||||
#include "driver/gpio.h"
|
||||
|
||||
class ESP32Board : public mesh::MainBoard {
|
||||
protected:
|
||||
@@ -60,13 +61,20 @@ public:
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32S3) && defined(P_LORA_DIO_1) // Supported ESP32 variants
|
||||
if (rtc_gpio_is_valid_gpio((gpio_num_t)P_LORA_DIO_1)) { // Only enter sleep mode if P_LORA_DIO_1 is RTC pin
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // To wake up when receiving a LoRa packet
|
||||
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // Wake on LoRa packet
|
||||
|
||||
// T5S3: Also wake on boot button press (GPIO0, active LOW).
|
||||
// gpio_wakeup uses level trigger — works for light sleep only.
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && defined(PIN_USER_BTN)
|
||||
gpio_wakeup_enable((gpio_num_t)PIN_USER_BTN, GPIO_INTR_LOW_LEVEL);
|
||||
esp_sleep_enable_gpio_wakeup();
|
||||
#endif
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000); // To wake up every hour to do periodically jobs
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000ULL); // Timer wake (microseconds)
|
||||
}
|
||||
|
||||
esp_light_sleep_start(); // CPU enters light sleep
|
||||
esp_light_sleep_start(); // CPU halts here, resumes on wake
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -154,4 +162,4 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "SerialBLEInterface.h"
|
||||
#include "esp_bt.h"
|
||||
#include "esp_gap_ble_api.h"
|
||||
|
||||
// See the following for generating UUIDs:
|
||||
// https://www.uuidgenerator.net/
|
||||
@@ -27,6 +29,11 @@ void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code
|
||||
BLEDevice::setSecurityCallbacks(this);
|
||||
BLEDevice::setMTU(MAX_FRAME_SIZE);
|
||||
|
||||
// Boost BLE TX power for improved range (+9 dBm, up from default +3 dBm)
|
||||
esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_DEFAULT, ESP_PWR_LVL_P9);
|
||||
esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ESP_PWR_LVL_P9);
|
||||
esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_SCAN, ESP_PWR_LVL_P9);
|
||||
|
||||
BLESecurity sec;
|
||||
sec.setStaticPIN(pin_code);
|
||||
sec.setAuthenticationMode(ESP_LE_AUTH_REQ_SC_MITM_BOND);
|
||||
@@ -77,6 +84,18 @@ void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) {
|
||||
if (cmpl.success) {
|
||||
BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Success");
|
||||
deviceConnected = true;
|
||||
|
||||
// Request fast connection interval (15ms) for faster contact sync.
|
||||
// Phone may negotiate higher, but most modern phones accept 15ms.
|
||||
// Units are 1.25ms, so 12 = 15ms, 16 = 20ms.
|
||||
esp_ble_conn_update_params_t conn_params;
|
||||
memcpy(conn_params.bda, _remote_bda, 6);
|
||||
conn_params.min_int = 12; // 15ms (12 × 1.25ms)
|
||||
conn_params.max_int = 16; // 20ms (16 × 1.25ms)
|
||||
conn_params.latency = 0; // no skipped intervals
|
||||
conn_params.timeout = 400; // 4 seconds supervision timeout
|
||||
esp_ble_gap_update_conn_params(&conn_params);
|
||||
BLE_DEBUG_PRINTLN(" - Requested fast connection interval (15-20ms)");
|
||||
} else {
|
||||
BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Failure*");
|
||||
|
||||
@@ -94,6 +113,7 @@ void SerialBLEInterface::onConnect(BLEServer* pServer) {
|
||||
void SerialBLEInterface::onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t *param) {
|
||||
BLE_DEBUG_PRINTLN("onConnect(), conn_id=%d, mtu=%d", param->connect.conn_id, pServer->getPeerMTU(param->connect.conn_id));
|
||||
last_conn_id = param->connect.conn_id;
|
||||
memcpy(_remote_bda, param->connect.remote_bda, 6);
|
||||
}
|
||||
|
||||
void SerialBLEInterface::onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) {
|
||||
@@ -185,7 +205,7 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
#define BLE_WRITE_MIN_INTERVAL 60
|
||||
#define BLE_WRITE_MIN_INTERVAL 15
|
||||
|
||||
bool SerialBLEInterface::isWriteBusy() const {
|
||||
return millis() < _last_write + BLE_WRITE_MIN_INTERVAL; // still too soon to start another write?
|
||||
|
||||
@@ -14,6 +14,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
|
||||
bool oldDeviceConnected;
|
||||
bool _isEnabled;
|
||||
uint16_t last_conn_id;
|
||||
uint8_t _remote_bda[6]; // peer BDA, stored in onConnect for conn param updates
|
||||
uint32_t _pin_code;
|
||||
unsigned long _last_write;
|
||||
unsigned long adv_restart_time;
|
||||
@@ -23,7 +24,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
|
||||
uint8_t buf[MAX_FRAME_SIZE];
|
||||
};
|
||||
|
||||
#define FRAME_QUEUE_SIZE 4
|
||||
#define FRAME_QUEUE_SIZE 16
|
||||
int recv_queue_len;
|
||||
Frame recv_queue[FRAME_QUEUE_SIZE];
|
||||
int send_queue_len;
|
||||
@@ -58,6 +59,7 @@ public:
|
||||
_isEnabled = false;
|
||||
_last_write = 0;
|
||||
last_conn_id = 0;
|
||||
memset(_remote_bda, 0, 6);
|
||||
send_queue_len = recv_queue_len = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ size_t SerialWifiInterface::writeFrame(const uint8_t src[], size_t len) {
|
||||
}
|
||||
|
||||
bool SerialWifiInterface::isWriteBusy() const {
|
||||
return false;
|
||||
return send_queue_len >= (FRAME_QUEUE_SIZE * 3 / 4); // backpressure at 75% full
|
||||
}
|
||||
|
||||
bool SerialWifiInterface::hasReceivedFrameHeader() {
|
||||
@@ -82,8 +82,8 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) {
|
||||
}
|
||||
|
||||
if (deviceConnected) {
|
||||
if (send_queue_len > 0) { // first, check send queue
|
||||
|
||||
// drain all pending send frames — WiFi TCP can handle the throughput
|
||||
while (send_queue_len > 0) {
|
||||
_last_write = millis();
|
||||
int len = send_queue[0].len;
|
||||
|
||||
@@ -97,70 +97,68 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) {
|
||||
for (int i = 0; i < send_queue_len; i++) { // delete top item from queue
|
||||
send_queue[i] = send_queue[i + 1];
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// check if we are waiting for a frame header
|
||||
if(!hasReceivedFrameHeader()){
|
||||
// check if we are waiting for a frame header
|
||||
if(!hasReceivedFrameHeader()){
|
||||
|
||||
// make sure we have received enough bytes for a frame header
|
||||
// 3 bytes frame header = (1 byte frame type) + (2 bytes frame length as unsigned 16-bit little endian)
|
||||
int frame_header_length = 3;
|
||||
if(client.available() >= frame_header_length){
|
||||
// make sure we have received enough bytes for a frame header
|
||||
// 3 bytes frame header = (1 byte frame type) + (2 bytes frame length as unsigned 16-bit little endian)
|
||||
int frame_header_length = 3;
|
||||
if(client.available() >= frame_header_length){
|
||||
|
||||
// read frame header
|
||||
client.readBytes(&received_frame_header.type, 1);
|
||||
client.readBytes((uint8_t*)&received_frame_header.length, 2);
|
||||
|
||||
}
|
||||
// read frame header
|
||||
client.readBytes(&received_frame_header.type, 1);
|
||||
client.readBytes((uint8_t*)&received_frame_header.length, 2);
|
||||
|
||||
}
|
||||
|
||||
// check if we have received a frame header
|
||||
if(hasReceivedFrameHeader()){
|
||||
}
|
||||
|
||||
// make sure we have received enough bytes for the required frame length
|
||||
int available = client.available();
|
||||
int frame_type = received_frame_header.type;
|
||||
int frame_length = received_frame_header.length;
|
||||
if(frame_length > available){
|
||||
WIFI_DEBUG_PRINTLN("Waiting for %d more bytes", frame_length - available);
|
||||
return 0;
|
||||
// check if we have received a frame header
|
||||
if(hasReceivedFrameHeader()){
|
||||
|
||||
// make sure we have received enough bytes for the required frame length
|
||||
int available = client.available();
|
||||
int frame_type = received_frame_header.type;
|
||||
int frame_length = received_frame_header.length;
|
||||
if(frame_length > available){
|
||||
WIFI_DEBUG_PRINTLN("Waiting for %d more bytes", frame_length - available);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// skip frames that are larger than MAX_FRAME_SIZE
|
||||
if(frame_length > MAX_FRAME_SIZE){
|
||||
WIFI_DEBUG_PRINTLN("Skipping frame: length=%d is larger than MAX_FRAME_SIZE=%d", frame_length, MAX_FRAME_SIZE);
|
||||
while(frame_length > 0){
|
||||
uint8_t skip[1];
|
||||
int skipped = client.read(skip, 1);
|
||||
frame_length -= skipped;
|
||||
}
|
||||
|
||||
// skip frames that are larger than MAX_FRAME_SIZE
|
||||
if(frame_length > MAX_FRAME_SIZE){
|
||||
WIFI_DEBUG_PRINTLN("Skipping frame: length=%d is larger than MAX_FRAME_SIZE=%d", frame_length, MAX_FRAME_SIZE);
|
||||
while(frame_length > 0){
|
||||
uint8_t skip[1];
|
||||
int skipped = client.read(skip, 1);
|
||||
frame_length -= skipped;
|
||||
}
|
||||
resetReceivedFrameHeader();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// skip frames that are not expected type
|
||||
// '<' is 0x3c which indicates a frame sent from app to radio
|
||||
if(frame_type != '<'){
|
||||
WIFI_DEBUG_PRINTLN("Skipping frame: type=0x%x is unexpected", frame_type);
|
||||
while(frame_length > 0){
|
||||
uint8_t skip[1];
|
||||
int skipped = client.read(skip, 1);
|
||||
frame_length -= skipped;
|
||||
}
|
||||
resetReceivedFrameHeader();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// read frame data to provided buffer
|
||||
client.readBytes(dest, frame_length);
|
||||
|
||||
// ready for next frame
|
||||
resetReceivedFrameHeader();
|
||||
return frame_length;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// skip frames that are not expected type
|
||||
// '<' is 0x3c which indicates a frame sent from app to radio
|
||||
if(frame_type != '<'){
|
||||
WIFI_DEBUG_PRINTLN("Skipping frame: type=0x%x is unexpected", frame_type);
|
||||
while(frame_length > 0){
|
||||
uint8_t skip[1];
|
||||
int skipped = client.read(skip, 1);
|
||||
frame_length -= skipped;
|
||||
}
|
||||
resetReceivedFrameHeader();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// read frame data to provided buffer
|
||||
client.readBytes(dest, frame_length);
|
||||
|
||||
// ready for next frame
|
||||
resetReceivedFrameHeader();
|
||||
return frame_length;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class SerialWifiInterface : public BaseSerialInterface {
|
||||
|
||||
FrameHeader received_frame_header;
|
||||
|
||||
#define FRAME_QUEUE_SIZE 4
|
||||
#define FRAME_QUEUE_SIZE 64
|
||||
int recv_queue_len;
|
||||
Frame recv_queue[FRAME_QUEUE_SIZE];
|
||||
int send_queue_len;
|
||||
@@ -68,4 +68,4 @@ public:
|
||||
#else
|
||||
#define WIFI_DEBUG_PRINT(...) {}
|
||||
#define WIFI_DEBUG_PRINTLN(...) {}
|
||||
#endif
|
||||
#endif
|
||||
@@ -1,350 +0,0 @@
|
||||
#if defined(TBEAM_SUPREME_SX1262) || defined(TBEAM_SX1262) || defined(TBEAM_SX1276)
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "TBeamBoard.h"
|
||||
//#include <RadioLib.h>
|
||||
|
||||
uint32_t deviceOnline = 0x00;
|
||||
|
||||
bool pmuInterrupt;
|
||||
static void setPmuFlag()
|
||||
{
|
||||
pmuInterrupt = true;
|
||||
}
|
||||
|
||||
void TBeamBoard::begin() {
|
||||
|
||||
ESP32Board::begin();
|
||||
|
||||
power_init();
|
||||
|
||||
//Configure user button
|
||||
pinMode(PIN_USER_BTN, INPUT);
|
||||
|
||||
#ifndef TBEAM_SUPREME_SX1262
|
||||
digitalWrite(P_LORA_TX_LED, HIGH); //inverted pin for SX1276 - HIGH for off
|
||||
#endif
|
||||
|
||||
//radiotype_detect();
|
||||
|
||||
esp_reset_reason_t reason = esp_reset_reason();
|
||||
if (reason == ESP_RST_DEEPSLEEP) {
|
||||
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
|
||||
if (wakeup_source & (1 << P_LORA_DIO_1)) { // received a LoRa packet (while in deep sleep)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef MESH_DEBUG
|
||||
void TBeamBoard::scanDevices(TwoWire *w)
|
||||
{
|
||||
uint8_t err, addr;
|
||||
int nDevices = 0;
|
||||
uint32_t start = 0;
|
||||
|
||||
Serial.println("Scanning I2C for Devices");
|
||||
for (addr = 1; addr < 127; addr++) {
|
||||
start = millis();
|
||||
w->beginTransmission(addr); delay(2);
|
||||
err = w->endTransmission();
|
||||
if (err == 0) {
|
||||
nDevices++;
|
||||
switch (addr) {
|
||||
case 0x77:
|
||||
case 0x76:
|
||||
Serial.println("\tFound BME280 Sensor");
|
||||
deviceOnline |= BME280_ONLINE;
|
||||
break;
|
||||
case 0x34:
|
||||
Serial.println("\tFound AXP192/AXP2101 PMU");
|
||||
deviceOnline |= POWERMANAGE_ONLINE;
|
||||
break;
|
||||
case 0x3C:
|
||||
Serial.println("\tFound SSD1306/SH1106 display");
|
||||
deviceOnline |= DISPLAY_ONLINE;
|
||||
break;
|
||||
case 0x51:
|
||||
Serial.println("\tFound PCF8563 RTC");
|
||||
deviceOnline |= PCF8563_ONLINE;
|
||||
break;
|
||||
case 0x1C:
|
||||
Serial.println("\tFound QMC6310 MAG Sensor");
|
||||
deviceOnline |= QMC6310_ONLINE;
|
||||
break;
|
||||
default:
|
||||
Serial.print("\tI2C device found at address 0x");
|
||||
if (addr < 16) {
|
||||
Serial.print("0");
|
||||
}
|
||||
Serial.print(addr, HEX);
|
||||
Serial.println(" !");
|
||||
break;
|
||||
}
|
||||
|
||||
} else if (err == 4) {
|
||||
Serial.print("Unknow error at address 0x");
|
||||
if (addr < 16) {
|
||||
Serial.print("0");
|
||||
}
|
||||
Serial.println(addr, HEX);
|
||||
}
|
||||
}
|
||||
if (nDevices == 0)
|
||||
Serial.println("No I2C devices found\n");
|
||||
|
||||
Serial.println("Scan for devices is complete.");
|
||||
Serial.println("\n");
|
||||
|
||||
Serial.printf("GPS RX pin: %d", PIN_GPS_RX);
|
||||
Serial.printf(" GPS TX pin: %d", PIN_GPS_TX);
|
||||
Serial.println();
|
||||
}
|
||||
void TBeamBoard::printPMU()
|
||||
{
|
||||
Serial.print("isCharging:"); Serial.println(PMU->isCharging() ? "YES" : "NO");
|
||||
Serial.print("isDischarge:"); Serial.println(PMU->isDischarge() ? "YES" : "NO");
|
||||
Serial.print("isVbusIn:"); Serial.println(PMU->isVbusIn() ? "YES" : "NO");
|
||||
Serial.print("getBattVoltage:"); Serial.print(PMU->getBattVoltage()); Serial.println("mV");
|
||||
Serial.print("getVbusVoltage:"); Serial.print(PMU->getVbusVoltage()); Serial.println("mV");
|
||||
Serial.print("getSystemVoltage:"); Serial.print(PMU->getSystemVoltage()); Serial.println("mV");
|
||||
|
||||
// The battery percentage may be inaccurate at first use, the PMU will automatically
|
||||
// learn the battery curve and will automatically calibrate the battery percentage
|
||||
// after a charge and discharge cycle
|
||||
if (PMU->isBatteryConnect()) {
|
||||
Serial.print("getBatteryPercent:"); Serial.print(PMU->getBatteryPercent()); Serial.println("%");
|
||||
}
|
||||
|
||||
Serial.println();
|
||||
}
|
||||
#endif
|
||||
|
||||
bool TBeamBoard::power_init()
|
||||
{
|
||||
if (!PMU) {
|
||||
#ifdef TBEAM_SUPREME_SX1262
|
||||
PMU = new XPowersAXP2101(PMU_WIRE_PORT, PIN_BOARD_SDA1, PIN_BOARD_SCL1, I2C_PMU_ADD);
|
||||
#else
|
||||
PMU = new XPowersAXP2101(PMU_WIRE_PORT, PIN_BOARD_SDA, PIN_BOARD_SCL, I2C_PMU_ADD);
|
||||
#endif
|
||||
if (!PMU->init()) {
|
||||
MESH_DEBUG_PRINTLN("Warning: Failed to find AXP2101 power management");
|
||||
delete PMU;
|
||||
PMU = NULL;
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("AXP2101 PMU init succeeded, using AXP2101 PMU");
|
||||
}
|
||||
}
|
||||
if (!PMU) {
|
||||
PMU = new XPowersAXP192(PMU_WIRE_PORT, PIN_BOARD_SDA, PIN_BOARD_SCL, I2C_PMU_ADD);
|
||||
if (!PMU->init()) {
|
||||
MESH_DEBUG_PRINTLN("Warning: Failed to find AXP192 power management");
|
||||
delete PMU;
|
||||
PMU = NULL;
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("AXP192 PMU init succeeded, using AXP192 PMU");
|
||||
}
|
||||
}
|
||||
|
||||
if (!PMU) {
|
||||
return false;
|
||||
}
|
||||
|
||||
deviceOnline |= POWERMANAGE_ONLINE;
|
||||
|
||||
PMU->setChargingLedMode(XPOWERS_CHG_LED_CTRL_CHG);
|
||||
|
||||
// Set up PMU interrupts
|
||||
pinMode(PIN_PMU_IRQ, INPUT_PULLUP);
|
||||
attachInterrupt(PIN_PMU_IRQ, setPmuFlag, FALLING);
|
||||
|
||||
if (PMU->getChipModel() == XPOWERS_AXP192) {
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_LDO2, 3300); //Set up LoRa power rail
|
||||
PMU->enablePowerOutput(XPOWERS_LDO2); //Enable the LoRa power rail
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_DCDC1, 3300); //Set up OLED power rail
|
||||
PMU->enablePowerOutput(XPOWERS_DCDC1); //Enable the OLED power rail
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_LDO3, 3300); //Set up GPS power rail
|
||||
PMU->enablePowerOutput(XPOWERS_LDO3); //Enable the GPS power rail
|
||||
|
||||
PMU->setProtectedChannel(XPOWERS_DCDC1); //Protect the OLED power rail
|
||||
PMU->setProtectedChannel(XPOWERS_DCDC3); //Protect the ESP32 power rail
|
||||
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC2); //Disable unsused power rail DC2
|
||||
|
||||
PMU->disableIRQ(XPOWERS_AXP192_ALL_IRQ); //Disable PMU IRQ
|
||||
|
||||
PMU->setChargerConstantCurr(XPOWERS_AXP192_CHG_CUR_450MA); //Set battery charging current
|
||||
PMU->setChargeTargetVoltage(XPOWERS_AXP192_CHG_VOL_4V2); //Set battery charge-stop voltage
|
||||
}
|
||||
else if(PMU->getChipModel() == XPOWERS_AXP2101){
|
||||
#ifdef TBEAM_SUPREME_SX1262
|
||||
//Set up the GPS power rail
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 3300);
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO4);
|
||||
|
||||
//Set up the LoRa power rail
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO3, 3300);
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO3);
|
||||
|
||||
//Set up power rail for the M.2 interface
|
||||
PMU->setPowerChannelVoltage(XPOWERS_DCDC3, 3300);
|
||||
PMU->enablePowerOutput(XPOWERS_DCDC3);
|
||||
|
||||
if (ESP_SLEEP_WAKEUP_UNDEFINED == esp_sleep_get_wakeup_cause()) {
|
||||
MESH_DEBUG_PRINTLN("Power off and restart ALDO BLDO..");
|
||||
PMU->disablePowerOutput(XPOWERS_ALDO1);
|
||||
PMU->disablePowerOutput(XPOWERS_ALDO2);
|
||||
PMU->disablePowerOutput(XPOWERS_BLDO1);
|
||||
delay(250);
|
||||
}
|
||||
|
||||
//Set up power rail for QMC6310U
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300);
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO2);
|
||||
|
||||
//Set up power rail for BME280 and OLED
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO1, 3300);
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO1);
|
||||
|
||||
//Set up pwer rail for SD Card
|
||||
PMU->setPowerChannelVoltage(XPOWERS_BLDO1, 3300);
|
||||
PMU->enablePowerOutput(XPOWERS_BLDO1);
|
||||
|
||||
//Set up power rail BLDO2 to headers
|
||||
PMU->setPowerChannelVoltage(XPOWERS_BLDO2, 3300);
|
||||
PMU->enablePowerOutput(XPOWERS_BLDO2);
|
||||
|
||||
//Set up power rail DCDC4 to headers
|
||||
PMU->setPowerChannelVoltage(XPOWERS_DCDC4, XPOWERS_AXP2101_DCDC4_VOL2_MAX);
|
||||
PMU->enablePowerOutput(XPOWERS_DCDC4);
|
||||
|
||||
//Set up power rail DCDC5 to headers
|
||||
PMU->setPowerChannelVoltage(XPOWERS_DCDC5, 3300);
|
||||
PMU->enablePowerOutput(XPOWERS_DCDC5);
|
||||
|
||||
//Disable unused power rails
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC2);
|
||||
PMU->disablePowerOutput(XPOWERS_DLDO1);
|
||||
PMU->disablePowerOutput(XPOWERS_DLDO2);
|
||||
PMU->disablePowerOutput(XPOWERS_VBACKUP);
|
||||
#else
|
||||
//Turn off unused power rails
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC2);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC3);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC4);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC5);
|
||||
PMU->disablePowerOutput(XPOWERS_ALDO1);
|
||||
PMU->disablePowerOutput(XPOWERS_ALDO4);
|
||||
PMU->disablePowerOutput(XPOWERS_BLDO1);
|
||||
PMU->disablePowerOutput(XPOWERS_BLDO2);
|
||||
PMU->disablePowerOutput(XPOWERS_DLDO1);
|
||||
PMU->disablePowerOutput(XPOWERS_DLDO2);
|
||||
//PMU->disablePowerOutput(XPOWERS_CPULDO);
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_VBACKUP, 3300); //Set up GPS RTC power
|
||||
PMU->enablePowerOutput(XPOWERS_VBACKUP); //Turn on GPS RTC power
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); //Set up LoRa power rail
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO2); //Enable LoRa power rail
|
||||
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO3, 3300); //Set up GPS power rail
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO3); //Enable GPS power rail
|
||||
|
||||
#endif
|
||||
|
||||
PMU->disableIRQ(XPOWERS_AXP2101_ALL_IRQ); //Disable all PMU interrupts
|
||||
|
||||
PMU->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_500MA); //Set battery charging current to 500mA
|
||||
PMU->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V2); //Set battery charging cutoff voltage to 4.2V
|
||||
|
||||
}
|
||||
|
||||
PMU->clearIrqStatus(); //Clear interrupt flags
|
||||
|
||||
PMU->disableTSPinMeasure(); //Disable TS detection, since it is not used
|
||||
|
||||
//Enable voltage measurements
|
||||
PMU->enableSystemVoltageMeasure();
|
||||
PMU->enableVbusVoltageMeasure();
|
||||
PMU->enableBattVoltageMeasure();
|
||||
|
||||
#ifdef MESH_DEBUG
|
||||
scanDevices(&Wire);
|
||||
printPMU();
|
||||
#endif
|
||||
|
||||
// Set the power key off press time
|
||||
PMU->setPowerKeyPressOffTime(XPOWERS_POWEROFF_4S);
|
||||
return true;
|
||||
}
|
||||
|
||||
#pragma region "Debug code"
|
||||
// void TBeamBoard::radiotype_detect(){
|
||||
|
||||
// static SPIClass spi;
|
||||
// char chipTypeInfo;
|
||||
|
||||
// #if defined(P_LORA_SCLK)
|
||||
// spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI);
|
||||
// #endif
|
||||
|
||||
// for(int i = 0; i<radioVersions; i++){
|
||||
// switch(i){
|
||||
// case 0:
|
||||
// CustomSX1262 radio = new Module(P_LORA_NSS, P_LORA_DIO_0, P_LORA_RESET, P_LORA_DIO_1, spi);
|
||||
// int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 8);
|
||||
// if (status != RADIOLIB_ERR_NONE) {
|
||||
// Serial.print("ERROR: SX1262 not found: ");
|
||||
// Serial.println(status);
|
||||
// //delete radio;
|
||||
// radio = NULL;
|
||||
// break;
|
||||
// }
|
||||
// else{
|
||||
// MESH_DEBUG_PRINTLN("SX1262 detected");
|
||||
// P_LORA_BUSY = 32;
|
||||
// RADIO_CLASS = CustomSX1262;
|
||||
// WRAPPER_CLASS = CustomSX1262Wrapper;
|
||||
// SX126X_RX_BOOSTED_GAIN = true;
|
||||
// SX126X_CURRENT_LIMIT = 140;
|
||||
// //delete radio;
|
||||
// radio = NULL;
|
||||
// break;
|
||||
// }
|
||||
// case 1:
|
||||
// SX1276 radio = new Module(P_LORA_NSS, P_LORA_DIO_0, P_LORA_RESET, P_LORA_DIO_1, spi);
|
||||
// int status1 = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 8);
|
||||
// if (status1 != RADIOLIB_ERR_NONE) {
|
||||
// Serial.print("ERROR: SX1272 not found: ");
|
||||
// Serial.println(status1);
|
||||
// //delete radio;
|
||||
// radio = NULL;
|
||||
// }
|
||||
// else{
|
||||
// MESH_DEBUG_PRINTLN("SX1272 detected");
|
||||
// P_LORA_BUSY = RADIOLIB_NC;
|
||||
// P_LORA_DIO_2 = 32;
|
||||
// RADIO_CLASS = CustomSX1272;
|
||||
// WRAPPER_CLASS = CustomSX1272Wrapper;
|
||||
// SX127X_CURRENT_LIMIT = 120;
|
||||
// //delete radio;
|
||||
// radio = NULL;
|
||||
// return;
|
||||
// }
|
||||
// default:
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// }
|
||||
#pragma endregion
|
||||
|
||||
#endif
|
||||
@@ -1,166 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#if defined(TBEAM_SUPREME_SX1262) || defined(TBEAM_SX1262) || defined(TBEAM_SX1276)
|
||||
|
||||
// Define pin mappings BEFORE including ESP32Board.h so sleep() can use P_LORA_DIO_1
|
||||
#ifdef TBEAM_SUPREME_SX1262
|
||||
// LoRa radio module pins for TBeam S3 Supreme SX1262
|
||||
#define P_LORA_DIO_0 -1 //NC
|
||||
#define P_LORA_DIO_1 1 //SX1262 IRQ pin
|
||||
#define P_LORA_NSS 10 //SX1262 SS pin
|
||||
#define P_LORA_RESET 5 //SX1262 Rest pin
|
||||
#define P_LORA_BUSY 4 //SX1262 Busy pin
|
||||
#define P_LORA_SCLK 12 //SX1262 SCLK pin
|
||||
#define P_LORA_MISO 13 //SX1262 MISO pin
|
||||
#define P_LORA_MOSI 11 //SX1262 MOSI pin
|
||||
|
||||
#define PIN_BOARD_SDA1 42 //SDA for PMU and PFC8563 (RTC)
|
||||
#define PIN_BOARD_SCL1 41 //SCL for PMU and PFC8563 (RTC)
|
||||
|
||||
#define PIN_PMU_IRQ 40 //IRQ pin for PMU
|
||||
|
||||
// #define PIN_GPS_RX 9
|
||||
// #define PIN_GPS_TX 8
|
||||
// #define PIN_GPS_EN 7
|
||||
|
||||
#define P_BOARD_SPI_MOSI 35 //SPI for SD Card and QMI8653 (IMU)
|
||||
#define P_BOARD_SPI_MISO 37 //SPI for SD Card and QMI8653 (IMU)
|
||||
#define P_BOARD_SPI_SCK 36 //SPI for SD Card and QMI8653 (IMU)
|
||||
#define P_BPARD_SPI_CS 47 //Pin for SD Card CS
|
||||
#define P_BOARD_IMU_CS 34 //Pin for QMI8653 (IMU) CS
|
||||
|
||||
#define P_BOARD_IMU_INT 33 //IMU Int pin
|
||||
#define P_BOARD_RTC_INT 14 //RTC Int pin
|
||||
|
||||
//I2C Wire addresses
|
||||
#define I2C_BME280_ADD 0x76 //BME280 sensor I2C address on Wire
|
||||
#define I2C_OLED_ADD 0x3C //SH1106 OLED I2C address on Wire
|
||||
#define I2C_QMC6310U_ADD 0x1C //QMC6310U mag sensor I2C address on Wire
|
||||
|
||||
//I2C Wire1 addresses
|
||||
#define I2C_RTC_ADD 0x51 //RTC I2C address on Wire1
|
||||
#define I2C_PMU_ADD 0x34 //AXP2101 I2C address on Wire1
|
||||
|
||||
#define PMU_WIRE_PORT Wire1
|
||||
#define RTC_WIRE_PORT Wire1
|
||||
#endif
|
||||
|
||||
#ifdef TBEAM_SX1262
|
||||
#define P_LORA_BUSY 32
|
||||
#endif
|
||||
|
||||
#ifdef TBEAM_SX1276
|
||||
#define P_LORA_DIO_2 32
|
||||
#define P_LORA_BUSY RADIOLIB_NC
|
||||
#endif
|
||||
|
||||
#if defined(TBEAM_SX1262) || defined(TBEAM_SX1276)
|
||||
// LoRa radio module pins for TBeam
|
||||
// uint32_t P_LORA_BUSY = 0; //shared, so define at run
|
||||
// uint32_t P_LORA_DIO_2 = 0; //SX1276 only, so define at run
|
||||
|
||||
#define P_LORA_DIO_0 26
|
||||
#define P_LORA_DIO_1 33
|
||||
#define P_LORA_NSS 18
|
||||
#define P_LORA_RESET 23
|
||||
#define P_LORA_SCLK 5
|
||||
#define P_LORA_MISO 19
|
||||
#define P_LORA_MOSI 27
|
||||
|
||||
// #define PIN_GPS_RX 34
|
||||
// #define PIN_GPS_TX 12
|
||||
|
||||
#define PIN_PMU_IRQ 35
|
||||
#define PMU_WIRE_PORT Wire
|
||||
#define RTC_WIRE_PORT Wire
|
||||
#define I2C_PMU_ADD 0x34
|
||||
#endif
|
||||
|
||||
// enum RadioType {
|
||||
// SX1262,
|
||||
// SX1276
|
||||
// };
|
||||
|
||||
// Include headers AFTER pin definitions so ESP32Board::sleep() can use P_LORA_DIO_1
|
||||
#include <Wire.h>
|
||||
#include <Arduino.h>
|
||||
#include "XPowersLib.h"
|
||||
#include "helpers/ESP32Board.h"
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
class TBeamBoard : public ESP32Board {
|
||||
XPowersLibInterface *PMU = NULL;
|
||||
//PhysicalLayer * pl;
|
||||
//RadioType * radio = NULL;
|
||||
// int radioVersions = 2;
|
||||
|
||||
enum {
|
||||
POWERMANAGE_ONLINE = _BV(0),
|
||||
DISPLAY_ONLINE = _BV(1),
|
||||
RADIO_ONLINE = _BV(2),
|
||||
GPS_ONLINE = _BV(3),
|
||||
PSRAM_ONLINE = _BV(4),
|
||||
SDCARD_ONLINE = _BV(5),
|
||||
AXDL345_ONLINE = _BV(6),
|
||||
BME280_ONLINE = _BV(7),
|
||||
BMP280_ONLINE = _BV(8),
|
||||
BME680_ONLINE = _BV(9),
|
||||
QMC6310_ONLINE = _BV(10),
|
||||
QMI8658_ONLINE = _BV(11),
|
||||
PCF8563_ONLINE = _BV(12),
|
||||
OSC32768_ONLINE = _BV(13),
|
||||
};
|
||||
|
||||
bool power_init();
|
||||
//void radiotype_detect();
|
||||
|
||||
public:
|
||||
|
||||
#ifdef MESH_DEBUG
|
||||
void printPMU();
|
||||
void scanDevices(TwoWire *w);
|
||||
#endif
|
||||
void begin();
|
||||
|
||||
#ifndef TBEAM_SUPREME_SX1262
|
||||
void onBeforeTransmit() override{
|
||||
digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on - invert pin for SX1276
|
||||
}
|
||||
void onAfterTransmit() override{
|
||||
digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off - invert pin for SX1276
|
||||
}
|
||||
#endif
|
||||
|
||||
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
|
||||
// Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep
|
||||
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
|
||||
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
|
||||
|
||||
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
|
||||
|
||||
if (pin_wake_btn < 0) {
|
||||
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet
|
||||
} else {
|
||||
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn
|
||||
}
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000);
|
||||
}
|
||||
|
||||
// Finally set ESP32 into sleep
|
||||
esp_deep_sleep_start(); // CPU halts here and never returns!
|
||||
}
|
||||
|
||||
uint16_t getBattMilliVolts(){
|
||||
return PMU->getBattVoltage();
|
||||
}
|
||||
|
||||
const char* getManufacturerName() const{
|
||||
return "LilyGo T-Beam";
|
||||
}
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -246,6 +246,7 @@ void SerialBLEInterface::enable() {
|
||||
clearBuffers();
|
||||
_last_health_check = millis();
|
||||
|
||||
Bluefruit.Advertising.restartOnDisconnect(true);
|
||||
Bluefruit.Advertising.start(0);
|
||||
}
|
||||
|
||||
@@ -259,8 +260,9 @@ void SerialBLEInterface::disable() {
|
||||
_isEnabled = false;
|
||||
BLE_DEBUG_PRINTLN("SerialBLEInterface: disable");
|
||||
|
||||
disconnect();
|
||||
Bluefruit.Advertising.restartOnDisconnect(false);
|
||||
Bluefruit.Advertising.stop();
|
||||
disconnect();
|
||||
_last_health_check = 0;
|
||||
}
|
||||
|
||||
@@ -394,4 +396,4 @@ bool SerialBLEInterface::isConnected() const {
|
||||
|
||||
bool SerialBLEInterface::isWriteBusy() const {
|
||||
return send_queue_len >= (FRAME_QUEUE_SIZE * 2 / 3);
|
||||
}
|
||||
}
|
||||
@@ -77,4 +77,4 @@ public:
|
||||
#else
|
||||
#define BLE_DEBUG_PRINT(...) {}
|
||||
#define BLE_DEBUG_PRINTLN(...) {}
|
||||
#endif
|
||||
#endif
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
};
|
||||
@@ -25,6 +25,14 @@ bool GxEPDDisplay::begin() {
|
||||
// Tell GxEPD2 to use our SPI instance
|
||||
// Using slower speed (4MHz) for reliable e-ink communication
|
||||
display.epd2.selectSPI(displaySpi, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
#elif defined(NRF52_PLATFORM)
|
||||
// nRF52 (Meshpocket et al): LoRa sits on the default SPI bus (PIN_SPI_MISO/
|
||||
// MOSI/SCK), e-ink sits on SPI1 (MISO/MOSI/SCK globals set by the variant's
|
||||
// variant.cpp). GxEPD2's default _pSPIx=&SPI would send display traffic to
|
||||
// the LoRa bus — hand the e-ink its own bus explicitly. This matches
|
||||
// upstream MeshCore's approach (which uses SPI1 universally).
|
||||
display.epd2.selectSPI(SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
SPI1.begin();
|
||||
#endif
|
||||
|
||||
// Initialize with:
|
||||
@@ -52,7 +60,7 @@ bool GxEPDDisplay::begin() {
|
||||
|
||||
void GxEPDDisplay::turnOn() {
|
||||
if (!_init) begin();
|
||||
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN)
|
||||
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN) && !defined(LilyGo_TDeck_Pro)
|
||||
digitalWrite(DISP_BACKLIGHT, HIGH);
|
||||
#elif defined(EXP_PIN_BACKLIGHT) && !defined(BACKLIGHT_BTN)
|
||||
expander.digitalWrite(EXP_PIN_BACKLIGHT, HIGH);
|
||||
@@ -61,23 +69,38 @@ void GxEPDDisplay::turnOn() {
|
||||
}
|
||||
|
||||
void GxEPDDisplay::turnOff() {
|
||||
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN)
|
||||
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN) && !defined(LilyGo_TDeck_Pro)
|
||||
// Only toggle backlight on boards that actually have one.
|
||||
// T-Deck Pro defines DISP_BACKLIGHT (GPIO 45) but has no physical backlight —
|
||||
// setting _isOn=false would stop the render loop, making the device appear frozen.
|
||||
digitalWrite(DISP_BACKLIGHT, LOW);
|
||||
_isOn = false;
|
||||
#elif defined(EXP_PIN_BACKLIGHT) && !defined(BACKLIGHT_BTN)
|
||||
expander.digitalWrite(EXP_PIN_BACKLIGHT, LOW);
|
||||
#endif
|
||||
_isOn = false;
|
||||
#endif
|
||||
// T-Deck Pro: _isOn stays true — e-ink has no backlight, render loop must keep running
|
||||
}
|
||||
|
||||
void GxEPDDisplay::clear() {
|
||||
display.fillScreen(GxEPD_WHITE);
|
||||
display.setTextColor(GxEPD_BLACK);
|
||||
if (_darkMode) {
|
||||
display.fillScreen(GxEPD_BLACK);
|
||||
display.setTextColor(GxEPD_WHITE);
|
||||
} else {
|
||||
display.fillScreen(GxEPD_WHITE);
|
||||
display.setTextColor(GxEPD_BLACK);
|
||||
}
|
||||
display_crc.reset();
|
||||
}
|
||||
|
||||
void GxEPDDisplay::startFrame(Color bkg) {
|
||||
display.fillScreen(GxEPD_WHITE);
|
||||
display.setTextColor(_curr_color = GxEPD_BLACK);
|
||||
if (_darkMode) {
|
||||
display.fillScreen(GxEPD_BLACK);
|
||||
display.setTextColor(_curr_color = GxEPD_WHITE);
|
||||
} else {
|
||||
display.fillScreen(GxEPD_WHITE);
|
||||
display.setTextColor(_curr_color = GxEPD_BLACK);
|
||||
}
|
||||
display_crc.reset();
|
||||
}
|
||||
|
||||
@@ -90,26 +113,43 @@ void GxEPDDisplay::setTextSize(int sz) {
|
||||
break;
|
||||
case 1: // Small - use 9pt (was 9pt)
|
||||
display.setFont(&FreeSans9pt7b);
|
||||
display.setTextSize(1);
|
||||
break;
|
||||
case 2: // Medium Bold - use 9pt bold instead of 12pt
|
||||
display.setFont(&FreeSans9pt7b);
|
||||
display.setTextSize(1);
|
||||
break;
|
||||
case 3: // Large - use 12pt instead of 18pt
|
||||
display.setFont(&FreeSansBold12pt7b);
|
||||
display.setTextSize(1);
|
||||
break;
|
||||
case 5: // Extra Large - lock screen clock face
|
||||
display.setFont(&FreeSansBold12pt7b);
|
||||
display.setTextSize(2); // GxEPD2 native 2× scaling on 12pt bold
|
||||
break;
|
||||
default:
|
||||
display.setFont(&FreeSans9pt7b);
|
||||
display.setTextSize(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void GxEPDDisplay::setColor(Color c) {
|
||||
display_crc.update<Color> (c);
|
||||
// colours need to be inverted for epaper displays
|
||||
if (c == DARK) {
|
||||
display.setTextColor(_curr_color = GxEPD_WHITE);
|
||||
if (_darkMode) {
|
||||
// Dark mode: DARK = black (background), LIGHT/GREEN/YELLOW = white (foreground)
|
||||
if (c == DARK) {
|
||||
display.setTextColor(_curr_color = GxEPD_BLACK);
|
||||
} else {
|
||||
display.setTextColor(_curr_color = GxEPD_WHITE);
|
||||
}
|
||||
} else {
|
||||
display.setTextColor(_curr_color = GxEPD_BLACK);
|
||||
// Normal e-paper: DARK = white (background), LIGHT/GREEN/YELLOW = black (foreground)
|
||||
if (c == DARK) {
|
||||
display.setTextColor(_curr_color = GxEPD_WHITE);
|
||||
} else {
|
||||
display.setTextColor(_curr_color = GxEPD_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
// T5S3 E-Paper Pro uses parallel e-ink (FastEPD), not SPI (GxEPD2)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include "FastEPDDisplay.h"
|
||||
using GxEPDDisplay = FastEPDDisplay;
|
||||
#else
|
||||
|
||||
#include <SPI.h>
|
||||
#include <Wire.h>
|
||||
|
||||
@@ -57,6 +63,7 @@ class GxEPDDisplay : public DisplayDriver {
|
||||
#endif
|
||||
bool _init = false;
|
||||
bool _isOn = false;
|
||||
bool _darkMode = false;
|
||||
uint16_t _curr_color;
|
||||
FrameCRC32 display_crc;
|
||||
int last_display_crc_value = 0;
|
||||
@@ -73,6 +80,11 @@ public:
|
||||
bool isOn() override {return _isOn;};
|
||||
void turnOn() override;
|
||||
void turnOff() override;
|
||||
|
||||
// Dark mode — inverts background/foreground for e-ink
|
||||
bool isDarkMode() const { return _darkMode; }
|
||||
void setDarkMode(bool on) { _darkMode = on; }
|
||||
|
||||
void clear() override;
|
||||
void startFrame(Color bkg = DARK) override;
|
||||
void setTextSize(int sz) override;
|
||||
@@ -104,4 +116,6 @@ public:
|
||||
// Force endFrame() to push to display even if CRC unchanged
|
||||
// (needed because drawPixelRaw bypasses CRC tracking)
|
||||
void invalidateFrameCRC() { last_display_crc_value = 0; }
|
||||
};
|
||||
};
|
||||
|
||||
#endif // !LilyGo_T5S3_EPaper_Pro
|
||||
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <helpers/RefCountedDigitalPin.h>
|
||||
#include <helpers/ESP32Board.h>
|
||||
|
||||
// built-ins
|
||||
#ifndef PIN_VBAT_READ // set in platformio.ini for boards like Heltec Wireless Paper (20)
|
||||
#define PIN_VBAT_READ 1
|
||||
#endif
|
||||
#ifndef PIN_ADC_CTRL // set in platformio.ini for Heltec Wireless Tracker (2)
|
||||
#define PIN_ADC_CTRL 37
|
||||
#endif
|
||||
#define PIN_ADC_CTRL_ACTIVE LOW
|
||||
#define PIN_ADC_CTRL_INACTIVE HIGH
|
||||
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
class HeltecV3Board : public ESP32Board {
|
||||
private:
|
||||
bool adc_active_state;
|
||||
|
||||
public:
|
||||
RefCountedDigitalPin periph_power;
|
||||
|
||||
HeltecV3Board() : periph_power(PIN_VEXT_EN) { }
|
||||
|
||||
void begin() {
|
||||
ESP32Board::begin();
|
||||
|
||||
// Auto-detect correct ADC_CTRL pin polarity (different for boards >3.2)
|
||||
pinMode(PIN_ADC_CTRL, INPUT);
|
||||
adc_active_state = !digitalRead(PIN_ADC_CTRL);
|
||||
|
||||
pinMode(PIN_ADC_CTRL, OUTPUT);
|
||||
digitalWrite(PIN_ADC_CTRL, !adc_active_state); // Initially inactive
|
||||
|
||||
periph_power.begin();
|
||||
|
||||
esp_reset_reason_t reason = esp_reset_reason();
|
||||
if (reason == ESP_RST_DEEPSLEEP) {
|
||||
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
|
||||
if (wakeup_source & (1 << P_LORA_DIO_1)) { // received a LoRa packet (while in deep sleep)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) {
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
|
||||
// Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep
|
||||
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
|
||||
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
|
||||
|
||||
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
|
||||
|
||||
if (pin_wake_btn < 0) {
|
||||
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet
|
||||
} else {
|
||||
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn
|
||||
}
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000);
|
||||
}
|
||||
|
||||
// Finally set ESP32 into sleep
|
||||
esp_deep_sleep_start(); // CPU halts here and never returns!
|
||||
}
|
||||
|
||||
void powerOff() override {
|
||||
enterDeepSleep(0);
|
||||
}
|
||||
|
||||
uint16_t getBattMilliVolts() override {
|
||||
analogReadResolution(10);
|
||||
digitalWrite(PIN_ADC_CTRL, adc_active_state);
|
||||
|
||||
uint32_t raw = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
raw += analogRead(PIN_VBAT_READ);
|
||||
}
|
||||
raw = raw / 8;
|
||||
|
||||
digitalWrite(PIN_ADC_CTRL, !adc_active_state);
|
||||
|
||||
return (5.42 * (3.3 / 1024.0) * raw) * 1000;
|
||||
}
|
||||
|
||||
const char* getManufacturerName() const override {
|
||||
return "Heltec V3";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
[Heltec_lora32_v3]
|
||||
extends = esp32_base
|
||||
board = esp32-s3-devkitc-1
|
||||
build_flags =
|
||||
${esp32_base.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/heltec_v3
|
||||
-D HELTEC_LORA_V3
|
||||
-D ESP32_CPU_FREQ=80
|
||||
-D P_LORA_DIO_1=14
|
||||
-D P_LORA_NSS=8
|
||||
-D P_LORA_RESET=RADIOLIB_NC
|
||||
-D P_LORA_BUSY=13
|
||||
-D P_LORA_SCLK=9
|
||||
-D P_LORA_MISO=11
|
||||
-D P_LORA_MOSI=10
|
||||
-D USE_SX1262
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
; -D P_LORA_TX_LED=35
|
||||
-D PIN_BOARD_SDA=17
|
||||
-D PIN_BOARD_SCL=18
|
||||
-D PIN_USER_BTN=0
|
||||
-D PIN_VEXT_EN=36
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=true
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=1.8
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D PIN_GPS_RX=47
|
||||
-D PIN_GPS_TX=48
|
||||
-D PIN_GPS_EN=26
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/heltec_v3>
|
||||
+<helpers/sensors>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
|
||||
[env:meck_wifi_repeater_heltec_v3]
|
||||
extends = Heltec_lora32_v3
|
||||
build_flags =
|
||||
${Heltec_lora32_v3.build_flags}
|
||||
-D DISPLAY_CLASS=SSD1306Display
|
||||
-D ADVERT_NAME='"WiFi Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MECK_WIFI_REMOTE=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
build_src_filter = ${Heltec_lora32_v3.build_src_filter}
|
||||
+<helpers/ui/SSD1306Display.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/simple_repeater>
|
||||
lib_deps =
|
||||
${Heltec_lora32_v3.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
knolleary/PubSubClient @ ^2.8
|
||||
bakercp/CRC32 @ ^2.0.0
|
||||
@@ -1,7 +1,7 @@
|
||||
#include <Arduino.h>
|
||||
#include "target.h"
|
||||
|
||||
TDeckBoard board;
|
||||
HeltecV3Board board;
|
||||
|
||||
#if defined(P_LORA_SCLK)
|
||||
static SPIClass spi;
|
||||
@@ -14,8 +14,14 @@ WRAPPER_CLASS radio_driver(radio, board);
|
||||
|
||||
ESP32RTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
MicroNMEALocationProvider gps(Serial1, &rtc_clock);
|
||||
EnvironmentSensorManager sensors(gps);
|
||||
|
||||
#if ENV_INCLUDE_GPS
|
||||
#include <helpers/sensors/MicroNMEALocationProvider.h>
|
||||
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
|
||||
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
|
||||
#else
|
||||
EnvironmentSensorManager sensors;
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
DISPLAY_CLASS display;
|
||||
@@ -25,8 +31,7 @@ EnvironmentSensorManager sensors(gps);
|
||||
bool radio_init() {
|
||||
fallback_clock.begin();
|
||||
rtc_clock.begin(Wire);
|
||||
Wire.begin(18, 8);
|
||||
|
||||
|
||||
#if defined(P_LORA_SCLK)
|
||||
return radio.std_init(&spi);
|
||||
#else
|
||||
@@ -45,11 +50,12 @@ void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setCodingRate(cr);
|
||||
}
|
||||
|
||||
void radio_set_tx_power(uint8_t dbm) {
|
||||
void radio_set_tx_power(int8_t dbm) {
|
||||
radio.setOutputPower(dbm);
|
||||
}
|
||||
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng); // create new random identity
|
||||
}
|
||||
return mesh::LocalIdentity(&rng); // create new random identity
|
||||
}
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <HeltecV3Board.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include <TDeckBoard.h>
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/sensors/EnvironmentSensorManager.h>
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include <helpers/ui/ST7789LCDDisplay.h>
|
||||
#include <helpers/ui/SSD1306Display.h>
|
||||
#include <helpers/ui/MomentaryButton.h>
|
||||
#endif
|
||||
#include "helpers/sensors/EnvironmentSensorManager.h"
|
||||
#include "helpers/sensors/MicroNMEALocationProvider.h"
|
||||
|
||||
extern TDeckBoard board;
|
||||
extern HeltecV3Board board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
@@ -27,5 +26,5 @@ extern EnvironmentSensorManager sensors;
|
||||
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_set_tx_power(int8_t dbm);
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "HeltecV4Board.h"
|
||||
|
||||
void HeltecV4Board::begin() {
|
||||
ESP32Board::begin();
|
||||
|
||||
|
||||
pinMode(PIN_ADC_CTRL, OUTPUT);
|
||||
digitalWrite(PIN_ADC_CTRL, LOW); // Initially inactive
|
||||
|
||||
// Set up digital GPIO registers before releasing RTC hold. The hold latches
|
||||
// the pad state including function select, so register writes accumulate
|
||||
// without affecting the pad. On hold release, all changes apply atomically
|
||||
// (IO MUX switches to digital GPIO with output already HIGH — no glitch).
|
||||
pinMode(P_LORA_PA_POWER, OUTPUT);
|
||||
digitalWrite(P_LORA_PA_POWER,HIGH);
|
||||
rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER);
|
||||
|
||||
pinMode(P_LORA_PA_EN, OUTPUT);
|
||||
digitalWrite(P_LORA_PA_EN,HIGH);
|
||||
rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_EN);
|
||||
pinMode(P_LORA_PA_TX_EN, OUTPUT);
|
||||
digitalWrite(P_LORA_PA_TX_EN,LOW);
|
||||
|
||||
esp_reset_reason_t reason = esp_reset_reason();
|
||||
if (reason != ESP_RST_DEEPSLEEP) {
|
||||
delay(1); // GC1109 startup time after cold power-on
|
||||
}
|
||||
|
||||
periph_power.begin();
|
||||
if (reason == ESP_RST_DEEPSLEEP) {
|
||||
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
|
||||
if (wakeup_source & (1 << P_LORA_DIO_1)) { // received a LoRa packet (while in deep sleep)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
void HeltecV4Board::onBeforeTransmit(void) {
|
||||
digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on
|
||||
digitalWrite(P_LORA_PA_TX_EN,HIGH);
|
||||
}
|
||||
|
||||
void HeltecV4Board::onAfterTransmit(void) {
|
||||
digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off
|
||||
digitalWrite(P_LORA_PA_TX_EN,LOW);
|
||||
}
|
||||
|
||||
void HeltecV4Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) {
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
|
||||
// Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep
|
||||
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
|
||||
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
|
||||
|
||||
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
|
||||
|
||||
// Hold GC1109 FEM pins during sleep to keep LNA active for RX wake
|
||||
rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER);
|
||||
rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_EN);
|
||||
|
||||
if (pin_wake_btn < 0) {
|
||||
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet
|
||||
} else {
|
||||
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn
|
||||
}
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000);
|
||||
}
|
||||
|
||||
// Finally set ESP32 into sleep
|
||||
esp_deep_sleep_start(); // CPU halts here and never returns!
|
||||
}
|
||||
|
||||
void HeltecV4Board::powerOff() {
|
||||
enterDeepSleep(0);
|
||||
}
|
||||
|
||||
uint16_t HeltecV4Board::getBattMilliVolts() {
|
||||
analogReadResolution(10);
|
||||
digitalWrite(PIN_ADC_CTRL, HIGH);
|
||||
delay(10);
|
||||
uint32_t raw = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
raw += analogRead(PIN_VBAT_READ);
|
||||
}
|
||||
raw = raw / 8;
|
||||
|
||||
digitalWrite(PIN_ADC_CTRL, LOW);
|
||||
|
||||
return (5.42 * (3.3 / 1024.0) * raw) * 1000;
|
||||
}
|
||||
|
||||
const char* HeltecV4Board::getManufacturerName() const {
|
||||
#ifdef HELTEC_LORA_V4_TFT
|
||||
return "Heltec V4 TFT";
|
||||
#else
|
||||
return "Heltec V4 OLED";
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <helpers/RefCountedDigitalPin.h>
|
||||
#include <helpers/ESP32Board.h>
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
class HeltecV4Board : public ESP32Board {
|
||||
|
||||
public:
|
||||
RefCountedDigitalPin periph_power;
|
||||
|
||||
HeltecV4Board() : periph_power(PIN_VEXT_EN,PIN_VEXT_EN_ACTIVE) { }
|
||||
|
||||
void begin();
|
||||
void onBeforeTransmit(void) override;
|
||||
void onAfterTransmit(void) override;
|
||||
void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1);
|
||||
void powerOff() override;
|
||||
uint16_t getBattMilliVolts() override;
|
||||
const char* getManufacturerName() const override ;
|
||||
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
# Heltec V4 WiFi Remote Repeater — Setup Guide
|
||||
|
||||
## Variant Files
|
||||
|
||||
Copy the following into `variants/heltec_v4/`:
|
||||
- `HeltecV4Board.h`, `HeltecV4Board.cpp`
|
||||
- `target.h`, `target.cpp`
|
||||
- `pins_arduino.h`
|
||||
|
||||
Copy `heltec_v4.json` into `boards/`
|
||||
|
||||
## Config Files (SPIFFS)
|
||||
|
||||
The Heltec V4 has no SD card slot — config lives in SPIFFS.
|
||||
|
||||
Create a `data/remote/` folder in your project root:
|
||||
|
||||
```
|
||||
data/
|
||||
remote/
|
||||
wifi.cfg
|
||||
mqtt.cfg
|
||||
```
|
||||
|
||||
### data/remote/wifi.cfg
|
||||
```
|
||||
YourSSID
|
||||
YourPassword
|
||||
BackupSSID
|
||||
BackupPassword
|
||||
```
|
||||
|
||||
### data/remote/mqtt.cfg
|
||||
```
|
||||
6818ce5f77dd45bb90facf753ba81d81.s1.eu.hivemq.cloud
|
||||
8883
|
||||
meckremote
|
||||
yourpassword
|
||||
heltec-wifi-1
|
||||
```
|
||||
|
||||
### Upload config to SPIFFS
|
||||
```bash
|
||||
pio run -e meck_wifi_repeater_heltec_v4 -t uploadfs
|
||||
```
|
||||
|
||||
This uploads the `data/` folder contents to SPIFFS on the device.
|
||||
|
||||
### Flash firmware
|
||||
```bash
|
||||
pio run -e meck_wifi_repeater_heltec_v4 -t upload
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The OLED display shows basic repeater status (same as stock repeater)
|
||||
- WiFi MQTT and Mycelium dashboard work identically to T-Deck Pro builds
|
||||
- OTA firmware updates work over WiFi via the Mycelium dashboard
|
||||
- Config changes require re-uploading SPIFFS (`-t uploadfs`)
|
||||
- The same `main.cpp`, `WiFiMQTT.h/cpp`, and `MyMesh.cpp` are shared
|
||||
with T-Deck Pro and T5S3 builds — no Heltec-specific source changes
|
||||
@@ -0,0 +1,67 @@
|
||||
#ifndef Pins_Arduino_h
|
||||
#define Pins_Arduino_h
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
static const uint8_t LED_BUILTIN = 35;
|
||||
#define BUILTIN_LED LED_BUILTIN // backward compatibility
|
||||
#define LED_BUILTIN LED_BUILTIN // allow testing #ifdef LED_BUILTIN
|
||||
|
||||
static const uint8_t TX = 43;
|
||||
static const uint8_t RX = 44;
|
||||
|
||||
static const uint8_t SDA = 3;
|
||||
static const uint8_t SCL = 4;
|
||||
|
||||
static const uint8_t SS = 8;
|
||||
static const uint8_t MOSI = 10;
|
||||
static const uint8_t MISO = 11;
|
||||
static const uint8_t SCK = 9;
|
||||
|
||||
static const uint8_t A0 = 1;
|
||||
static const uint8_t A1 = 2;
|
||||
static const uint8_t A2 = 3;
|
||||
static const uint8_t A3 = 4;
|
||||
static const uint8_t A4 = 5;
|
||||
static const uint8_t A5 = 6;
|
||||
static const uint8_t A6 = 7;
|
||||
static const uint8_t A7 = 8;
|
||||
static const uint8_t A8 = 9;
|
||||
static const uint8_t A9 = 10;
|
||||
static const uint8_t A10 = 11;
|
||||
static const uint8_t A11 = 12;
|
||||
static const uint8_t A12 = 13;
|
||||
static const uint8_t A13 = 14;
|
||||
static const uint8_t A14 = 15;
|
||||
static const uint8_t A15 = 16;
|
||||
static const uint8_t A16 = 17;
|
||||
static const uint8_t A17 = 18;
|
||||
static const uint8_t A18 = 19;
|
||||
static const uint8_t A19 = 20;
|
||||
|
||||
static const uint8_t T1 = 1;
|
||||
static const uint8_t T2 = 2;
|
||||
static const uint8_t T3 = 3;
|
||||
static const uint8_t T4 = 4;
|
||||
static const uint8_t T5 = 5;
|
||||
static const uint8_t T6 = 6;
|
||||
static const uint8_t T7 = 7;
|
||||
static const uint8_t T8 = 8;
|
||||
static const uint8_t T9 = 9;
|
||||
static const uint8_t T10 = 10;
|
||||
static const uint8_t T11 = 11;
|
||||
static const uint8_t T12 = 12;
|
||||
static const uint8_t T13 = 13;
|
||||
static const uint8_t T14 = 14;
|
||||
|
||||
static const uint8_t Vext = 36;
|
||||
static const uint8_t LED = 35;
|
||||
static const uint8_t RST_OLED = 21;
|
||||
static const uint8_t SCL_OLED = 18;
|
||||
static const uint8_t SDA_OLED = 17;
|
||||
|
||||
static const uint8_t RST_LoRa = 12;
|
||||
static const uint8_t BUSY_LoRa = 13;
|
||||
static const uint8_t DIO0 = 14;
|
||||
|
||||
#endif /* Pins_Arduino_h */
|
||||
@@ -0,0 +1,208 @@
|
||||
[Heltec_lora32_v4]
|
||||
extends = esp32_base
|
||||
board = heltec_v4
|
||||
build_flags =
|
||||
${esp32_base.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/heltec_v4
|
||||
-D HELTEC_LORA_V4
|
||||
-D USE_SX1262
|
||||
-D ESP32_CPU_FREQ=80
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D P_LORA_TX_LED=35
|
||||
-D P_LORA_DIO_1=14
|
||||
-D P_LORA_NSS=8
|
||||
-D P_LORA_RESET=12
|
||||
-D P_LORA_BUSY=13
|
||||
-D P_LORA_SCLK=9
|
||||
-D P_LORA_MISO=11
|
||||
-D P_LORA_MOSI=10
|
||||
-D P_LORA_PA_POWER=7 ; VFEM_Ctrl - Power on GC1109
|
||||
-D P_LORA_PA_EN=2 ; PA CSD - Enable GC1109
|
||||
-D P_LORA_PA_TX_EN=46 ; PA CPS - GC1109 TX PA full(High) / bypass(Low)
|
||||
-D PIN_USER_BTN=0
|
||||
-D PIN_VEXT_EN=36
|
||||
-D PIN_VEXT_EN_ACTIVE=HIGH
|
||||
-D LORA_TX_POWER=10 ;If it is configured as 10 here, the final output will be 22 dbm.
|
||||
-D MAX_LORA_TX_POWER=22 ; Max SX1262 output
|
||||
-D SX126X_REGISTER_PATCH=1 ; Patch register 0x8B5 for improved RX
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=true ; GC1109 CTX is controlled by SX1262 DIO2
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=1.8
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1 ; In some cases, commenting this out will improve RX
|
||||
-D PIN_GPS_RX=38
|
||||
-D PIN_GPS_TX=39
|
||||
-D PIN_GPS_RESET=42
|
||||
-D PIN_GPS_RESET_ACTIVE=LOW
|
||||
-D PIN_GPS_EN=34
|
||||
-D PIN_GPS_EN_ACTIVE=LOW
|
||||
-D ENV_INCLUDE_GPS=1
|
||||
-D PIN_ADC_CTRL=37
|
||||
-D PIN_VBAT_READ=1
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/heltec_v4>
|
||||
+<helpers/sensors>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
|
||||
[heltec_v4_oled]
|
||||
extends = Heltec_lora32_v4
|
||||
build_flags =
|
||||
${Heltec_lora32_v4.build_flags}
|
||||
-D HELTEC_LORA_V4_OLED
|
||||
-D PIN_BOARD_SDA=17
|
||||
-D PIN_BOARD_SCL=18
|
||||
-D PIN_OLED_RESET=21
|
||||
build_src_filter= ${Heltec_lora32_v4.build_src_filter}
|
||||
lib_deps = ${Heltec_lora32_v4.lib_deps}
|
||||
|
||||
[heltec_v4_tft]
|
||||
extends = Heltec_lora32_v4
|
||||
build_flags =
|
||||
${Heltec_lora32_v4.build_flags}
|
||||
-D HELTEC_LORA_V4_TFT
|
||||
-D PIN_BOARD_SDA=4
|
||||
-D PIN_BOARD_SCL=3
|
||||
-D DISPLAY_SCALE_X=2.5
|
||||
-D DISPLAY_SCALE_Y=3.75
|
||||
-D PIN_TFT_RST=18
|
||||
-D PIN_TFT_VDD_CTL=-1
|
||||
-D PIN_TFT_LEDA_CTL=21
|
||||
-D PIN_TFT_LEDA_CTL_ACTIVE=HIGH
|
||||
-D PIN_TFT_CS=15
|
||||
-D PIN_TFT_DC=16
|
||||
-D PIN_TFT_SCL=17
|
||||
-D PIN_TFT_SDA=33
|
||||
build_src_filter= ${Heltec_lora32_v4.build_src_filter}
|
||||
lib_deps =
|
||||
${Heltec_lora32_v4.lib_deps}
|
||||
adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Heltec V4 WiFi Remote Repeater — WiFi MQTT backhaul, remote management
|
||||
; No SD card — config files stored in SPIFFS.
|
||||
; Upload config: create data/remote/ folder with wifi.cfg and mqtt.cfg,
|
||||
; then run: pio run -e meck_wifi_repeater_heltec_v4 -t uploadfs
|
||||
; OLED display shows status (optional — works headless too)
|
||||
; Flash: pio run -e meck_wifi_repeater_heltec_v4 -t upload
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_wifi_repeater_heltec_v4]
|
||||
extends = heltec_v4_oled
|
||||
upload_port = /dev/cu.usbmodem101
|
||||
build_src_filter = ${heltec_v4_oled.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<helpers/ui/SSD1306Display.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/simple_repeater/*.cpp>
|
||||
build_flags =
|
||||
${heltec_v4_oled.build_flags}
|
||||
-D FIRMWARE_VERSION='"Meck HV4 WiFi Rptr v0.1"'
|
||||
-D FIRMWARE_BUILD_DATE='"5 Apr 2026"'
|
||||
-D DISPLAY_CLASS=SSD1306Display
|
||||
-D ADVERT_NAME='"Heltec Repeater"'
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MECK_WIFI_REMOTE
|
||||
-D MECK_REMOTE_REPEATER=1
|
||||
-D DISABLE_WIFI_OTA=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D RADIOLIB_EXCLUDE_CC1101=1
|
||||
-D RADIOLIB_EXCLUDE_NRF24=1
|
||||
-D RADIOLIB_EXCLUDE_RF69=1
|
||||
-D RADIOLIB_EXCLUDE_SX1231=1
|
||||
-D RADIOLIB_EXCLUDE_SX1233=1
|
||||
-D RADIOLIB_EXCLUDE_SI443X=1
|
||||
-D RADIOLIB_EXCLUDE_RFM2X=1
|
||||
-D RADIOLIB_EXCLUDE_SX127X=1
|
||||
-D RADIOLIB_EXCLUDE_SX1272=1
|
||||
-D RADIOLIB_EXCLUDE_SX1278=1
|
||||
-D RADIOLIB_EXCLUDE_STM32WLX=1
|
||||
-D RADIOLIB_EXCLUDE_LR11X0=1
|
||||
-D RADIOLIB_EXCLUDE_LLCC68=1
|
||||
-D RADIOLIB_EXCLUDE_SX128X=1
|
||||
-D RADIOLIB_EXCLUDE_AFSK=1
|
||||
-D RADIOLIB_EXCLUDE_AX25=1
|
||||
-D RADIOLIB_EXCLUDE_HELLSCHREIBER=1
|
||||
-D RADIOLIB_EXCLUDE_MORSE=1
|
||||
-D RADIOLIB_EXCLUDE_RTTY=1
|
||||
-D RADIOLIB_EXCLUDE_SSTV=1
|
||||
-D RADIOLIB_EXCLUDE_APRS=1
|
||||
-D RADIOLIB_EXCLUDE_LORAWAN=1
|
||||
-D RADIOLIB_EXCLUDE_PAGER=1
|
||||
-D RADIOLIB_EXCLUDE_FSK4=1
|
||||
-D RADIOLIB_EXCLUDE_BELL=1
|
||||
lib_deps =
|
||||
${heltec_v4_oled.lib_deps}
|
||||
knolleary/PubSubClient@^2.8
|
||||
lib_ignore =
|
||||
ESP32 BLE Arduino
|
||||
AsyncTCP
|
||||
RPAsyncTCP
|
||||
ESPAsyncWebServer
|
||||
AsyncElegantOTA
|
||||
ESP32-audioI2S
|
||||
esp32_codec2_arduino
|
||||
board_build.partitions = default_16MB.csv
|
||||
board_build.filesystem = spiffs
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Heltec V4 WiFi Remote Repeater — HEADLESS (no display)
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_wifi_repeater_heltec_v4_headless]
|
||||
extends = Heltec_lora32_v4
|
||||
upload_port = /dev/cu.usbmodem101
|
||||
build_src_filter = ${Heltec_lora32_v4.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<../examples/simple_repeater/*.cpp>
|
||||
build_flags =
|
||||
${Heltec_lora32_v4.build_flags}
|
||||
-D ESP32_CPU_FREQ=240
|
||||
-D FIRMWARE_VERSION='"Meck HV4 WiFi Rptr v0.1"'
|
||||
-D FIRMWARE_BUILD_DATE='"5 Apr 2026"'
|
||||
-D ADVERT_NAME='"Heltec Repeater"'
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MECK_WIFI_REMOTE
|
||||
-D MECK_REMOTE_REPEATER=1
|
||||
-D DISABLE_WIFI_OTA=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D RADIOLIB_EXCLUDE_CC1101=1
|
||||
-D RADIOLIB_EXCLUDE_NRF24=1
|
||||
-D RADIOLIB_EXCLUDE_RF69=1
|
||||
-D RADIOLIB_EXCLUDE_SX1231=1
|
||||
-D RADIOLIB_EXCLUDE_SX1233=1
|
||||
-D RADIOLIB_EXCLUDE_SI443X=1
|
||||
-D RADIOLIB_EXCLUDE_RFM2X=1
|
||||
-D RADIOLIB_EXCLUDE_SX127X=1
|
||||
-D RADIOLIB_EXCLUDE_SX1272=1
|
||||
-D RADIOLIB_EXCLUDE_SX1278=1
|
||||
-D RADIOLIB_EXCLUDE_STM32WLX=1
|
||||
-D RADIOLIB_EXCLUDE_LR11X0=1
|
||||
-D RADIOLIB_EXCLUDE_LLCC68=1
|
||||
-D RADIOLIB_EXCLUDE_SX128X=1
|
||||
-D RADIOLIB_EXCLUDE_AFSK=1
|
||||
-D RADIOLIB_EXCLUDE_AX25=1
|
||||
-D RADIOLIB_EXCLUDE_HELLSCHREIBER=1
|
||||
-D RADIOLIB_EXCLUDE_MORSE=1
|
||||
-D RADIOLIB_EXCLUDE_RTTY=1
|
||||
-D RADIOLIB_EXCLUDE_SSTV=1
|
||||
-D RADIOLIB_EXCLUDE_APRS=1
|
||||
-D RADIOLIB_EXCLUDE_LORAWAN=1
|
||||
-D RADIOLIB_EXCLUDE_PAGER=1
|
||||
-D RADIOLIB_EXCLUDE_FSK4=1
|
||||
-D RADIOLIB_EXCLUDE_BELL=1
|
||||
lib_deps =
|
||||
${Heltec_lora32_v4.lib_deps}
|
||||
knolleary/PubSubClient@^2.8
|
||||
lib_ignore =
|
||||
ESP32 BLE Arduino
|
||||
AsyncTCP
|
||||
RPAsyncTCP
|
||||
ESPAsyncWebServer
|
||||
AsyncElegantOTA
|
||||
ESP32-audioI2S
|
||||
esp32_codec2_arduino
|
||||
board_build.partitions = default_16MB.csv
|
||||
board_build.filesystem = spiffs
|
||||
@@ -0,0 +1,61 @@
|
||||
#include <Arduino.h>
|
||||
#include "target.h"
|
||||
|
||||
HeltecV4Board board;
|
||||
|
||||
#if defined(P_LORA_SCLK)
|
||||
static SPIClass spi;
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);
|
||||
#else
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY);
|
||||
#endif
|
||||
|
||||
WRAPPER_CLASS radio_driver(radio, board);
|
||||
|
||||
ESP32RTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
|
||||
#if ENV_INCLUDE_GPS
|
||||
#include <helpers/sensors/MicroNMEALocationProvider.h>
|
||||
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
|
||||
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
|
||||
#else
|
||||
EnvironmentSensorManager sensors;
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
DISPLAY_CLASS display(NULL);
|
||||
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
fallback_clock.begin();
|
||||
rtc_clock.begin(Wire);
|
||||
|
||||
#if defined(P_LORA_SCLK)
|
||||
return radio.std_init(&spi);
|
||||
#else
|
||||
return radio.std_init();
|
||||
#endif
|
||||
}
|
||||
|
||||
uint32_t radio_get_rng_seed() {
|
||||
return radio.random(0x7FFFFFFF);
|
||||
}
|
||||
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setFrequency(freq);
|
||||
radio.setSpreadingFactor(sf);
|
||||
radio.setBandwidth(bw);
|
||||
radio.setCodingRate(cr);
|
||||
}
|
||||
|
||||
void radio_set_tx_power(int8_t dbm) {
|
||||
radio.setOutputPower(dbm);
|
||||
}
|
||||
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng); // create new random identity
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <HeltecV4Board.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/sensors/EnvironmentSensorManager.h>
|
||||
#ifdef DISPLAY_CLASS
|
||||
#ifdef HELTEC_LORA_V4_OLED
|
||||
#include <helpers/ui/SSD1306Display.h>
|
||||
#elif defined(HELTEC_LORA_V4_TFT)
|
||||
#include <helpers/ui/ST7789LCDDisplay.h>
|
||||
#endif
|
||||
#include <helpers/ui/MomentaryButton.h>
|
||||
#endif
|
||||
|
||||
extern HeltecV4Board board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
extern DISPLAY_CLASS display;
|
||||
extern MomentaryButton user_btn;
|
||||
#endif
|
||||
|
||||
bool radio_init();
|
||||
uint32_t radio_get_rng_seed();
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
|
||||
void radio_set_tx_power(int8_t dbm);
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// CPU Frequency Scaling for ESP32-S3
|
||||
//
|
||||
// Typical current draw (CPU only, rough):
|
||||
// 240 MHz ~70-80 mA
|
||||
// 160 MHz ~50-60 mA
|
||||
// 80 MHz ~30-40 mA
|
||||
// 40 MHz ~15-20 mA (low-power / lock screen mode)
|
||||
//
|
||||
// SPI peripherals and UART use their own clock dividers from the APB clock,
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz.
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
#ifndef CPU_FREQ_IDLE
|
||||
#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_BOOST
|
||||
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_LOW_POWER
|
||||
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
|
||||
#endif
|
||||
|
||||
#ifndef CPU_BOOST_TIMEOUT_MS
|
||||
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
|
||||
#endif
|
||||
|
||||
class CPUPowerManager {
|
||||
public:
|
||||
CPUPowerManager() : _boosted(false), _lowPower(false), _boost_started(0) {}
|
||||
|
||||
void begin() {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
_lowPower = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
|
||||
// Return to low-power if locked, otherwise normal idle
|
||||
if (_lowPower) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: boost expired, returning to low-power %d MHz", CPU_FREQ_LOW_POWER);
|
||||
} else {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
_boosted = false;
|
||||
}
|
||||
}
|
||||
|
||||
void setBoost() {
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_BOOST);
|
||||
_boosted = true;
|
||||
MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST);
|
||||
}
|
||||
_boost_started = millis();
|
||||
}
|
||||
|
||||
void setIdle() {
|
||||
if (_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
if (_lowPower) {
|
||||
_lowPower = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Low-power mode — drops CPU to 40 MHz for lock screen standby.
|
||||
// If currently boosted, the boost timeout will return to 40 MHz
|
||||
// instead of 80 MHz.
|
||||
void setLowPower() {
|
||||
_lowPower = true;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: low-power at %d MHz", CPU_FREQ_LOW_POWER);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to low-power instead of idle
|
||||
}
|
||||
|
||||
// Exit low-power mode — returns to normal idle (80 MHz).
|
||||
// If currently boosted, the boost timeout will return to idle
|
||||
// instead of low-power.
|
||||
void clearLowPower() {
|
||||
_lowPower = false;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz (low-power cleared)", CPU_FREQ_IDLE);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to idle as normal
|
||||
}
|
||||
|
||||
bool isBoosted() const { return _boosted; }
|
||||
bool isLowPower() const { return _lowPower; }
|
||||
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
|
||||
|
||||
private:
|
||||
bool _boosted;
|
||||
bool _lowPower;
|
||||
unsigned long _boost_started;
|
||||
};
|
||||
|
||||
#endif // ESP32
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,461 @@
|
||||
#include <Arduino.h>
|
||||
#include "variant.h"
|
||||
#include "T5S3Board.h"
|
||||
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
|
||||
|
||||
void T5S3Board::begin() {
|
||||
MESH_DEBUG_PRINTLN("T5S3Board::begin() - starting");
|
||||
|
||||
// Initialize I2C with T5S3 V2 pins
|
||||
// Note: No explicit peripheral power enable needed on T5S3
|
||||
// (unlike T-Deck Pro's PIN_PERF_POWERON)
|
||||
Wire.begin(I2C_SDA, I2C_SCL);
|
||||
Wire.setClock(100000); // 100kHz for reliable fuel gauge communication
|
||||
MESH_DEBUG_PRINTLN("T5S3Board::begin() - I2C initialized (SDA=%d, SCL=%d)", I2C_SDA, I2C_SCL);
|
||||
|
||||
// Call parent class begin (handles CPU freq, etc.)
|
||||
// Note: ESP32Board::begin() also calls Wire.begin() but with our
|
||||
// PIN_BOARD_SDA/SCL defines it will use the same pins — harmless.
|
||||
ESP32Board::begin();
|
||||
|
||||
// Configure backlight (off by default — save power)
|
||||
#ifdef BOARD_BL_EN
|
||||
pinMode(BOARD_BL_EN, OUTPUT);
|
||||
digitalWrite(BOARD_BL_EN, LOW);
|
||||
MESH_DEBUG_PRINTLN("T5S3Board::begin() - backlight pin configured (GPIO%d)", BOARD_BL_EN);
|
||||
#endif
|
||||
|
||||
// Configure user button
|
||||
pinMode(PIN_USER_BTN, INPUT);
|
||||
|
||||
// Configure LoRa SPI MISO pullup
|
||||
pinMode(P_LORA_MISO, INPUT_PULLUP);
|
||||
|
||||
// Handle wake from deep sleep
|
||||
esp_reset_reason_t reason = esp_reset_reason();
|
||||
if (reason == ESP_RST_DEEPSLEEP) {
|
||||
uint64_t wakeup_source = esp_sleep_get_ext1_wakeup_status();
|
||||
if (wakeup_source & (1ULL << P_LORA_DIO_1)) {
|
||||
startup_reason = BD_STARTUP_RX_PACKET;
|
||||
}
|
||||
rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS);
|
||||
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
|
||||
}
|
||||
|
||||
// Test BQ27220 communication and configure design capacity
|
||||
#if HAS_BQ27220
|
||||
uint16_t voltage = getBattMilliVolts();
|
||||
MESH_DEBUG_PRINTLN("T5S3Board::begin() - Battery voltage: %d mV", voltage);
|
||||
configureFuelGauge();
|
||||
#endif
|
||||
|
||||
// Early low-voltage protection
|
||||
#if HAS_BQ27220 && defined(AUTO_SHUTDOWN_MILLIVOLTS)
|
||||
{
|
||||
uint16_t bootMv = getBattMilliVolts();
|
||||
if (bootMv > 0 && bootMv < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
Serial.printf("CRITICAL: Boot voltage %dmV < %dmV — sleeping immediately\n",
|
||||
bootMv, AUTO_SHUTDOWN_MILLIVOLTS);
|
||||
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
|
||||
esp_sleep_enable_ext1_wakeup(1ULL << PIN_USER_BTN, ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
MESH_DEBUG_PRINTLN("T5S3Board::begin() - complete");
|
||||
}
|
||||
|
||||
// ---- BQ27220 register helpers (static, file-local) ----
|
||||
|
||||
#if HAS_BQ27220
|
||||
static uint16_t bq27220_read16(uint8_t reg) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2) != 2) return 0;
|
||||
uint16_t val = Wire.read();
|
||||
val |= (Wire.read() << 8);
|
||||
return val;
|
||||
}
|
||||
|
||||
static uint8_t bq27220_read8(uint8_t reg) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)1) != 1) return 0;
|
||||
return Wire.read();
|
||||
}
|
||||
|
||||
static bool bq27220_writeControl(uint16_t subcmd) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x00);
|
||||
Wire.write(subcmd & 0xFF);
|
||||
Wire.write((subcmd >> 8) & 0xFF);
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---- BQ27220 public interface ----
|
||||
|
||||
uint16_t T5S3Board::getBattMilliVolts() {
|
||||
#if HAS_BQ27220
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(BQ27220_REG_VOLTAGE);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
uint8_t count = Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2);
|
||||
if (count != 2) return 0;
|
||||
uint16_t voltage = Wire.read();
|
||||
voltage |= (Wire.read() << 8);
|
||||
return voltage;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint8_t T5S3Board::getBatteryPercent() {
|
||||
#if HAS_BQ27220
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(BQ27220_REG_SOC);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
uint8_t count = Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2);
|
||||
if (count != 2) return 0;
|
||||
uint16_t soc = Wire.read();
|
||||
soc |= (Wire.read() << 8);
|
||||
return (uint8_t)min(soc, (uint16_t)100);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t T5S3Board::getAvgCurrent() {
|
||||
#if HAS_BQ27220
|
||||
return (int16_t)bq27220_read16(BQ27220_REG_AVG_CURRENT);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t T5S3Board::getAvgPower() {
|
||||
#if HAS_BQ27220
|
||||
return (int16_t)bq27220_read16(BQ27220_REG_AVG_POWER);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t T5S3Board::getTimeToEmpty() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_TIME_TO_EMPTY);
|
||||
#else
|
||||
return 0xFFFF;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t T5S3Board::getRemainingCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_REMAIN_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t T5S3Board::getFullChargeCapacity() {
|
||||
#if HAS_BQ27220
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
if (fcc > BQ27220_DESIGN_CAPACITY_MAH) fcc = BQ27220_DESIGN_CAPACITY_MAH;
|
||||
return fcc;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t T5S3Board::getDesignCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t T5S3Board::getBattTemperature() {
|
||||
#if HAS_BQ27220
|
||||
uint16_t raw = bq27220_read16(BQ27220_REG_TEMPERATURE);
|
||||
return (int16_t)(raw - 2731); // 0.1°K to 0.1°C
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---- BQ27220 Design Capacity configuration ----
|
||||
// The BQ27220 ships with a 3000 mAh default. T5S3 uses a 1500 mAh cell.
|
||||
// This function checks on boot and writes the correct value via the
|
||||
// MAC Data Memory interface if needed. The value persists in battery-backed
|
||||
// RAM, so this typically only writes once (or after a full battery disconnect).
|
||||
//
|
||||
// When DC and DE are already correct but FCC is stuck (common after initial
|
||||
// flash), the root cause is Qmax Cell 0 (0x9106) and stored FCC (0x929D)
|
||||
// retaining factory 3000 mAh defaults. This function detects and fixes all
|
||||
// three layers: DC/DE, Qmax, and stored FCC.
|
||||
|
||||
bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
#if HAS_BQ27220
|
||||
uint16_t currentDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
|
||||
|
||||
if (currentDC == designCapacity_mAh) {
|
||||
// Design Capacity correct, but check if Full Charge Capacity is sane.
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity already correct, FCC=%d mAh\n", fcc);
|
||||
if (fcc >= designCapacity_mAh * 3 / 2) {
|
||||
// FCC is >=150% of design — stale from factory defaults (typically 3000 mAh).
|
||||
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
|
||||
Serial.printf("BQ27220: FCC %d >> DC %d, checking Design Energy (target %d mWh)\n",
|
||||
fcc, designCapacity_mAh, designEnergy);
|
||||
|
||||
// Unseal to read data memory and issue RESET
|
||||
bq27220_writeControl(0x0414); delay(2);
|
||||
bq27220_writeControl(0x3672); delay(2);
|
||||
// Full Access
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
|
||||
// Enter CFG_UPDATE to access data memory
|
||||
bq27220_writeControl(0x0090);
|
||||
bool ready = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
delay(20);
|
||||
uint16_t opSt = bq27220_read16(BQ27220_REG_OP_STATUS);
|
||||
if (opSt & 0x0400) { ready = true; break; }
|
||||
}
|
||||
if (ready) {
|
||||
// Read Design Energy at data memory address 0x92A1
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint16_t currentDE = (oldMSB << 8) | oldLSB;
|
||||
|
||||
if (currentDE != designEnergy) {
|
||||
// Design Energy actually needs updating — write it
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint8_t newMSB = (designEnergy >> 8) & 0xFF;
|
||||
uint8_t newLSB = designEnergy & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: DE old=%d new=%d mWh, writing\n", currentDE, designEnergy);
|
||||
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.write(newMSB); Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(newChk); Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
// Exit with reinit since we actually changed data
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Design Energy written, exited CFG_UPDATE");
|
||||
} else {
|
||||
// DC and DE are both correct, but FCC is stuck.
|
||||
// Root cause: Qmax Cell 0 (0x9106) and stored FCC (0x929D) retain
|
||||
// factory 3000 mAh defaults. Overwrite both with designCapacity_mAh.
|
||||
Serial.printf("BQ27220: DE correct (%d mWh) — fixing Qmax + stored FCC\n", currentDE);
|
||||
|
||||
// --- Helper lambda for MAC data memory 2-byte write ---
|
||||
// Reads old value + checksum, computes differential checksum, writes new value.
|
||||
auto writeDM16 = [](uint16_t addr, uint16_t newVal) -> bool {
|
||||
// Select address
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(addr & 0xFF);
|
||||
Wire.write((addr >> 8) & 0xFF);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint16_t oldVal = (oldMSB << 8) | oldLSB;
|
||||
|
||||
if (oldVal == newVal) {
|
||||
Serial.printf("BQ27220: [0x%04X] already %d, skip\n", addr, newVal);
|
||||
return true; // already correct
|
||||
}
|
||||
|
||||
uint8_t newMSB = (newVal >> 8) & 0xFF;
|
||||
uint8_t newLSB = newVal & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: [0x%04X] %d -> %d\n", addr, oldVal, newVal);
|
||||
|
||||
// Write new value
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(addr & 0xFF);
|
||||
Wire.write((addr >> 8) & 0xFF);
|
||||
Wire.write(newMSB);
|
||||
Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
|
||||
// Write checksum
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60);
|
||||
Wire.write(newChk);
|
||||
Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Overwrite Qmax Cell 0 (IT Cfg) — this is what FCC is derived from
|
||||
writeDM16(0x9106, designCapacity_mAh);
|
||||
|
||||
// Overwrite stored FCC reference (Gas Gauging, 2 bytes before DC)
|
||||
writeDM16(0x929D, designCapacity_mAh);
|
||||
|
||||
// Exit with reinit to apply the new values
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Qmax + stored FCC updated, exited CFG_UPDATE");
|
||||
}
|
||||
} else {
|
||||
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE check");
|
||||
}
|
||||
|
||||
// Seal first, then issue RESET.
|
||||
// RESET forces the gauge to fully reinitialize its Impedance Track
|
||||
// algorithm and recalculate FCC from the current DC/DE values.
|
||||
bq27220_writeControl(0x0030); // SEAL
|
||||
delay(5);
|
||||
Serial.println("BQ27220: Issuing RESET to force FCC recalculation...");
|
||||
bq27220_writeControl(0x0041); // RESET
|
||||
delay(2000); // Full reset needs generous settle time
|
||||
|
||||
fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: FCC after RESET: %d mAh (target <= %d)\n", fcc, designCapacity_mAh);
|
||||
|
||||
if (fcc > designCapacity_mAh * 3 / 2) {
|
||||
// RESET didn't fix FCC — the gauge IT algorithm is stubbornly
|
||||
// retaining its learned value. This typically resolves after one
|
||||
// full charge/discharge cycle. Software clamp in
|
||||
// getFullChargeCapacity() ensures correct display regardless.
|
||||
Serial.printf("BQ27220: FCC still stale at %d — software clamp active\n", fcc);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.printf("BQ27220: Updating Design Capacity from %d to %d mAh\n", currentDC, designCapacity_mAh);
|
||||
|
||||
// Step 1: Unseal (default unseal keys)
|
||||
bq27220_writeControl(0x0414); delay(2);
|
||||
bq27220_writeControl(0x3672); delay(2);
|
||||
|
||||
// Step 2: Full Access
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
|
||||
// Step 3: Enter CFG_UPDATE
|
||||
bq27220_writeControl(0x0090);
|
||||
bool cfgReady = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
delay(20);
|
||||
uint16_t opStatus = bq27220_read16(BQ27220_REG_OP_STATUS);
|
||||
if (opStatus & 0x0400) { cfgReady = true; break; }
|
||||
}
|
||||
if (!cfgReady) {
|
||||
Serial.println("BQ27220: Timeout waiting for CFGUPDATE");
|
||||
bq27220_writeControl(0x0092);
|
||||
bq27220_writeControl(0x0030);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 4: Write Design Capacity at 0x929F
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0x9F); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dataLen = bq27220_read8(0x61);
|
||||
|
||||
uint8_t newMSB = (designCapacity_mAh >> 8) & 0xFF;
|
||||
uint8_t newLSB = designCapacity_mAh & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0x9F); Wire.write(0x92);
|
||||
Wire.write(newMSB); Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(newChk); Wire.write(dataLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
// Step 4a: Write Design Energy at 0x92A1
|
||||
{
|
||||
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
uint8_t deOldMSB = bq27220_read8(0x40);
|
||||
uint8_t deOldLSB = bq27220_read8(0x41);
|
||||
uint8_t deOldChk = bq27220_read8(0x60);
|
||||
uint8_t deLen = bq27220_read8(0x61);
|
||||
uint8_t deNewMSB = (designEnergy >> 8) & 0xFF;
|
||||
uint8_t deNewLSB = designEnergy & 0xFF;
|
||||
uint8_t deTemp = (255 - deOldChk - deOldMSB - deOldLSB);
|
||||
uint8_t deNewChk = 255 - ((deTemp + deNewMSB + deNewLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: Design Energy: old=%d new=%d mWh\n",
|
||||
(deOldMSB << 8) | deOldLSB, designEnergy);
|
||||
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.write(deNewMSB); Wire.write(deNewLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(deNewChk); Wire.write(deLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
}
|
||||
|
||||
// Step 5: Exit CFG_UPDATE with reinit
|
||||
bq27220_writeControl(0x0091);
|
||||
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
|
||||
delay(200);
|
||||
|
||||
// Step 6: Seal
|
||||
bq27220_writeControl(0x0030);
|
||||
delay(5);
|
||||
|
||||
// Step 7: Force RESET to reinitialize FCC from new DC/DE
|
||||
bq27220_writeControl(0x0041); // RESET
|
||||
delay(1000);
|
||||
|
||||
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
uint16_t newFCC = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Post-config DC=%d FCC=%d mAh\n", verifyDC, newFCC);
|
||||
|
||||
return verifyDC == designCapacity_mAh;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
};
|
||||
@@ -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 */
|
||||
@@ -0,0 +1,234 @@
|
||||
; ===========================================================================
|
||||
; LilyGo T5 S3 E-Paper Pro (H752-B / V2 hardware)
|
||||
; 4.7" parallel e-ink (960x540), GT911 touch, SX1262 LoRa, no keyboard
|
||||
; ===========================================================================
|
||||
;
|
||||
; Place t5s3-epaper-pro.json in boards/ directory.
|
||||
; Place variant files in variants/LilyGo_T5S3_EPaper_Pro/
|
||||
; Place FastEPDDisplay.h/.cpp in src/helpers/ui/
|
||||
;
|
||||
|
||||
[LilyGo_T5S3_EPaper_Pro]
|
||||
extends = esp32_base
|
||||
extra_scripts = post:merge_firmware.py
|
||||
board = t5s3-epaper-pro
|
||||
board_build.flash_mode = qio
|
||||
board_build.f_flash = 80000000L
|
||||
board_build.arduino.memory_type = qio_opi
|
||||
board_upload.flash_size = 16MB
|
||||
build_flags =
|
||||
${esp32_base.build_flags}
|
||||
-I variants/LilyGo_T5S3_EPaper_Pro
|
||||
-D LilyGo_T5S3_EPaper_Pro
|
||||
-D T5_S3_EPAPER_PRO_V2
|
||||
-D BOARD_HAS_PSRAM=1
|
||||
-D CORE_DEBUG_LEVEL=1
|
||||
-D FORMAT_SPIFFS_IF_FAILED=1
|
||||
-D FORMAT_LITTLEFS_IF_FAILED=1
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_DIO2_AS_RF_SWITCH
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=2.4f
|
||||
-D P_LORA_DIO_1=10
|
||||
-D P_LORA_NSS=46
|
||||
-D P_LORA_RESET=1
|
||||
-D P_LORA_BUSY=47
|
||||
-D P_LORA_SCLK=14
|
||||
-D P_LORA_MISO=21
|
||||
-D P_LORA_MOSI=13
|
||||
-D ENV_INCLUDE_AHTX0=0
|
||||
-D ENV_INCLUDE_BME280=0
|
||||
-D ENV_INCLUDE_BMP280=0
|
||||
-D ENV_INCLUDE_SHTC3=0
|
||||
-D ENV_INCLUDE_SHT4X=0
|
||||
-D ENV_INCLUDE_LPS22HB=0
|
||||
-D ENV_INCLUDE_INA3221=0
|
||||
-D ENV_INCLUDE_INA219=0
|
||||
-D ENV_INCLUDE_INA226=0
|
||||
-D ENV_INCLUDE_INA260=0
|
||||
-D ENV_INCLUDE_MLX90614=0
|
||||
-D ENV_INCLUDE_VL53L0X=0
|
||||
-D ENV_INCLUDE_BME680=0
|
||||
-D ENV_INCLUDE_BMP085=0
|
||||
-D HAS_BQ27220=1
|
||||
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
|
||||
-D PIN_USER_BTN=0
|
||||
-D SDCARD_USE_SPI1
|
||||
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/LilyGo_T5S3_EPaper_Pro>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
WebServer
|
||||
DNSServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; T5S3 standalone — touch UI (stub), verify display rendering
|
||||
; Uses FastEPD for parallel e-ink, Adafruit GFX for drawing
|
||||
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_t5s3_standalone]
|
||||
extends = LilyGo_T5S3_EPaper_Pro
|
||||
build_flags =
|
||||
${LilyGo_T5S3_EPaper_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D OFFLINE_QUEUE_SIZE=1
|
||||
-D CHANNEL_MSG_HISTORY_SIZE=800
|
||||
-D DISPLAY_CLASS=FastEPDDisplay
|
||||
-D USE_EINK
|
||||
-D MECK_CARDKB
|
||||
-D MECK_OTA_UPDATE=1
|
||||
; -D MECK_SERIF_FONT ; FreeSerif (Times New Roman-like)
|
||||
; ; Default (no flag): FreeSans (Arial-like)
|
||||
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/FastEPDDisplay.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_T5S3_EPaper_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
https://github.com/mverch67/FastEPD/archive/0df1bff329b6fc782e062f611758880762340647.zip
|
||||
https://github.com/lewisxhe/SensorLib/archive/refs/tags/v0.3.4.zip
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; T5S3 BLE companion — touch UI, BLE phone bridging
|
||||
; Connect via MeshCore iOS/Android app over Bluetooth
|
||||
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
|
||||
; Flash: pio run -e meck_t5s3_ble -t upload
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_t5s3_ble]
|
||||
extends = LilyGo_T5S3_EPaper_Pro
|
||||
build_flags =
|
||||
${LilyGo_T5S3_EPaper_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D DISPLAY_CLASS=FastEPDDisplay
|
||||
-D USE_EINK
|
||||
-D MECK_CARDKB
|
||||
-D MECK_OTA_UPDATE=1
|
||||
; -D MECK_SERIF_FONT
|
||||
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/FastEPDDisplay.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_T5S3_EPaper_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
https://github.com/mverch67/FastEPD/archive/0df1bff329b6fc782e062f611758880762340647.zip
|
||||
https://github.com/lewisxhe/SensorLib/archive/refs/tags/v0.3.4.zip
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; T5S3 WiFi companion — touch UI, WiFi phone bridging, web browser
|
||||
; Connect via MeshCore web app or meshcore.js over local network (TCP:5000)
|
||||
; MECK_WEB_READER: shares WiFi companion connection — no extra setup needed
|
||||
; Flash: pio run -e meck_t5s3_wifi -t upload
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_t5s3_wifi]
|
||||
extends = LilyGo_T5S3_EPaper_Pro
|
||||
build_flags =
|
||||
${LilyGo_T5S3_EPaper_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D MECK_WIFI_COMPANION=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D TCP_PORT=5000
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D DISPLAY_CLASS=FastEPDDisplay
|
||||
-D USE_EINK
|
||||
-D MECK_CARDKB
|
||||
; -D MECK_SERIF_FONT
|
||||
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/FastEPDDisplay.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_T5S3_EPaper_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
https://github.com/mverch67/FastEPD/archive/0df1bff329b6fc782e062f611758880762340647.zip
|
||||
https://github.com/lewisxhe/SensorLib/archive/refs/tags/v0.3.4.zip
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; T5S3 WiFi Remote Repeater — WiFi MQTT backhaul, remote management
|
||||
; Same MQTT protocol as T-Deck Pro remote repeater builds.
|
||||
; Uses FastEPD for parallel e-ink display.
|
||||
; Flash: pio run -e meck_wifi_repeater_t5s3 -t upload
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_wifi_repeater_t5s3]
|
||||
extends = LilyGo_T5S3_EPaper_Pro
|
||||
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/FastEPDDisplay.cpp>
|
||||
+<../examples/simple_repeater/*.cpp>
|
||||
build_flags =
|
||||
${LilyGo_T5S3_EPaper_Pro.build_flags}
|
||||
-D FIRMWARE_VERSION='"Meck T5S3 WiFi Rptr v0.1"'
|
||||
-D FIRMWARE_BUILD_DATE='"5 Apr 2026"'
|
||||
-D DISPLAY_CLASS=FastEPDDisplay
|
||||
-D USE_EINK
|
||||
-D MECK_WIFI_REMOTE
|
||||
-D MECK_REMOTE_REPEATER=1
|
||||
-D DISABLE_WIFI_OTA=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D HAS_SDCARD=1
|
||||
-D RADIOLIB_EXCLUDE_CC1101=1
|
||||
-D RADIOLIB_EXCLUDE_NRF24=1
|
||||
-D RADIOLIB_EXCLUDE_RF69=1
|
||||
-D RADIOLIB_EXCLUDE_SX1231=1
|
||||
-D RADIOLIB_EXCLUDE_SX1233=1
|
||||
-D RADIOLIB_EXCLUDE_SI443X=1
|
||||
-D RADIOLIB_EXCLUDE_RFM2X=1
|
||||
-D RADIOLIB_EXCLUDE_SX127X=1
|
||||
-D RADIOLIB_EXCLUDE_SX1272=1
|
||||
-D RADIOLIB_EXCLUDE_SX1278=1
|
||||
-D RADIOLIB_EXCLUDE_STM32WLX=1
|
||||
-D RADIOLIB_EXCLUDE_LR11X0=1
|
||||
-D RADIOLIB_EXCLUDE_LLCC68=1
|
||||
-D RADIOLIB_EXCLUDE_SX128X=1
|
||||
-D RADIOLIB_EXCLUDE_AFSK=1
|
||||
-D RADIOLIB_EXCLUDE_AX25=1
|
||||
-D RADIOLIB_EXCLUDE_HELLSCHREIBER=1
|
||||
-D RADIOLIB_EXCLUDE_MORSE=1
|
||||
-D RADIOLIB_EXCLUDE_RTTY=1
|
||||
-D RADIOLIB_EXCLUDE_SSTV=1
|
||||
-D RADIOLIB_EXCLUDE_APRS=1
|
||||
-D RADIOLIB_EXCLUDE_LORAWAN=1
|
||||
-D RADIOLIB_EXCLUDE_PAGER=1
|
||||
-D RADIOLIB_EXCLUDE_FSK4=1
|
||||
-D RADIOLIB_EXCLUDE_BELL=1
|
||||
lib_deps =
|
||||
${LilyGo_T5S3_EPaper_Pro.lib_deps}
|
||||
knolleary/PubSubClient@^2.8
|
||||
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
|
||||
lib_ignore =
|
||||
ESP32 BLE Arduino
|
||||
AsyncTCP
|
||||
RPAsyncTCP
|
||||
ESPAsyncWebServer
|
||||
AsyncElegantOTA
|
||||
ESP32-audioI2S
|
||||
esp32_codec2_arduino
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,189 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// LilyGo T5 S3 E-Paper Pro V2 (H752-01/H752-B) - Pin Definitions for Meck
|
||||
//
|
||||
// 4.7" parallel e-ink (ED047TC1, 960x540, 16-grey) — NO SPI display
|
||||
// GT911 capacitive touch (no physical keyboard)
|
||||
// SX1262 LoRa, BQ27220+BQ25896 battery, PCF85063 RTC, PCA9535 IO expander
|
||||
// =============================================================================
|
||||
|
||||
// Board identifier
|
||||
#define LilyGo_T5S3_EPaper_Pro 1
|
||||
#define T5_S3_EPAPER_PRO_V2 1
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// I2C Bus — shared by GT911, PCF85063, PCA9535, BQ25896, BQ27220, TPS65185
|
||||
// -----------------------------------------------------------------------------
|
||||
#define I2C_SDA 39
|
||||
#define I2C_SCL 40
|
||||
|
||||
// Aliases for ESP32Board base class compatibility
|
||||
#define PIN_BOARD_SDA I2C_SDA
|
||||
#define PIN_BOARD_SCL I2C_SCL
|
||||
|
||||
// I2C Device Addresses
|
||||
#define I2C_ADDR_GT911 0x5D // Touch controller
|
||||
#define I2C_ADDR_PCF85063 0x51 // RTC
|
||||
#define I2C_ADDR_PCA9535 0x20 // IO expander (e-ink power control)
|
||||
#define I2C_ADDR_BQ27220 0x55 // Fuel gauge
|
||||
#define I2C_ADDR_BQ25896 0x6B // Battery charger
|
||||
#define I2C_ADDR_TPS65185 0x68 // E-ink power driver
|
||||
#define CARDKB_I2C_ADDR 0x5F // M5Stack CardKB (external, via QWIIC)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SPI Bus — shared by LoRa and SD card
|
||||
// Different from T-Deck Pro! (T-Deck: 33/47/36, T5S3: 13/21/14)
|
||||
// -----------------------------------------------------------------------------
|
||||
#define BOARD_SPI_SCLK 14
|
||||
#define BOARD_SPI_MISO 21
|
||||
#define BOARD_SPI_MOSI 13
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// LoRa Radio (SX1262)
|
||||
// SPI bus shared with SD card, different chip selects
|
||||
// -----------------------------------------------------------------------------
|
||||
#define P_LORA_NSS 46
|
||||
#define P_LORA_DIO_1 10 // IRQ
|
||||
#define P_LORA_RESET 1
|
||||
#define P_LORA_BUSY 47
|
||||
#define P_LORA_SCLK BOARD_SPI_SCLK
|
||||
#define P_LORA_MISO BOARD_SPI_MISO
|
||||
#define P_LORA_MOSI BOARD_SPI_MOSI
|
||||
// Note: No P_LORA_EN on T5S3 — LoRa is always powered
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// E-Ink Display (ED047TC1 — 8-bit parallel, NOT SPI)
|
||||
// Driven by epdiy/FastEPD library via TPS65185 + PCA9535
|
||||
// GxEPD2 is NOT used on this board.
|
||||
// -----------------------------------------------------------------------------
|
||||
// Parallel data bus (directly wired to ESP32-S3 GPIOs)
|
||||
#define EP_D0 5
|
||||
#define EP_D1 6
|
||||
#define EP_D2 7
|
||||
#define EP_D3 15
|
||||
#define EP_D4 16
|
||||
#define EP_D5 17
|
||||
#define EP_D6 18
|
||||
#define EP_D7 8
|
||||
|
||||
// Control signals
|
||||
#define EP_CKV 48 // Clock vertical
|
||||
#define EP_STH 41 // Start horizontal
|
||||
#define EP_LEH 42 // Latch enable horizontal
|
||||
#define EP_STV 45 // Start vertical
|
||||
#define EP_CKH 4 // Clock horizontal (edge)
|
||||
|
||||
// E-ink power is managed by TPS65185 through PCA9535 IO expander:
|
||||
// PCA9535 IO10 -> EP_OE (output enable, source driver)
|
||||
// PCA9535 IO11 -> EP_MODE (output mode, gate driver)
|
||||
// PCA9535 IO13 -> TPS_PWRUP
|
||||
// PCA9535 IO14 -> VCOM_CTRL
|
||||
// PCA9535 IO15 -> TPS_WAKEUP
|
||||
// PCA9535 IO16 -> TPS_PWR_GOOD (input)
|
||||
// PCA9535 IO17 -> TPS_INT (input)
|
||||
|
||||
// Display dimensions — native resolution of ED047TC1
|
||||
#define EPD_WIDTH 960
|
||||
#define EPD_HEIGHT 540
|
||||
|
||||
// Backlight (warm-tone front-light — functional on V2!)
|
||||
#define BOARD_BL_EN 11
|
||||
|
||||
// We do NOT define DISPLAY_CLASS or EINK_DISPLAY_MODEL here.
|
||||
// The parallel display uses FastEPD, not GxEPD2.
|
||||
// DISPLAY_CLASS will be defined in platformio.ini as FastEPDDisplay
|
||||
// for builds that include display support.
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Touch Controller (GT911)
|
||||
// No physical keyboard on this board — touch-only input
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_TOUCHSCREEN 1
|
||||
#define GT911_PIN_INT 3
|
||||
#define GT911_PIN_RST 9
|
||||
#define GT911_PIN_SDA I2C_SDA
|
||||
#define GT911_PIN_SCL I2C_SCL
|
||||
|
||||
// No keyboard
|
||||
// #define HAS_PHYSICAL_KEYBOARD 0
|
||||
|
||||
// Compatibility: main.cpp references CST328 touch (T-Deck Pro).
|
||||
// Map to GT911 equivalents so shared code compiles.
|
||||
// The actual touch init for T5S3 will use GT911 in Phase 2.
|
||||
#define CST328_PIN_INT GT911_PIN_INT
|
||||
#define CST328_PIN_RST GT911_PIN_RST
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SD Card — shares SPI bus with LoRa
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_SDCARD
|
||||
#define SDCARD_USE_SPI1
|
||||
#define SDCARD_CS 12
|
||||
#define SPI_CS SDCARD_CS
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GPS — Not present on H752-B (non-GPS variant)
|
||||
// If a GPS model is used (H752-01/H752-02), define HAS_GPS=1
|
||||
// and uncomment the GPS pins below.
|
||||
// -----------------------------------------------------------------------------
|
||||
// #define HAS_GPS 1
|
||||
// #define GPS_BAUDRATE 38400
|
||||
// #define GPS_RX_PIN 44
|
||||
// #define GPS_TX_PIN 43
|
||||
|
||||
// Fallback for code that references GPS_BAUDRATE without HAS_GPS guard
|
||||
// (e.g. MyMesh.cpp CLI rescue command)
|
||||
#ifndef GPS_BAUDRATE
|
||||
#define GPS_BAUDRATE 9600
|
||||
#endif
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// RTC — PCF85063 (proper hardware RTC, battery-backed!)
|
||||
// This is a significant upgrade over T-Deck Pro which has no RTC.
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_PCF85063_RTC 1
|
||||
#define PCF85063_I2C_ADDR 0x51
|
||||
#define PCF85063_INT_PIN 2
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PCA9535 IO Expander
|
||||
// Controls e-ink power sequencing and has a user button
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_PCA9535 1
|
||||
#define PCA9535_I2C_ADDR 0x20
|
||||
#define PCA9535_INT_PIN 38
|
||||
|
||||
// PCA9535 pin assignments (directly from LilyGo schematic):
|
||||
// Port 0 (IO0x): IO00-IO07 — mostly unused/reserved
|
||||
// Port 1 (IO1x):
|
||||
#define PCA9535_EP_OE 0 // IO10 — EP output enable (source driver)
|
||||
#define PCA9535_EP_MODE 1 // IO11 — EP mode (gate driver)
|
||||
#define PCA9535_BUTTON 2 // IO12 — User button via IO expander
|
||||
#define PCA9535_TPS_PWRUP 3 // IO13 — TPS65185 power up
|
||||
#define PCA9535_VCOM_CTRL 4 // IO14 — VCOM control
|
||||
#define PCA9535_TPS_WAKEUP 5 // IO15 — TPS65185 wakeup
|
||||
#define PCA9535_TPS_PWRGOOD 6 // IO16 — TPS65185 power good (input)
|
||||
#define PCA9535_TPS_INT 7 // IO17 — TPS65185 interrupt (input)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Buttons & Controls
|
||||
// -----------------------------------------------------------------------------
|
||||
#define BUTTON_PIN 0 // Boot button (GPIO0)
|
||||
#define PIN_USER_BTN 0
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Power Management
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_BQ27220 1
|
||||
#define BQ27220_I2C_ADDR 0x55
|
||||
|
||||
// T5S3 E-Paper Pro battery (1500 mAh — larger than T-Deck Pro's 1400 mAh)
|
||||
#ifndef BQ27220_DESIGN_CAPACITY_MAH
|
||||
#define BQ27220_DESIGN_CAPACITY_MAH 1500
|
||||
#endif
|
||||
|
||||
#define AUTO_SHUTDOWN_MILLIVOLTS 2800
|
||||
|
||||
// No explicit peripheral power pin on T5S3 (unlike T-Deck Pro's PIN_PERF_POWERON)
|
||||
// Peripherals are always powered when the board is on.
|
||||
@@ -1,35 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "TDeckBoard.h"
|
||||
|
||||
uint32_t deviceOnline = 0x00;
|
||||
|
||||
void TDeckBoard::begin() {
|
||||
|
||||
ESP32Board::begin();
|
||||
|
||||
// Enable peripheral power
|
||||
pinMode(PIN_PERF_POWERON, OUTPUT);
|
||||
digitalWrite(PIN_PERF_POWERON, HIGH);
|
||||
|
||||
// Configure user button
|
||||
pinMode(PIN_USER_BTN, INPUT);
|
||||
|
||||
// Configure LoRa Pins
|
||||
pinMode(P_LORA_MISO, INPUT_PULLUP);
|
||||
// pinMode(P_LORA_DIO_1, INPUT_PULLUP);
|
||||
|
||||
#ifdef P_LORA_TX_LED
|
||||
digitalWrite(P_LORA_TX_LED, HIGH); // inverted pin for SX1276 - HIGH for off
|
||||
#endif
|
||||
|
||||
esp_reset_reason_t reason = esp_reset_reason();
|
||||
if (reason == ESP_RST_DEEPSLEEP) {
|
||||
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
|
||||
if (wakeup_source & (1 << P_LORA_DIO_1)) {
|
||||
startup_reason = BD_STARTUP_RX_PACKET; // received a LoRa packet (while in deep sleep)
|
||||
}
|
||||
|
||||
rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS);
|
||||
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Wire.h>
|
||||
#include <Arduino.h>
|
||||
#include "helpers/ESP32Board.h"
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
#define PIN_VBAT_READ 4
|
||||
#define BATTERY_SAMPLES 8
|
||||
#define ADC_MULTIPLIER (2.0f * 3.3f * 1000)
|
||||
|
||||
class TDeckBoard : public ESP32Board {
|
||||
public:
|
||||
void begin();
|
||||
|
||||
#ifdef P_LORA_TX_LED
|
||||
void onBeforeTransmit() override{
|
||||
digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on - invert pin for SX1276
|
||||
}
|
||||
|
||||
void onAfterTransmit() override{
|
||||
digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off - invert pin for SX1276
|
||||
}
|
||||
#endif
|
||||
|
||||
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
|
||||
// Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep
|
||||
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
|
||||
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
|
||||
|
||||
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
|
||||
|
||||
if (pin_wake_btn < 0) {
|
||||
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet
|
||||
} else {
|
||||
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn
|
||||
}
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000);
|
||||
}
|
||||
|
||||
// Finally set ESP32 into sleep
|
||||
esp_deep_sleep_start(); // CPU halts here and never returns!
|
||||
}
|
||||
|
||||
uint16_t getBattMilliVolts() {
|
||||
#if defined(PIN_VBAT_READ) && defined(ADC_MULTIPLIER)
|
||||
analogReadResolution(12);
|
||||
|
||||
uint32_t raw = 0;
|
||||
for (int i = 0; i < BATTERY_SAMPLES; i++) {
|
||||
raw += analogRead(PIN_VBAT_READ);
|
||||
}
|
||||
|
||||
raw = raw / BATTERY_SAMPLES;
|
||||
return (ADC_MULTIPLIER * raw) / 4096;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
const char* getManufacturerName() const{
|
||||
return "LilyGo T-Deck";
|
||||
}
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
[LilyGo_TDeck]
|
||||
extends = esp32_base
|
||||
board = t-deck
|
||||
build_flags =
|
||||
${esp32_base.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/lilygo_tdeck
|
||||
-D LILYGO_TDECK
|
||||
-D BOARD_HAS_PSRAM=1
|
||||
-D CORE_DEBUG_LEVEL=1
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
-D PIN_USER_BTN=0 ; Trackball button
|
||||
-D PIN_PERF_POWERON=10 ; Peripheral power pin
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=false
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=1.8f
|
||||
-D P_LORA_DIO_1=45 ; LORA IRQ pin
|
||||
-D ENV_INCLUDE_GPS=1
|
||||
-D ENV_INCLUDE_AHTX0=0
|
||||
-D ENV_INCLUDE_BME280=0
|
||||
-D ENV_INCLUDE_BMP280=0
|
||||
-D ENV_INCLUDE_SHTC3=0
|
||||
-D ENV_INCLUDE_SHT4X=0
|
||||
-D ENV_INCLUDE_LPS22HB=0
|
||||
-D ENV_INCLUDE_INA3221=0
|
||||
-D ENV_INCLUDE_INA219=0
|
||||
-D ENV_INCLUDE_INA226=0
|
||||
-D ENV_INCLUDE_INA260=0
|
||||
-D ENV_INCLUDE_MLX90614=0
|
||||
-D ENV_INCLUDE_VL53L0X=0
|
||||
-D ENV_INCLUDE_BME680=0
|
||||
-D ENV_INCLUDE_BMP085=0
|
||||
-D P_LORA_NSS=9 ; LORA SS pin
|
||||
-D P_LORA_RESET=17 ; LORA RST pin
|
||||
-D P_LORA_BUSY=13 ; LORA Busy pin
|
||||
-D P_LORA_SCLK=40 ; LORA SCLK pin
|
||||
-D P_LORA_MISO=38 ; LORA MISO pin
|
||||
-D P_LORA_MOSI=41 ; LORA MOSI pin
|
||||
-D DISPLAY_CLASS=ST7789LCDDisplay
|
||||
-D DISPLAY_SCALE_X=2.5
|
||||
-D DISPLAY_SCALE_Y=3.75
|
||||
-D PIN_TFT_RST=-1
|
||||
-D PIN_TFT_VDD_CTL=-1
|
||||
-D PIN_TFT_LEDA_CTL=42
|
||||
-D PIN_TFT_CS=12
|
||||
-D PIN_TFT_DC=11
|
||||
-D PIN_TFT_SCL=40
|
||||
-D PIN_TFT_SDA=41
|
||||
-D PIN_GPS_RX=43
|
||||
-D PIN_GPS_TX=44
|
||||
-D GPS_BAUD_RATE=38400
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/lilygo_tdeck>
|
||||
+<helpers/sensors/*.cpp>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0
|
||||
|
||||
[env:LilyGo_TDeck_companion_radio_usb]
|
||||
extends = LilyGo_TDeck
|
||||
build_flags =
|
||||
${LilyGo_TDeck.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
build_src_filter = ${LilyGo_TDeck.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/ST7789LCDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:LilyGo_TDeck_companion_radio_ble]
|
||||
extends = LilyGo_TDeck
|
||||
build_flags =
|
||||
${LilyGo_TDeck.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
build_src_filter = ${LilyGo_TDeck.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/ST7789LCDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:LilyGo_TDeck_repeater]
|
||||
extends = LilyGo_TDeck
|
||||
build_flags =
|
||||
${LilyGo_TDeck.build_flags}
|
||||
-D ADVERT_NAME='"TDeck Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
build_src_filter = ${LilyGo_TDeck.build_src_filter}
|
||||
+<../examples/simple_repeater>
|
||||
+<helpers/ui/ST7789LCDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
@@ -8,9 +8,10 @@
|
||||
// 240 MHz ~70-80 mA
|
||||
// 160 MHz ~50-60 mA
|
||||
// 80 MHz ~30-40 mA
|
||||
// 40 MHz ~15-20 mA (low-power / lock screen mode)
|
||||
//
|
||||
// SPI peripherals and UART use their own clock dividers from the APB clock,
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz.
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz.
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
@@ -22,23 +23,36 @@
|
||||
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_LOW_POWER
|
||||
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
|
||||
#endif
|
||||
|
||||
#ifndef CPU_BOOST_TIMEOUT_MS
|
||||
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
|
||||
#endif
|
||||
|
||||
class CPUPowerManager {
|
||||
public:
|
||||
CPUPowerManager() : _boosted(false), _boost_started(0) {}
|
||||
CPUPowerManager() : _boosted(false), _lowPower(false), _boost_started(0) {}
|
||||
|
||||
void begin() {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
_lowPower = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
|
||||
setIdle();
|
||||
// Return to low-power if locked, otherwise normal idle
|
||||
if (_lowPower) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: boost expired, returning to low-power %d MHz", CPU_FREQ_LOW_POWER);
|
||||
} else {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
_boosted = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +71,42 @@ public:
|
||||
_boosted = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
if (_lowPower) {
|
||||
_lowPower = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Low-power mode — drops CPU to 40 MHz for lock screen standby.
|
||||
// If currently boosted, the boost timeout will return to 40 MHz
|
||||
// instead of 80 MHz.
|
||||
void setLowPower() {
|
||||
_lowPower = true;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: low-power at %d MHz", CPU_FREQ_LOW_POWER);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to low-power instead of idle
|
||||
}
|
||||
|
||||
// Exit low-power mode — returns to normal idle (80 MHz).
|
||||
// If currently boosted, the boost timeout will return to idle
|
||||
// instead of low-power.
|
||||
void clearLowPower() {
|
||||
_lowPower = false;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz (low-power cleared)", CPU_FREQ_IDLE);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to idle as normal
|
||||
}
|
||||
|
||||
bool isBoosted() const { return _boosted; }
|
||||
bool isLowPower() const { return _lowPower; }
|
||||
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
|
||||
|
||||
private:
|
||||
bool _boosted;
|
||||
bool _lowPower;
|
||||
unsigned long _boost_started;
|
||||
};
|
||||
|
||||
|
||||
@@ -199,11 +199,16 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
// Design Capacity correct, but check if Full Charge Capacity is sane.
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity already correct, FCC=%d mAh\n", fcc);
|
||||
if (fcc >= designCapacity_mAh * 3 / 2) {
|
||||
// Check if FCC is outside an acceptable band around design capacity.
|
||||
// Catches both: FCC too high (stale factory 3000mAh) and FCC too low
|
||||
// (gauge learned on a smaller battery, e.g. 1400mAh on a 2500mAh pack).
|
||||
uint16_t fccLo = (designCapacity_mAh > 100) ? designCapacity_mAh - 100 : 0;
|
||||
uint16_t fccHi = designCapacity_mAh + 100;
|
||||
if (fcc < fccLo || fcc > fccHi) {
|
||||
// FCC is >=150% of design — stale from factory defaults (typically 3000 mAh).
|
||||
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
|
||||
Serial.printf("BQ27220: FCC %d >> DC %d, checking Design Energy (target %d mWh)\n",
|
||||
fcc, designCapacity_mAh, designEnergy);
|
||||
Serial.printf("BQ27220: FCC %d outside target band [%d..%d], checking Design Energy (target %d mWh)\n",
|
||||
fcc, fccLo, fccHi, designEnergy);
|
||||
|
||||
// Unseal to read data memory and issue RESET
|
||||
bq27220_writeControl(0x0414); delay(2);
|
||||
@@ -344,7 +349,7 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: FCC after RESET: %d mAh (target <= %d)\n", fcc, designCapacity_mAh);
|
||||
|
||||
if (fcc > designCapacity_mAh * 3 / 2) {
|
||||
if (fcc > designCapacity_mAh) {
|
||||
// RESET didn't fix FCC — the gauge IT algorithm is stubbornly
|
||||
// retaining its learned value. This typically resolves after one
|
||||
// full charge/discharge cycle. Software clamp in
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
#define KB_KEY_BACKSPACE '\b'
|
||||
#define KB_KEY_ENTER '\r'
|
||||
#define KB_KEY_SPACE ' '
|
||||
#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker)
|
||||
#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker)
|
||||
#define KB_KEY_MIC 0x02 // Mic key press (PTT start / voice screen open)
|
||||
#define KB_KEY_MIC_RELEASE 0x03 // Mic key release (PTT stop)
|
||||
|
||||
class TCA8418Keyboard {
|
||||
private:
|
||||
@@ -34,7 +36,10 @@ private:
|
||||
bool _shiftUsedWhileHeld; // Was shift consumed by any key while held
|
||||
bool _altActive; // Sticky alt (one-shot)
|
||||
bool _symActive; // Sticky sym (one-shot)
|
||||
bool _micHeld; // Mic key physically held down (for PTT release detection)
|
||||
unsigned long _lastShiftTime; // For Shift+key combos
|
||||
bool _enterHeld; // Enter key physically held down
|
||||
unsigned long _enterPressTime; // millis() when Enter was pressed
|
||||
|
||||
uint8_t readReg(uint8_t reg) {
|
||||
_wire->beginTransmission(_addr);
|
||||
@@ -151,7 +156,8 @@ private:
|
||||
public:
|
||||
TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire)
|
||||
: _addr(addr), _wire(wire), _initialized(false),
|
||||
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
|
||||
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _micHeld(false), _lastShiftTime(0),
|
||||
_enterHeld(false), _enterPressTime(0) {}
|
||||
|
||||
bool begin() {
|
||||
// Check if device responds
|
||||
@@ -161,24 +167,47 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Configure keyboard matrix (8 rows x 10 cols)
|
||||
// --- Warm-reboot safe init sequence ---
|
||||
// The TCA8418 stays powered across ESP32 resets (no dedicated RST pin),
|
||||
// so the scanner may still be active from the previous session.
|
||||
// We must disable it before reconfiguring the matrix.
|
||||
|
||||
// 1. Disable scanner — stop all scanning before touching config
|
||||
writeReg(TCA8418_REG_CFG, 0x00);
|
||||
|
||||
// 2. Drain any stale events from the previous session
|
||||
for (int i = 0; i < 16; i++) {
|
||||
if ((readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) == 0) break;
|
||||
readReg(TCA8418_REG_KEY_EVENT_A);
|
||||
}
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F); // Clear all interrupt flags
|
||||
|
||||
// 3. Explicitly clear GPI event masks (prevent phantom GPI events)
|
||||
writeReg(TCA8418_REG_GPI_EM1, 0x00);
|
||||
writeReg(TCA8418_REG_GPI_EM2, 0x00);
|
||||
writeReg(TCA8418_REG_GPI_EM3, 0x00);
|
||||
|
||||
// 4. Configure keyboard matrix (8 rows x 10 cols)
|
||||
writeReg(TCA8418_REG_KP_GPIO1, 0xFF); // Rows 0-7 as keypad
|
||||
writeReg(TCA8418_REG_KP_GPIO2, 0xFF); // Cols 0-7 as keypad
|
||||
writeReg(TCA8418_REG_KP_GPIO3, 0x03); // Cols 8-9 as keypad
|
||||
|
||||
// Enable keypad with FIFO overflow detection
|
||||
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
|
||||
|
||||
// Set debounce
|
||||
// 5. Set debounce
|
||||
writeReg(TCA8418_REG_DEBOUNCE, 0x03);
|
||||
|
||||
// Clear any pending interrupts
|
||||
// 6. Final pre-enable cleanup
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F);
|
||||
|
||||
// Flush the FIFO
|
||||
while (readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) {
|
||||
// 7. Enable scanner — matrix config is stable, safe to start scanning
|
||||
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
|
||||
|
||||
// 8. Let scanner stabilise, then flush any spurious first-scan events
|
||||
delay(5);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
if ((readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) == 0) break;
|
||||
readReg(TCA8418_REG_KEY_EVENT_A);
|
||||
}
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F);
|
||||
|
||||
_initialized = true;
|
||||
Serial.println("TCA8418: Keyboard initialized OK");
|
||||
@@ -219,7 +248,22 @@ public:
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Track mic key release — return KB_KEY_MIC_RELEASE for PTT stop
|
||||
if (!pressed && keyCode == 34) {
|
||||
if (_micHeld) {
|
||||
_micHeld = false;
|
||||
Serial.println("KB: Mic released -> KB_KEY_MIC_RELEASE");
|
||||
return KB_KEY_MIC_RELEASE;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Only act on key press, not release
|
||||
// (Enter release tracked for long-press detection)
|
||||
if (!pressed && keyCode == 21) {
|
||||
_enterHeld = false;
|
||||
return 0;
|
||||
}
|
||||
if (!pressed || keyCode == 0) {
|
||||
return 0;
|
||||
}
|
||||
@@ -243,6 +287,13 @@ public:
|
||||
Serial.println("KB: Sym activated");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Track Enter press for long-press detection
|
||||
if (keyCode == 21) {
|
||||
_enterHeld = true;
|
||||
_enterPressTime = millis();
|
||||
// Fall through to normal processing — '\r' is returned below
|
||||
}
|
||||
|
||||
// Handle dedicated $ key (key code 22, next to M)
|
||||
// Bare press = emoji picker, Sym+$ = literal '$'
|
||||
@@ -256,12 +307,17 @@ public:
|
||||
return KB_KEY_EMOJI;
|
||||
}
|
||||
|
||||
// Handle Mic key - always produces '0' (silk-screened on key)
|
||||
// Sym+Mic also produces '0' (consumes sym so it doesn't leak)
|
||||
// Handle Mic key — bare press returns KB_KEY_MIC for PTT / voice screen
|
||||
// Sym+Mic produces '0' (silk-screened on key) for text input
|
||||
if (keyCode == 34) {
|
||||
_symActive = false;
|
||||
Serial.println("KB: Mic -> '0'");
|
||||
return '0';
|
||||
if (_symActive) {
|
||||
_symActive = false;
|
||||
Serial.println("KB: Sym+Mic -> '0'");
|
||||
return '0';
|
||||
}
|
||||
_micHeld = true;
|
||||
Serial.println("KB: Mic -> KB_KEY_MIC");
|
||||
return KB_KEY_MIC;
|
||||
}
|
||||
|
||||
// Get the character
|
||||
@@ -315,6 +371,7 @@ public:
|
||||
}
|
||||
|
||||
bool isReady() const { return _initialized; }
|
||||
bool isMicHeld() const { return _micHeld; }
|
||||
|
||||
// Check if shift was pressed within the last N milliseconds
|
||||
bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const {
|
||||
@@ -326,4 +383,10 @@ public:
|
||||
bool wasShiftConsumed() const {
|
||||
return _shiftConsumed;
|
||||
}
|
||||
|
||||
// Enter long-press detection
|
||||
bool isEnterHeld() const { return _enterHeld; }
|
||||
unsigned long enterHeldMs() const {
|
||||
return _enterHeld ? (millis() - _enterPressTime) : 0;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
[LilyGo_TDeck_Pro]
|
||||
extends = esp32_base
|
||||
extra_scripts = post:merge_firmware.py
|
||||
board = t-deck_pro
|
||||
board_build.flash_mode = qio
|
||||
board_build.f_flash = 80000000L
|
||||
@@ -10,6 +11,7 @@ build_flags =
|
||||
${sensor_base.build_flags}
|
||||
-I variants/LilyGo_TDeck_Pro
|
||||
-D LilyGo_TDeck_Pro
|
||||
-D HAS_GPS=1
|
||||
-D BOARD_HAS_PSRAM=1
|
||||
-D CORE_DEBUG_LEVEL=1
|
||||
-D FORMAT_SPIFFS_IF_FAILED=1
|
||||
@@ -81,6 +83,8 @@ build_flags =
|
||||
-D PIN_DISPLAY_MOSI=33
|
||||
-D PIN_DISPLAY_BL=45
|
||||
-D PIN_USER_BTN=0
|
||||
-D HAS_TOUCHSCREEN=1
|
||||
-D CST328_PIN_INT=12
|
||||
-D CST328_PIN_RST=38
|
||||
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
@@ -92,24 +96,30 @@ lib_deps =
|
||||
zinggjm/GxEPD2@^1.5.9
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
bitbank2/PNGdec@^1.0.1
|
||||
WebServer
|
||||
DNSServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Meck unified builds — one codebase, six variants via build flags
|
||||
; ---------------------------------------------------------------------------
|
||||
|
||||
; Audio + BLE companion (audio-player hardware with BLE phone bridging)
|
||||
; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510)
|
||||
; MAX_CONTACTS=2000 — protocol v11+ sends true capacity in extended DEVICE_INFO field.
|
||||
; Older apps see 510 (sentinel 0xFF in legacy byte) and still work correctly.
|
||||
; Contact + sort arrays allocated in PSRAM via BaseChatMesh::initContacts().
|
||||
[env:meck_audio_ble]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=500
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -121,6 +131,10 @@ lib_deps =
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
https://github.com/sh123/esp32_codec2_arduino.git
|
||||
lib_ignore =
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
|
||||
; Audio + WiFi companion (audio-player hardware with WiFi app bridging)
|
||||
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
|
||||
@@ -134,7 +148,7 @@ extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D MECK_WIFI_COMPANION=1
|
||||
-D TCP_PORT=5000
|
||||
@@ -142,9 +156,11 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.9WiFi"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.7.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
@@ -154,21 +170,29 @@ lib_deps =
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
https://github.com/sh123/esp32_codec2_arduino.git
|
||||
lib_ignore =
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
ESP32 BLE Arduino
|
||||
|
||||
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
|
||||
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
|
||||
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
|
||||
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
|
||||
[env:meck_audio_standalone]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D OFFLINE_QUEUE_SIZE=1
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_OTA_UPDATE=1
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
@@ -178,21 +202,29 @@ lib_deps =
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
https://github.com/sh123/esp32_codec2_arduino.git
|
||||
lib_ignore =
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
ESP32 BLE Arduino
|
||||
|
||||
; 4G + BLE companion (4G modem hardware, no audio — GPIO conflict with PCM5102A)
|
||||
; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510)
|
||||
; MAX_CONTACTS=2000 — protocol v11+ sends true capacity in extended DEVICE_INFO field.
|
||||
; Older apps see 510 (sentinel 0xFF in legacy byte) and still work correctly.
|
||||
; Contact + sort arrays allocated in PSRAM via BaseChatMesh::initContacts().
|
||||
[env:meck_4g_ble]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=500
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.94G"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.7.4G"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -202,6 +234,11 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
lib_ignore =
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
ESP32-audioI2S
|
||||
esp32_codec2_arduinov
|
||||
|
||||
; 4G + WiFi companion (4G modem hardware with WiFi app bridging, no audio)
|
||||
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
|
||||
@@ -214,7 +251,7 @@ extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D MECK_WIFI_COMPANION=1
|
||||
-D TCP_PORT=5000
|
||||
@@ -222,9 +259,11 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.94G.WiFi"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.7.4G.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
@@ -232,6 +271,12 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
lib_ignore =
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
ESP32-audioI2S
|
||||
esp32_codec2_arduino
|
||||
ESP32 BLE Arduino
|
||||
|
||||
; 4G standalone (4G modem hardware, no BLE — maximum battery + cellular features)
|
||||
; No BLE_PIN_CODE: BLE never initializes, saving ~30KB heap + radio power.
|
||||
@@ -243,18 +288,146 @@ extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D OFFLINE_QUEUE_SIZE=1
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.94G.SA"'
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.7.4G.SA"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
lib_ignore =
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
ESP32-audioI2S
|
||||
esp32_codec2_arduino
|
||||
ESP32 BLE Arduino
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Remote Repeater (T-Deck Pro 4G, cellular MQTT remote management)
|
||||
;
|
||||
; MeshCore repeater firmware + A7682E cellular MQTT for remote admin.
|
||||
; No BLE, no SMS/calls, no companion protocol. All management via MQTT
|
||||
; or USB serial CLI.
|
||||
;
|
||||
; SD card config required: /remote/mqtt.cfg (broker, port, user, pass)
|
||||
; Optional: /remote/apn.cfg (APN override)
|
||||
;
|
||||
; Add this block to the bottom of platformio.ini
|
||||
; Flash with: pio run -e meck_remote_repeater
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_remote_repeater]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/simple_repeater
|
||||
-D ADMIN_PASSWORD='"admin"'
|
||||
-D HAS_4G_MODEM=1
|
||||
-D DISABLE_WIFI_OTA=1
|
||||
-D MECK_REMOTE_REPEATER=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D FIRMWARE_VERSION='"Meck RemRptr v0.3"'
|
||||
-D FIRMWARE_BUILD_DATE='"5 April 2026"'
|
||||
-D RADIOLIB_EXCLUDE_CC1101=1
|
||||
-D RADIOLIB_EXCLUDE_NRF24=1
|
||||
-D RADIOLIB_EXCLUDE_RF69=1
|
||||
-D RADIOLIB_EXCLUDE_SX1231=1
|
||||
-D RADIOLIB_EXCLUDE_SX1233=1
|
||||
-D RADIOLIB_EXCLUDE_SI443X=1
|
||||
-D RADIOLIB_EXCLUDE_RFM2X=1
|
||||
-D RADIOLIB_EXCLUDE_SX127X=1
|
||||
-D RADIOLIB_EXCLUDE_SX1272=1
|
||||
-D RADIOLIB_EXCLUDE_SX1278=1
|
||||
-D RADIOLIB_EXCLUDE_STM32WLX=1
|
||||
-D RADIOLIB_EXCLUDE_LR11X0=1
|
||||
-D RADIOLIB_EXCLUDE_LLCC68=1
|
||||
-D RADIOLIB_EXCLUDE_SX128X=1
|
||||
-D RADIOLIB_EXCLUDE_AFSK=1
|
||||
-D RADIOLIB_EXCLUDE_AX25=1
|
||||
-D RADIOLIB_EXCLUDE_HELLSCHREIBER=1
|
||||
-D RADIOLIB_EXCLUDE_MORSE=1
|
||||
-D RADIOLIB_EXCLUDE_RTTY=1
|
||||
-D RADIOLIB_EXCLUDE_SSTV=1
|
||||
-D RADIOLIB_EXCLUDE_APRS=1
|
||||
-D RADIOLIB_EXCLUDE_LORAWAN=1
|
||||
-D RADIOLIB_EXCLUDE_PAGER=1
|
||||
-D RADIOLIB_EXCLUDE_FSK4=1
|
||||
-D RADIOLIB_EXCLUDE_BELL=1
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
+<../examples/simple_repeater/*.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
lib_ignore =
|
||||
ESP32 BLE Arduino
|
||||
NimBLE-Arduino
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
AsyncElegantOTA
|
||||
ESP32-audioI2S
|
||||
esp32_codec2_arduino
|
||||
|
||||
[env:meck_wifi_repeater]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
+<../examples/simple_repeater/*.cpp>
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-D FIRMWARE_VERSION='"Meck WiFi Rptr v0.3"'
|
||||
-D FIRMWARE_BUILD_DATE='"5 April 2026"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MECK_WIFI_REMOTE
|
||||
-D MECK_REMOTE_REPEATER=1
|
||||
-D DISABLE_WIFI_OTA=1
|
||||
-D RADIOLIB_EXCLUDE_CC1101=1
|
||||
-D RADIOLIB_EXCLUDE_NRF24=1
|
||||
-D RADIOLIB_EXCLUDE_RF69=1
|
||||
-D RADIOLIB_EXCLUDE_SX1231=1
|
||||
-D RADIOLIB_EXCLUDE_SX1233=1
|
||||
-D RADIOLIB_EXCLUDE_SI443X=1
|
||||
-D RADIOLIB_EXCLUDE_RFM2X=1
|
||||
-D RADIOLIB_EXCLUDE_SX127X=1
|
||||
-D RADIOLIB_EXCLUDE_SX1272=1
|
||||
-D RADIOLIB_EXCLUDE_SX1278=1
|
||||
-D RADIOLIB_EXCLUDE_STM32WLX=1
|
||||
-D RADIOLIB_EXCLUDE_LR11X0=1
|
||||
-D RADIOLIB_EXCLUDE_LLCC68=1
|
||||
-D RADIOLIB_EXCLUDE_SX128X=1
|
||||
-D RADIOLIB_EXCLUDE_AFSK=1
|
||||
-D RADIOLIB_EXCLUDE_AX25=1
|
||||
-D RADIOLIB_EXCLUDE_HELLSCHREIBER=1
|
||||
-D RADIOLIB_EXCLUDE_MORSE=1
|
||||
-D RADIOLIB_EXCLUDE_RTTY=1
|
||||
-D RADIOLIB_EXCLUDE_SSTV=1
|
||||
-D RADIOLIB_EXCLUDE_APRS=1
|
||||
-D RADIOLIB_EXCLUDE_LORAWAN=1
|
||||
-D RADIOLIB_EXCLUDE_PAGER=1
|
||||
-D RADIOLIB_EXCLUDE_FSK4=1
|
||||
-D RADIOLIB_EXCLUDE_BELL=1
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
knolleary/PubSubClient@^2.8
|
||||
lib_ignore =
|
||||
ESP32 BLE Arduino
|
||||
AsyncTCP
|
||||
RPAsyncTCP
|
||||
ESPAsyncWebServer
|
||||
AsyncElegantOTA
|
||||
ESP32-audioI2S
|
||||
esp32_codec2_arduino
|
||||
@@ -49,19 +49,11 @@ bool radio_init() {
|
||||
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - SPI initialized, calling radio.std_init()...");
|
||||
bool result = radio.std_init(&loraSpi);
|
||||
if (result) {
|
||||
radio.setPreambleLength(32);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("radio_init() - radio.std_init() returned: %s", result ? "SUCCESS" : "FAILED");
|
||||
return result;
|
||||
#else
|
||||
MESH_DEBUG_PRINTLN("radio_init() - calling radio.std_init() without custom SPI...");
|
||||
bool result = radio.std_init();
|
||||
if (result) {
|
||||
radio.setPreambleLength(32);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
|
||||
}
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
@@ -75,6 +67,14 @@ void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setSpreadingFactor(sf);
|
||||
radio.setBandwidth(bw);
|
||||
radio.setCodingRate(cr);
|
||||
|
||||
// Longer preamble for low SF improves reliability — each symbol is shorter
|
||||
// at low SF, so more symbols are needed for reliable detection.
|
||||
// SF <= 8 gets 32 symbols (~65ms at SF7/62.5kHz); SF >= 9 keeps 16 (already ~131ms+).
|
||||
// See: https://github.com/meshcore-dev/MeshCore/pull/1954
|
||||
uint16_t preamble = (sf <= 8) ? 32 : 16;
|
||||
radio.setPreambleLength(preamble);
|
||||
MESH_DEBUG_PRINTLN("radio_set_params() - bw=%.1f sf=%u preamble=%u", bw, sf, preamble);
|
||||
}
|
||||
|
||||
void radio_set_tx_power(uint8_t dbm) {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
#include <Arduino.h>
|
||||
#include "variant.h"
|
||||
#include "TDeckProMaxBoard.h"
|
||||
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
|
||||
|
||||
// LEDC channel for e-ink backlight PWM (Arduino ESP32 core 2.x channel-based API)
|
||||
#ifdef PIN_EINK_BL
|
||||
#define EINK_BL_LEDC_CHANNEL 0
|
||||
#endif
|
||||
|
||||
// =============================================================================
|
||||
// TDeckProMaxBoard::begin() — Boot sequence for T-Deck Pro MAX V0.1
|
||||
//
|
||||
// Critical ordering:
|
||||
// 1. I2C bus init (XL9555, BQ27220, and all sensors share this bus)
|
||||
// 2. XL9555 init (must be up before ANY peripheral that depends on it)
|
||||
// 3. Touch reset pulse via XL9555 (needed before touch driver init)
|
||||
// 4. Keyboard reset pulse via XL9555 (clean keyboard state)
|
||||
// 5. LoRa power enable via XL9555 (must be on before SPI radio init)
|
||||
// 6. GPS power + UART init
|
||||
// 7. Parent class init (ESP32Board::begin)
|
||||
// 8. LoRa SPI pin config + deep sleep wake handling
|
||||
// 9. BQ27220 fuel gauge check
|
||||
// 10. Low-voltage protection
|
||||
//
|
||||
// NOTE: We do NOT call TDeckBoard::begin() — we reimplement the boot sequence
|
||||
// to handle XL9555-routed pins. BQ27220 methods are inherited unchanged.
|
||||
// =============================================================================
|
||||
|
||||
void TDeckProMaxBoard::begin() {
|
||||
|
||||
MESH_DEBUG_PRINTLN("TDeckProMaxBoard::begin() - T-Deck Pro MAX V0.1");
|
||||
|
||||
// ------ Step 1: I2C bus ------
|
||||
// All I2C devices (XL9555, BQ27220, TCA8418, CST328, DRV2605, ES8311,
|
||||
// BQ25896, BHI260AP) share SDA=13, SCL=14.
|
||||
Wire.begin(I2C_SDA, I2C_SCL);
|
||||
Wire.setClock(100000); // 100kHz — safe for all devices on the bus
|
||||
MESH_DEBUG_PRINTLN(" I2C initialized (SDA=%d SCL=%d)", I2C_SDA, I2C_SCL);
|
||||
|
||||
// ------ Step 2: XL9555 I/O Expander ------
|
||||
// This must happen before anything that needs peripheral power or resets.
|
||||
if (!xl9555_init()) {
|
||||
Serial.println("CRITICAL: XL9555 init failed — peripherals will not work!");
|
||||
// Continue anyway; some things (display, keyboard INT) might still work
|
||||
// without XL9555, but LoRa/GPS/modem will be dead.
|
||||
}
|
||||
|
||||
// ------ Step 3: Touch reset pulse ------
|
||||
// The touch controller (CST328) needs a clean reset via XL9555 IO07
|
||||
// before the touch driver tries to communicate with it.
|
||||
touchReset();
|
||||
|
||||
// ------ Step 4: Keyboard reset pulse ------
|
||||
keyboardReset();
|
||||
|
||||
// ------ Step 5: Parent class init ------
|
||||
// ESP32Board::begin() handles common ESP32 setup.
|
||||
// We skip TDeckBoard::begin() because it uses PIN_PERF_POWERON and
|
||||
// direct GPIO for LoRa/GPS power that don't exist on MAX.
|
||||
ESP32Board::begin();
|
||||
|
||||
// ------ Step 6: GPS UART init ------
|
||||
// GPS power was already enabled by XL9555 boot defaults (GPS_EN HIGH).
|
||||
// Now init the UART with the MAX-specific pins.
|
||||
#if HAS_GPS
|
||||
Serial2.begin(GPS_BAUDRATE, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
|
||||
MESH_DEBUG_PRINTLN(" GPS Serial2 initialized (RX=%d TX=%d @ %d baud)",
|
||||
GPS_RX_PIN, GPS_TX_PIN, GPS_BAUDRATE);
|
||||
#endif
|
||||
|
||||
// ------ Step 7: Configure user button ------
|
||||
pinMode(PIN_USER_BTN, INPUT);
|
||||
|
||||
// ------ Step 8: Configure LoRa SPI pins ------
|
||||
// LoRa power is already enabled via XL9555 (LORA_EN HIGH in boot defaults).
|
||||
pinMode(P_LORA_MISO, INPUT_PULLUP);
|
||||
|
||||
// ------ Step 9: 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);
|
||||
}
|
||||
|
||||
// ------ Step 10: BQ27220 fuel gauge ------
|
||||
#if HAS_BQ27220
|
||||
uint16_t voltage = getBattMilliVolts();
|
||||
MESH_DEBUG_PRINTLN(" Battery voltage: %d mV", voltage);
|
||||
configureFuelGauge(); // Inherited from TDeckBoard — sets 1500 mAh
|
||||
#endif
|
||||
|
||||
// ------ Step 11: 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
|
||||
|
||||
// ------ Step 12: E-ink backlight (working on MAX!) ------
|
||||
// Configure LEDC PWM for backlight brightness control.
|
||||
// Start with backlight OFF — UI code can enable it when needed.
|
||||
#ifdef PIN_EINK_BL
|
||||
// Arduino ESP32 core 2.x uses channel-based LEDC API
|
||||
ledcSetup(EINK_BL_LEDC_CHANNEL, 1000, 8); // Channel 0, 1kHz, 8-bit resolution
|
||||
ledcAttachPin(PIN_EINK_BL, EINK_BL_LEDC_CHANNEL);
|
||||
ledcWrite(EINK_BL_LEDC_CHANNEL, 0); // Off by default
|
||||
MESH_DEBUG_PRINTLN(" Backlight PWM configured on IO%d", PIN_EINK_BL);
|
||||
#endif
|
||||
|
||||
MESH_DEBUG_PRINTLN("TDeckProMaxBoard::begin() - complete");
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// XL9555 I/O Expander — Lightweight I2C Driver
|
||||
// =============================================================================
|
||||
|
||||
bool TDeckProMaxBoard::xl9555_writeReg(uint8_t reg, uint8_t val) {
|
||||
Wire.beginTransmission(I2C_ADDR_XL9555);
|
||||
Wire.write(reg);
|
||||
Wire.write(val);
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
|
||||
uint8_t TDeckProMaxBoard::xl9555_readReg(uint8_t reg) {
|
||||
Wire.beginTransmission(I2C_ADDR_XL9555);
|
||||
Wire.write(reg);
|
||||
Wire.endTransmission(false);
|
||||
Wire.requestFrom((uint8_t)I2C_ADDR_XL9555, (uint8_t)1);
|
||||
return Wire.available() ? Wire.read() : 0xFF;
|
||||
}
|
||||
|
||||
bool TDeckProMaxBoard::xl9555_init() {
|
||||
MESH_DEBUG_PRINTLN(" XL9555: Initializing I/O expander at 0x%02X", I2C_ADDR_XL9555);
|
||||
|
||||
// Verify XL9555 is present on the bus
|
||||
Wire.beginTransmission(I2C_ADDR_XL9555);
|
||||
if (Wire.endTransmission() != 0) {
|
||||
Serial.println(" XL9555: NOT FOUND on I2C bus!");
|
||||
_xlReady = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set ALL pins as outputs (config register: 0 = output)
|
||||
// Port 0 (pins 0-7): all output
|
||||
if (!xl9555_writeReg(XL9555_REG_CONFIG_0, 0x00)) return false;
|
||||
// Port 1 (pins 8-15): all output
|
||||
if (!xl9555_writeReg(XL9555_REG_CONFIG_1, 0x00)) return false;
|
||||
|
||||
// Apply boot defaults
|
||||
_xlPort0 = XL9555_BOOT_PORT0;
|
||||
_xlPort1 = XL9555_BOOT_PORT1;
|
||||
if (!xl9555_writeReg(XL9555_REG_OUTPUT_0, _xlPort0)) return false;
|
||||
if (!xl9555_writeReg(XL9555_REG_OUTPUT_1, _xlPort1)) return false;
|
||||
|
||||
_xlReady = true;
|
||||
|
||||
MESH_DEBUG_PRINTLN(" XL9555: Ready (Port0=0x%02X Port1=0x%02X)", _xlPort0, _xlPort1);
|
||||
MESH_DEBUG_PRINTLN(" XL9555: LoRa=%s GPS=%s 1V8=%s Modem=%s Antenna=%s",
|
||||
(_xlPort0 & (1 << XL_PIN_LORA_EN)) ? "ON" : "OFF",
|
||||
(_xlPort0 & (1 << XL_PIN_GPS_EN)) ? "ON" : "OFF",
|
||||
(_xlPort0 & (1 << XL_PIN_1V8_EN)) ? "ON" : "OFF",
|
||||
(_xlPort0 & (1 << XL_PIN_6609_EN)) ? "ON" : "OFF",
|
||||
(_xlPort0 & (1 << XL_PIN_LORA_SEL)) ? "internal" : "external");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::xl9555_digitalWrite(uint8_t pin, bool value) {
|
||||
if (!_xlReady) return;
|
||||
|
||||
if (pin < 8) {
|
||||
// Port 0
|
||||
if (value) _xlPort0 |= (1 << pin);
|
||||
else _xlPort0 &= ~(1 << pin);
|
||||
xl9555_writeReg(XL9555_REG_OUTPUT_0, _xlPort0);
|
||||
} else if (pin < 16) {
|
||||
// Port 1 (subtract 8 for bit position)
|
||||
uint8_t bit = pin - 8;
|
||||
if (value) _xlPort1 |= (1 << bit);
|
||||
else _xlPort1 &= ~(1 << bit);
|
||||
xl9555_writeReg(XL9555_REG_OUTPUT_1, _xlPort1);
|
||||
}
|
||||
}
|
||||
|
||||
bool TDeckProMaxBoard::xl9555_digitalRead(uint8_t pin) const {
|
||||
if (pin < 8) return (_xlPort0 >> pin) & 1;
|
||||
if (pin < 16) return (_xlPort1 >> (pin - 8)) & 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::xl9555_writePort0(uint8_t val) {
|
||||
_xlPort0 = val;
|
||||
if (_xlReady) xl9555_writeReg(XL9555_REG_OUTPUT_0, val);
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::xl9555_writePort1(uint8_t val) {
|
||||
_xlPort1 = val;
|
||||
if (_xlReady) xl9555_writeReg(XL9555_REG_OUTPUT_1, val);
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// High-level peripheral control
|
||||
// =============================================================================
|
||||
|
||||
// ---- Modem (A7682E) ----
|
||||
|
||||
void TDeckProMaxBoard::modemPowerOn() {
|
||||
MESH_DEBUG_PRINTLN(" XL9555: Modem power ON (6609_EN HIGH)");
|
||||
xl9555_digitalWrite(XL_PIN_6609_EN, HIGH);
|
||||
delay(100); // Allow SGM6609 boost to stabilise
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::modemPowerOff() {
|
||||
MESH_DEBUG_PRINTLN(" XL9555: Modem power OFF (6609_EN LOW)");
|
||||
xl9555_digitalWrite(XL_PIN_6609_EN, LOW);
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::modemPwrkeyPulse() {
|
||||
// A7682E power-on sequence: pulse PWRKEY LOW for >= 500ms
|
||||
// (Some datasheets say pull HIGH then LOW; LilyGo factory sets HIGH then toggles.)
|
||||
MESH_DEBUG_PRINTLN(" XL9555: Modem PWRKEY pulse");
|
||||
xl9555_digitalWrite(XL_PIN_PWRKEY_EN, HIGH);
|
||||
delay(100);
|
||||
xl9555_digitalWrite(XL_PIN_PWRKEY_EN, LOW);
|
||||
delay(1200);
|
||||
xl9555_digitalWrite(XL_PIN_PWRKEY_EN, HIGH);
|
||||
}
|
||||
|
||||
// ---- Audio output selection ----
|
||||
|
||||
void TDeckProMaxBoard::selectAudioES8311() {
|
||||
MESH_DEBUG_PRINTLN(" XL9555: Audio select → ES8311");
|
||||
xl9555_digitalWrite(XL_PIN_AUDIO_SEL, LOW);
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::selectAudioModem() {
|
||||
MESH_DEBUG_PRINTLN(" XL9555: Audio select → A7682E");
|
||||
xl9555_digitalWrite(XL_PIN_AUDIO_SEL, HIGH);
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::amplifierEnable() {
|
||||
xl9555_digitalWrite(XL_PIN_AMPLIFIER, HIGH);
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::amplifierDisable() {
|
||||
xl9555_digitalWrite(XL_PIN_AMPLIFIER, LOW);
|
||||
}
|
||||
|
||||
// ---- LoRa antenna selection ----
|
||||
|
||||
void TDeckProMaxBoard::loraAntennaInternal() {
|
||||
MESH_DEBUG_PRINTLN(" XL9555: LoRa antenna → internal");
|
||||
xl9555_digitalWrite(XL_PIN_LORA_SEL, HIGH);
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::loraAntennaExternal() {
|
||||
MESH_DEBUG_PRINTLN(" XL9555: LoRa antenna → external");
|
||||
xl9555_digitalWrite(XL_PIN_LORA_SEL, LOW);
|
||||
}
|
||||
|
||||
// ---- Motor (DRV2605) ----
|
||||
|
||||
void TDeckProMaxBoard::motorEnable() {
|
||||
xl9555_digitalWrite(XL_PIN_MOTOR_EN, HIGH);
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::motorDisable() {
|
||||
xl9555_digitalWrite(XL_PIN_MOTOR_EN, LOW);
|
||||
}
|
||||
|
||||
// ---- Touch reset ----
|
||||
|
||||
void TDeckProMaxBoard::touchReset() {
|
||||
if (!_xlReady) return;
|
||||
MESH_DEBUG_PRINTLN(" XL9555: Touch reset pulse");
|
||||
xl9555_digitalWrite(XL_PIN_TOUCH_RST, LOW);
|
||||
delay(20);
|
||||
xl9555_digitalWrite(XL_PIN_TOUCH_RST, HIGH);
|
||||
delay(50); // Allow touch controller to come out of reset
|
||||
}
|
||||
|
||||
// ---- Keyboard reset ----
|
||||
|
||||
void TDeckProMaxBoard::keyboardReset() {
|
||||
if (!_xlReady) return;
|
||||
MESH_DEBUG_PRINTLN(" XL9555: Keyboard reset pulse");
|
||||
xl9555_digitalWrite(XL_PIN_KEY_RST, LOW);
|
||||
delay(20);
|
||||
xl9555_digitalWrite(XL_PIN_KEY_RST, HIGH);
|
||||
delay(50);
|
||||
}
|
||||
|
||||
// ---- GPS power ----
|
||||
|
||||
void TDeckProMaxBoard::gpsPowerOn() {
|
||||
xl9555_digitalWrite(XL_PIN_GPS_EN, HIGH);
|
||||
delay(100);
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::gpsPowerOff() {
|
||||
xl9555_digitalWrite(XL_PIN_GPS_EN, LOW);
|
||||
}
|
||||
|
||||
// ---- LoRa power ----
|
||||
|
||||
void TDeckProMaxBoard::loraPowerOn() {
|
||||
xl9555_digitalWrite(XL_PIN_LORA_EN, HIGH);
|
||||
delay(10);
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::loraPowerOff() {
|
||||
xl9555_digitalWrite(XL_PIN_LORA_EN, LOW);
|
||||
}
|
||||
|
||||
// ---- E-ink backlight (working on MAX!) ----
|
||||
|
||||
void TDeckProMaxBoard::backlightOn() {
|
||||
#ifdef PIN_EINK_BL
|
||||
ledcWrite(EINK_BL_LEDC_CHANNEL, 255);
|
||||
#endif
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::backlightOff() {
|
||||
#ifdef PIN_EINK_BL
|
||||
ledcWrite(EINK_BL_LEDC_CHANNEL, 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
void TDeckProMaxBoard::backlightSetBrightness(uint8_t duty) {
|
||||
#ifdef PIN_EINK_BL
|
||||
ledcWrite(EINK_BL_LEDC_CHANNEL, duty);
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// TDeckProMaxBoard — Board support for LilyGo T-Deck Pro MAX V0.1
|
||||
//
|
||||
// Extends TDeckBoard (which provides all BQ27220 fuel gauge methods) with:
|
||||
// - XL9555 I/O expander initialisation and control
|
||||
// - XL9555-routed peripheral power management
|
||||
// - Touch/keyboard reset via XL9555
|
||||
// - Modem power/PWRKEY via XL9555
|
||||
// - LoRa antenna selection via XL9555
|
||||
// - Audio output mux (ES8311 vs A7682E) via XL9555
|
||||
// - Speaker amplifier enable via XL9555
|
||||
//
|
||||
// The XL9555 must be initialised before LoRa, GPS, modem, or touch are used.
|
||||
// All power enables, resets, and switches go through I2C — not direct GPIO.
|
||||
// =============================================================================
|
||||
|
||||
#include "variant.h"
|
||||
#include "TDeckBoard.h" // Inherits BQ27220 fuel gauge, deep sleep, power management
|
||||
|
||||
class TDeckProMaxBoard : public TDeckBoard {
|
||||
public:
|
||||
void begin();
|
||||
|
||||
const char* getManufacturerName() const {
|
||||
return "LilyGo T-Deck Pro MAX";
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// XL9555 I/O Expander — lightweight inline driver
|
||||
//
|
||||
// The XL9555 has 16 I/O pins across two 8-bit ports.
|
||||
// Pin 0-7 = Port 0, Pin 8-15 = Port 1.
|
||||
// We shadow the output state in _xlPort0/_xlPort1 to allow
|
||||
// single-bit set/clear without read-modify-write over I2C.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Initialise XL9555: set all used pins as outputs, apply boot defaults.
|
||||
// Returns true if I2C communication with XL9555 succeeded.
|
||||
bool xl9555_init();
|
||||
|
||||
// Set a single XL9555 pin HIGH or LOW (pin 0-15).
|
||||
void xl9555_digitalWrite(uint8_t pin, bool value);
|
||||
|
||||
// Read the current output state of a pin (from shadow, not I2C read).
|
||||
bool xl9555_digitalRead(uint8_t pin) const;
|
||||
|
||||
// Write raw port values (for batch updates).
|
||||
void xl9555_writePort0(uint8_t val);
|
||||
void xl9555_writePort1(uint8_t val);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// High-level peripheral control (delegates to XL9555)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Modem (A7682E) power control
|
||||
void modemPowerOn(); // Enable SGM6609 boost (6609_EN HIGH)
|
||||
void modemPowerOff(); // Disable SGM6609 boost (6609_EN LOW)
|
||||
void modemPwrkeyPulse(); // Toggle PWRKEY: HIGH 100ms → LOW 1200ms → HIGH
|
||||
|
||||
// Audio output selection
|
||||
void selectAudioES8311(); // AUDIO_SEL LOW → ES8311 output to speaker/headphones
|
||||
void selectAudioModem(); // AUDIO_SEL HIGH → A7682E output to speaker/headphones
|
||||
void amplifierEnable(); // NS4150B amplifier ON (louder speaker)
|
||||
void amplifierDisable(); // NS4150B amplifier OFF (saves power)
|
||||
|
||||
// LoRa antenna selection (SKY13453 RF switch)
|
||||
void loraAntennaInternal(); // LORA_SEL HIGH → internal PCB antenna (default)
|
||||
void loraAntennaExternal(); // LORA_SEL LOW → external IPEX antenna
|
||||
|
||||
// Motor (DRV2605) power
|
||||
void motorEnable(); // MOTOR_EN HIGH
|
||||
void motorDisable(); // MOTOR_EN LOW
|
||||
|
||||
// Touch controller reset via XL9555
|
||||
void touchReset(); // Pulse TOUCH_RST: LOW 20ms → HIGH, then 50ms settle
|
||||
|
||||
// Keyboard reset via XL9555
|
||||
void keyboardReset(); // Pulse KEY_RST: LOW 20ms → HIGH, then 50ms settle
|
||||
|
||||
// GPS power control via XL9555
|
||||
void gpsPowerOn(); // GPS_EN HIGH
|
||||
void gpsPowerOff(); // GPS_EN LOW
|
||||
|
||||
// LoRa power control via XL9555
|
||||
void loraPowerOn(); // LORA_EN HIGH
|
||||
void loraPowerOff(); // LORA_EN LOW
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// E-ink front-light control
|
||||
// On MAX, IO41 has a working backlight circuit (boost converter + LEDs).
|
||||
// PWM control for brightness is possible via ledc.
|
||||
// -------------------------------------------------------------------------
|
||||
void backlightOn();
|
||||
void backlightOff();
|
||||
void backlightSetBrightness(uint8_t duty); // 0-255, via LEDC PWM
|
||||
|
||||
private:
|
||||
// Shadow registers for XL9555 output ports (avoid I2C read-modify-write)
|
||||
uint8_t _xlPort0 = XL9555_BOOT_PORT0;
|
||||
uint8_t _xlPort1 = XL9555_BOOT_PORT1;
|
||||
bool _xlReady = false;
|
||||
|
||||
// Low-level I2C helpers
|
||||
bool xl9555_writeReg(uint8_t reg, uint8_t val);
|
||||
uint8_t xl9555_readReg(uint8_t reg);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user