39 Commits

Author SHA1 Message Date
pelgraine
9b15458927 Update README.md 2026-03-08 00:41:51 +11:00
pelgraine
85ccdf526e Update README.md 2026-03-08 00:40:47 +11:00
pelgraine
c0dd59834c updated firmware version and date 2026-03-07 15:56:16 +11:00
pelgraine
90a4f5f881 multi.acks 1 set default for new firmware installs; can set rxdelay, int.thresh, gps.baud and multi.acks prefs over serial; adjust preamble length dependant on SF setting; updated serial settings guide and Meck Readme accordingly 2026-03-07 15:53:50 +11:00
pelgraine
b27acb3252 multi-byte path implementation to bring Meck up to speed with Meshcore v1.14; fix regression of ui display in last msg rcd repeater hop count view and also update it for 2 byte nodes 2026-03-07 05:02:22 +11:00
pelgraine
580484e0ad update firmware version and date to v0.9.9 in prep for multibyte testing 2026-03-07 03:21:10 +11:00
pelgraine
9d7fbc3134 updated photo sizes in Launcher flash guide 2026-03-06 22:15:17 +11:00
pelgraine
b859f8f168 Added guide to flashing Meck via Launcher 2026-03-06 22:10:38 +11:00
pelgraine
190b40c2ce fixed regression with previously committed missing code from webreaderscreen h 2026-03-06 18:26:32 +11:00
pelgraine
859919348d fix serial settings loop regression argh 2026-03-05 19:13:29 +11:00
pelgraine
e91ad4bac4 repeater admin login regression bugfix and timing response improvements 2026-03-05 18:04:28 +11:00
pelgraine
db58f8cf87 add additional firmware version update to v0.9.8 for impending bugfixes 2026-03-05 17:51:27 +11:00
pelgraine
a74b1c3f7a fix accidental leftover preseed discovery code 2026-03-05 17:44:05 +11:00
pelgraine
12477af8c7 fix serial monitor loop issue 2026-03-04 19:47:19 +11:00
pelgraine
49d399c4d6 updated firmware build date 2026-03-04 19:25:01 +11:00
pelgraine
33f2e0fc6e proper discover node function working 2026-03-04 18:55:13 +11:00
pelgraine
7685de4be6 fix wifi connection path; enable phone hotspot connection pairing 2026-03-04 18:36:39 +11:00
pelgraine
3f4da4bc2b Enable settings setup over serial - see guide for details 2026-03-04 07:56:35 +11:00
pelgraine
fe949235d9 fixed stupid persistent contacts saved bug in datastore; prelim contacts discovery function 2026-03-04 07:16:07 +11:00
pelgraine
d92fdc9ffe updated accidental regression in settings - readded autoadd contacts settings; added wifi on off option to settings 2026-03-03 23:17:41 +11:00
pelgraine
3a6673edea attempt to fix contacts persistency error bug 2026-03-03 22:38:40 +11:00
pelgraine
e2a04892f4 updated uitask for wifi companions 2026-03-03 22:07:39 +11:00
pelgraine
31db349305 new wifi companions with wifi setup in onboarding; update firmware version and date accordingly 2026-03-03 21:59:30 +11:00
pelgraine
b444a664c5 removed gpsaiding references 2026-03-03 20:57:26 +11:00
pelgraine
4e4c6cba80 Removed GPSaiding as was causing device to lose fix 2026-03-03 17:26:12 +11:00
pelgraine
a178d43046 refined gpsaiding integration now that gpsdutycycle is deleted 2026-03-02 20:01:10 +11:00
pelgraine
36c5fafec6 removed gps cycle due to slow or no fix from cold start frequency 2026-03-02 19:49:03 +11:00
pelgraine
5260f0ccea commented out setting for ringtone as appears to be impossible to silence 2026-03-02 07:42:56 +11:00
pelgraine
edf3fb7fff commented out setting for ringtone as appears to be impossible to silence 2026-03-02 07:38:28 +11:00
pelgraine
129a75ed4e update firmware version and date; 2026-03-02 07:33:46 +11:00
pelgraine
1ecda1a8f5 fix qmax entries so fcc is limited to 2000mah and not 3000mah 2026-03-01 23:49:02 +11:00
pelgraine
4bb721e060 implementing low battery brownout protection to prevent contacts file corruption caused by low voltage reboot loop; board goes to sleep at 2800mv 2026-03-01 23:28:49 +11:00
pelgraine
4646fd6bd9 ringtone 2026-03-01 23:16:54 +11:00
pelgraine
d1104d0b9c Hopefully faster gps fix after cold boot 2026-03-01 22:54:26 +11:00
pelgraine
513715e472 add contacts settings in settings 2026-03-01 14:11:39 +11:00
pelgraine
1dfab7d9a6 Add image and update README for T-Deck Pro 2026-03-01 13:39:26 +11:00
pelgraine
4724cded26 changed map labels so non ascii characters aren't displayed to make it more readable; implemented fix to prevent contacts rewrite if crash occurs during boot 2026-03-01 12:46:52 +11:00
pelgraine
74d5bfef70 fix labels and map icon rendering 2026-03-01 12:36:35 +11:00
pelgraine
e9540bcf23 maps! version 1 - g key to access 2026-03-01 11:38:20 +11:00
56 changed files with 4868 additions and 696 deletions

View File

@@ -1,5 +1,7 @@
## Meshcore + Fork = Meck
This fork was created specifically to focus on enabling BLE companion firmware for the LilyGo T-Deck Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
This fork was created specifically to focus on enabling BLE & WiFi companion firmware for the LilyGo T-Deck Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
<img src="https://github.com/user-attachments/assets/b30ce6bd-79af-44d3-93c4-f5e7e21e5621" alt="IMG_1453" width="300" height="650">
### Contents
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
@@ -11,6 +13,7 @@ This fork was created specifically to focus on enabling BLE companion firmware f
- [Sending a Direct Message](#sending-a-direct-message)
- [Repeater Admin Screen](#repeater-admin-screen)
- [Settings Screen](#settings-screen)
- [Serial Settings (USB)](Serial_Settings_Guide.md)
- [Compose Mode](#compose-mode)
- [Symbol Entry (Sym Key)](#symbol-entry-sym-key)
- [Emoji Picker](#emoji-picker)
@@ -32,7 +35,7 @@ This fork was created specifically to focus on enabling BLE companion firmware f
## T-Deck Pro Keyboard Controls
The T-Deck Pro BLE companion firmware includes full keyboard support for standalone messaging without a phone.
The T-Deck Pro companion firmware includes full keyboard support for standalone messaging without a phone.
### Navigation (Home Screen)
@@ -49,6 +52,8 @@ The T-Deck Pro BLE companion firmware includes full keyboard support for standal
| B | Open web browser (BLE and 4G variants only) |
| T | Open SMS & Phone app (4G variant only) |
| P | Open audiobook player (audio variant only) |
| F | Open node discovery (search for nearby repeaters/nodes) |
| G | Open map screen (shows contacts with GPS positions) |
| Q | Back to home screen |
### Bluetooth (BLE)
@@ -163,6 +168,8 @@ When adding a hashtag channel, type the channel name and press Enter. The channe
If you've changed radio parameters, pressing Q will prompt you to apply changes before exiting.
> **Tip:** All device settings (plus mesh tuning parameters not available on-screen) can also be configured via USB serial. See the [Serial Settings Guide](Serial_Settings_Guide.md) for complete documentation.
### Compose Mode
| Key | Action |

393
Serial Settings Guide.md Normal file
View File

@@ -0,0 +1,393 @@
# Meck Serial Settings Guide
Configure your T-Deck Pro's Meck firmware over USB serial — no companion app needed. Plug in a USB-C cable, open a serial terminal, and you have full access to every setting on the device.
## Getting Started
### What You Need
- T-Deck Pro running Meck firmware
- USB-C cable
- A serial terminal application:
- **Windows:** PuTTY, TeraTerm, or the Arduino IDE Serial Monitor
- **macOS:** `screen`, CoolTerm, or the Arduino IDE Serial Monitor
- **Linux:** `screen`, `minicom`, `picocom`, or the Arduino IDE Serial Monitor
### Connection Settings
| Parameter | Value |
|-----------|-------|
| Baud rate | 115200 |
| Data bits | 8 |
| Parity | None |
| Stop bits | 1 |
| Line ending | CR (carriage return) or CR+LF |
### Quick Start (macOS / Linux)
```
screen /dev/ttyACM0 115200
```
On macOS the port is typically `/dev/cu.usbmodem*`. On Linux it is usually `/dev/ttyACM0` or `/dev/ttyUSB0`.
### Quick Start (Arduino IDE)
Open **Tools → Serial Monitor**, set baud to **115200** and line ending to **Carriage Return** or **Both NL & CR**.
Once connected, type `help` and press Enter to confirm everything is working.
---
## Command Reference
All commands follow a simple pattern: `get` to read, `set` to write.
### Viewing Settings
| Command | Description |
|---------|-------------|
| `get all` | Dump every setting at once |
| `get name` | Device name |
| `get freq` | Radio frequency (MHz) |
| `get bw` | Bandwidth (kHz) |
| `get sf` | Spreading factor |
| `get cr` | Coding rate |
| `get tx` | TX power (dBm) |
| `get radio` | All radio params in one line |
| `get utc` | UTC offset (hours) |
| `get notify` | Keyboard flash notification (on/off) |
| `get gps` | GPS status and interval |
| `get pin` | BLE pairing PIN |
| `get path.hash.mode` | Path hash size (0=1-byte, 1=2-byte, 2=3-byte) |
| `get rxdelay` | Rx delay base (0=disabled) |
| `get af` | Airtime factor |
| `get multi.acks` | Redundant ACKs (0 or 1) |
| `get int.thresh` | Interference threshold (0=disabled) |
| `get gps.baud` | GPS baud rate (0=compile-time default) |
| `get channels` | List all channels with index numbers |
| `get presets` | List all radio presets with parameters |
| `get pubkey` | Device public key (hex) |
| `get firmware` | Firmware version string |
**4G variant only:**
| Command | Description |
|---------|-------------|
| `get modem` | Modem enabled/disabled |
| `get apn` | Current APN |
| `get imei` | Device IMEI |
### Changing Settings
#### Device Name
```
set name MyNode
```
Names cannot contain these characters: `[ ] / \ : , ? *`
#### Radio Parameters (Individual)
Each of these applies immediately — no reboot required.
```
set freq 910.525
set bw 62.5
set sf 7
set cr 5
set tx 22
```
Valid ranges:
| Parameter | Min | Max |
|-----------|-----|-----|
| freq | 400.0 | 928.0 |
| bw | 7.8 | 500.0 |
| sf | 5 | 12 |
| cr | 5 | 8 |
| tx | 1 | Board max (typically 22) |
#### Radio Parameters (All at Once)
Set frequency, bandwidth, spreading factor, and coding rate in a single command:
```
set radio 910.525 62.5 7 5
```
#### Radio Presets
The easiest way to configure your radio. First, list the available presets:
```
get presets
```
This prints a numbered list like:
```
Available radio presets:
0 Australia 915.800 MHz BW250.0 SF10 CR5 TX22
1 Australia (Narrow) 916.575 MHz BW62.5 SF7 CR8 TX22
...
14 USA/Canada (Recommended) 910.525 MHz BW62.5 SF7 CR5 TX22
15 Vietnam 920.250 MHz BW250.0 SF11 CR5 TX22
```
Apply a preset by name or number:
```
set preset USA/Canada (Recommended)
set preset 14
```
Preset names are case-insensitive, so `set preset australia` works too. The preset applies all five radio parameters (freq, bw, sf, cr, tx) and takes effect immediately.
#### UTC Offset
```
set utc 10
```
Range: -12 to +14.
#### Keyboard Notification Flash
Toggle whether the keyboard backlight flashes when a new message arrives:
```
set notify on
set notify 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: 020 (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: 09 (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 113 are not functional and will be rejected.
#### GPS Baud Rate (gps.baud)
Override the GPS serial baud rate. The default (0) uses the compile-time value of 38400. **Requires a reboot to take effect** — the GPS serial port is only configured at startup.
```
set gps.baud 9600
set gps.baud 0
```
Valid rates: 0 (default), 4800, 9600, 19200, 38400, 57600, 115200.
### Channel Management
#### List Channels
```
get channels
```
Output:
```
[0] #public
[1] #meck-test
[2] #local-group
```
#### Add a Hashtag Channel
```
set channel.add meck-test
```
The `#` prefix is added automatically if you omit it. The channel's encryption key is derived from the name (SHA-256), matching the same method used by the on-device Settings screen and companion apps.
#### Delete a Channel
```
set channel.del 2
```
Channels are referenced by their index number (shown in `get channels`). Channel 0 (public) cannot be deleted. Remaining channels are automatically compacted after deletion.
### 4G Modem (4G Variant Only)
#### Enable / Disable Modem
```
set modem on
set modem off
```
#### Set APN
```
set apn telstra.internet
```
To clear a custom APN and revert to auto-detection on next boot:
```
set apn
```
### System Commands
| Command | Description |
|---------|-------------|
| `reboot` | Restart the device |
| `rebuild` | Erase filesystem, re-save identity + prefs + contacts + channels |
| `erase` | Format the filesystem (caution: loses everything) |
| `ls UserData/` | List files on internal filesystem |
| `ls ExtraFS/` | List files on secondary filesystem |
| `cat UserData/<path>` | Dump file contents as hex |
| `rm UserData/<path>` | Delete a file |
| `help` | Show command summary |
---
## Common Workflows
### First-Time Setup
Plug in your new T-Deck Pro and run through these commands to get on the air:
```
set name YourCallsign
set preset Australia
set utc 10
set channel.add local-group
get all
```
### Switching to a New Region
Moving from Australia to the US? One command:
```
set preset USA/Canada (Recommended)
```
Verify with:
```
get radio
```
### Custom Radio Configuration
If none of the presets match your local group or you need specific parameters, set them directly. You can do it all in one command:
```
set radio 916.575 62.5 8 8
set tx 20
```
Or one parameter at a time if you're only adjusting part of your config:
```
set freq 916.575
set bw 62.5
set sf 8
set cr 8
set tx 20
```
Both approaches apply immediately. Confirm with `get radio` to double-check everything took:
```
get radio
> freq=916.575 bw=62.5 sf=8 cr=8 tx=20
```
### Troubleshooting Radio Settings
If you're not sure what went wrong, dump everything:
```
get all
```
Compare the radio section against what others in your area are using. If you need to match exact parameters from another node:
```
set radio 916.575 62.5 7 8
set tx 22
```
### Backing Up Your Settings
Use `get all` to capture a snapshot of your configuration. Copy the serial output and save it — you can manually re-enter the settings after a firmware update or device reset if your SD card backup isn't available.
---
## Tips
- **All radio changes apply live.** There is no need to reboot after changing frequency, bandwidth, spreading factor, coding rate, or TX power. The radio reconfigures on the fly.
- **Preset selection by number is faster.** Once you've seen `get presets`, use the index number instead of typing the full name.
- **Settings are persisted immediately.** Every `set` command writes to flash. If power is lost, your settings are safe.
- **SD card backup is automatic.** If your T-Deck Pro has an SD card inserted, settings are backed up after every change. On a fresh flash, settings restore automatically from the SD card.
- **The `get all` command is your friend.** When in doubt, dump everything and check.

View File

@@ -0,0 +1,101 @@
# How to Flash Meck Firmware Using Launcher over WiFi
## How to Install Launcher on Your T-Deck Pro
First, ensure your SD card is inserted into your T-Deck Pro. Your SD card should already have been formatted as FAT32.
1. Plug your T-Deck Pro into your computer via USB-C.
2. Go to [https://bmorcelli.github.io/Launcher/webflasher.html](https://bmorcelli.github.io/Launcher/webflasher.html) in Chrome browser.
3. Click on **LilyGo** under Choose a Vendor.
4. Click on **T-Deck Pro**.
5. Click on **Connect**.
6. In the serial connect popup, click on your device in the list (likely starts with "USB JTAG/serial debug unit"), and click **Connect**. Wait a few seconds for it to connect.
7. Click the **Install T-Deck Pro** popup.
8. Click **Next**. (Don't worry about ticking the Erase Device checkbox.)
9. Click **Install**.
### If You Don't Already Have a Meck Firmware File
Download one from [https://github.com/pelgraine/Meck/releases](https://github.com/pelgraine/Meck/releases).
## How to Install a New Meck Firmware .bin File via Launcher
After flashing using [https://bmorcelli.github.io/Launcher/webflasher.html](https://bmorcelli.github.io/Launcher/webflasher.html), your Pro will reboot itself automatically and display the main Launcher home screen, with the SD card option highlighted.
<img src="images/01_launcher_home.jpg" alt="Launcher home screen" width="200">
Either tap **NEXT** on the device screen twice or tap on the WUI button, and tap **SEL**.
<img src="images/02_wui_selected.jpg" alt="WUI selected" width="200">
Tap on **My Network** on the pop-up menu. Press **NEXT/SEL** as needed to highlight and select your WiFi SSID.
Enter your WiFi SSID details.
Once connected, your device will display the WebUI connection screen with the T-Deck Pro IP address.
Open a browser on your computer — Chrome, Firefox, or Safari will do, but Firefox tends to be easiest — and type in the IP address displayed on your T-Deck Pro into your computer browser address bar, and press enter.
<img src="images/03_webui_ip.jpg" alt="WebUI IP address screen" width="200">
In this instance, for example, I would type `192.168.1.118`, and once I've pressed enter, the address bar now displays `http://192.168.1.118/` (as per the photo). If you're having trouble loading the IP address page, double check your browser hasn't automatically changed it to `https`. If it has, delete the `s` out of the URL and hit enter to load the page.
Login to the browser page with the username **admin** and password **launcher**, and click **Login**. The browser will refresh and display your SD card file list.
<img src="images/04_browser_login.jpg" alt="Browser login" width="450">
<img src="images/05_send_files.png" alt="SD card file list with Send Files button" width="450">
Scroll down to the bottom of the browser page, and click the **Send Files** button.
Your computer/device will load the file browser. Navigate to wherever you've previously saved your new Meck firmware `.bin` file, select the bin file, and click **Open**.
Wait for the blue loading bar on the bottom of the browser page to finish, and then check you can see the file name in the list in green. Also worth checking the file is at least 1.2MB — if it is under 1MB, the file hasn't uploaded properly and you will need to go through the **Send Files** button to try uploading it again.
<img src="images/06_check_file_uploaded.png" alt="Check file uploaded" width="450">
You can then either close the browser window or just leave it. Go back to your T-Deck Pro and press **SEL** to disconnect the WUI mode.
<img src="images/07_disconnect_wui.png" alt="Disconnect WUI" width="200">
Either press **PREV** twice to navigate to it and then press **SEL** again to open, or tap right on the **SD** button to open the SD card menu.
<img src="images/08_sd_button.jpg" alt="SD button on Launcher home" width="200">
The Launcher SD file browser will open. You will most likely have to tap **Page Down** at least twice to scroll to where the name of your new file is.
<img src="images/09_sd_file_list.png" alt="SD file list page 1" width="200">
<img src="images/10_page_down.png" alt="Page Down to find file" width="200">
Either press **NEXT** to navigate until the new file is highlighted with the `>`, or just tap right on the file name, and press **SEL** to bring up the file menu.
<img src="images/11_select_file.png" alt="Select the firmware file" width="200">
The first option on the file menu list will be **>Install**. You can either tap right on **Install** or tap **SEL**.
<img src="images/12_install_option.png" alt="Install option" width="200">
**Wait for the firmware to finish installing.** It will reboot itself automatically.
<img src="images/13_installing_fw.jpg" alt="Installing firmware" width="200">
> **Note:** On first flash of a new firmware version, the "Loading…" screen will most likely display for about 70 seconds. This is a known bug. **Please be patient** if this is the first time loading your new Meck firmware.
<img src="images/14_loading_screen.png" alt="Loading screen" width="200">
On every boot, the firmware will scan your SD card and `/books` folder for any new `.txt` or `.epub` files that haven't yet been cached. It's usually very quick even if you have a lot of ebook files, and even faster after the first boot.
<img src="images/15_indexing_pages.jpg" alt="Indexing pages" width="200">
You'll then see the firmware version splash screen for a split second.
<img src="images/16_version_splash.jpg" alt="Version splash screen" width="200">
Then the Meck home screen will display, and you're good to go. Here's an example of what the Meck 4G WiFi companion firmware home screen looks like:
<img src="images/17_meck_home.jpg" alt="Meck home screen" width="200">
> **Tip:** Every time you reset the device, the Launcher splash screen will display. Wait about six seconds if you just want the Meck firmware to boot by default. Otherwise, tap the **LAUNCHER** text at the bottom to boot back into the Launcher home screen, to get access to the SD menu and WUI menu again.
<img src="images/18_launcher_boot.jpg" alt="Launcher boot screen" width="200">

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

BIN
docs/images/03_webui_ip.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

View File

@@ -41,7 +41,7 @@ public:
void disableSerial() { _serial->disable(); }
virtual void msgRead(int msgcount) = 0;
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path = nullptr) = 0;
const uint8_t* path = nullptr, int8_t snr = 0) = 0;
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual void loop() = 0;
virtual void showAlert(const char* text, int duration_millis) {}

View File

@@ -230,6 +230,28 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
file.read((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
// Fields added later — may not exist in older prefs files
if (file.read((uint8_t *)&_prefs.kb_flash_notify, sizeof(_prefs.kb_flash_notify)) != sizeof(_prefs.kb_flash_notify)) {
_prefs.kb_flash_notify = 0; // default OFF for old files
}
if (file.read((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)) != sizeof(_prefs.ringtone_enabled)) {
_prefs.ringtone_enabled = 0; // default OFF for old files
}
// Clamp booleans to 0/1 in case of garbage
if (_prefs.kb_flash_notify > 1) _prefs.kb_flash_notify = 0;
if (_prefs.ringtone_enabled > 1) _prefs.ringtone_enabled = 0;
// v1.14+ fields — may not exist in older prefs files
if (file.read((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)) != sizeof(_prefs.path_hash_mode)) {
_prefs.path_hash_mode = 0; // default: legacy 1-byte
}
if (file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)) != sizeof(_prefs.autoadd_max_hops)) {
_prefs.autoadd_max_hops = 0; // default: no limit
}
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
file.close();
}
}
@@ -265,14 +287,66 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
file.write((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
file.write((uint8_t *)&_prefs.kb_flash_notify, sizeof(_prefs.kb_flash_notify)); // 89
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.close();
}
}
void DataStore::loadContacts(DataStoreHost* host) {
File file = openRead(_getContactsChannelsFS(), "/contacts3");
FILESYSTEM* fs = _getContactsChannelsFS();
// --- Crash recovery ---
// If /contacts3 is missing but /contacts3.tmp exists, a crash occurred
// after removing the original but before the rename completed.
// The .tmp file has the valid data — promote it.
if (!fs->exists("/contacts3") && fs->exists("/contacts3.tmp")) {
Serial.println("DataStore: recovering contacts from .tmp file");
fs->rename("/contacts3.tmp", "/contacts3");
}
// If both exist, a crash occurred before the old file was removed.
// The original /contacts3 is still valid — just clean up the orphan.
if (fs->exists("/contacts3.tmp")) {
fs->remove("/contacts3.tmp");
}
File file = openRead(fs, "/contacts3");
if (file) {
// --- Truncation guard ---
// If the file is smaller than one full contact record (152 bytes),
// it was truncated by a crash/brown-out. Discard it and try the
// .tmp backup if available.
size_t fsize = file.size();
if (fsize > 0 && fsize < 152) {
Serial.printf("DataStore: contacts3 truncated (%d bytes < 152), discarding\n", (int)fsize);
file.close();
fs->remove("/contacts3");
if (fs->exists("/contacts3.tmp")) {
File tmp = openRead(fs, "/contacts3.tmp");
if (tmp && tmp.size() >= 152) {
Serial.println("DataStore: recovering from .tmp after truncation");
tmp.close();
fs->rename("/contacts3.tmp", "/contacts3");
file = openRead(fs, "/contacts3");
if (!file) return; // give up
} else {
if (tmp) tmp.close();
Serial.println("DataStore: no valid contacts backup — starting fresh");
return;
}
} else {
Serial.println("DataStore: no .tmp backup — starting fresh");
return;
}
} else if (fsize == 0) {
// Empty file — nothing to load
file.close();
return;
}
bool full = false;
while (!full) {
ContactInfo c;
@@ -302,36 +376,86 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
}
void DataStore::saveContacts(DataStoreHost* host) {
File file = openWrite(_getContactsChannelsFS(), "/contacts3");
if (file) {
uint32_t idx = 0;
ContactInfo c;
uint8_t unused = 0;
FILESYSTEM* fs = _getContactsChannelsFS();
const char* finalPath = "/contacts3";
const char* tmpPath = "/contacts3.tmp";
while (host->getContactForSave(idx, c)) {
bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
// --- Step 1: Write all contacts to a temporary file ---
File file = openWrite(fs, tmpPath);
if (!file) {
Serial.println("DataStore: saveContacts FAILED — cannot open tmp file");
return;
}
if (!success) break; // write failed
uint32_t idx = 0;
ContactInfo c;
uint8_t unused = 0;
uint32_t recordsWritten = 0;
bool writeOk = true;
idx++; // advance to next contact
while (host->getContactForSave(idx, c)) {
bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
if (!success) {
writeOk = false;
Serial.printf("DataStore: saveContacts write error at record %d\n", idx);
break;
}
file.close();
recordsWritten++;
idx++;
}
file.close();
// --- Step 2: Verify the write completed ---
// Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close)
size_t expectedBytes = recordsWritten * 152; // 152 bytes per contact record
File verify = openRead(fs, tmpPath);
size_t bytesWritten = verify ? verify.size() : 0;
if (verify) verify.close();
if (!writeOk || bytesWritten != expectedBytes) {
Serial.printf("DataStore: saveContacts ABORTED — wrote %d bytes, expected %d (%d records)\n",
(int)bytesWritten, (int)expectedBytes, recordsWritten);
fs->remove(tmpPath); // Clean up failed tmp file
return; // Original /contacts3 is untouched
}
// --- Step 3: Replace original with verified temp file ---
fs->remove(finalPath);
if (fs->rename(tmpPath, finalPath)) {
Serial.printf("DataStore: saved %d contacts (%d bytes)\n", recordsWritten, (int)bytesWritten);
} else {
// Rename failed — tmp file still has the good data
Serial.println("DataStore: rename failed, tmp file preserved");
}
}
void DataStore::loadChannels(DataStoreHost* host) {
File file = openRead(_getContactsChannelsFS(), "/channels2");
FILESYSTEM* fs = _getContactsChannelsFS();
// Crash recovery (same pattern as contacts)
if (!fs->exists("/channels2") && fs->exists("/channels2.tmp")) {
Serial.println("DataStore: recovering channels from .tmp file");
fs->rename("/channels2.tmp", "/channels2");
}
if (fs->exists("/channels2.tmp")) {
fs->remove("/channels2.tmp");
}
File file = openRead(fs, "/channels2");
if (file) {
bool full = false;
uint8_t channel_idx = 0;
@@ -356,22 +480,54 @@ void DataStore::loadChannels(DataStoreHost* host) {
}
void DataStore::saveChannels(DataStoreHost* host) {
File file = openWrite(_getContactsChannelsFS(), "/channels2");
if (file) {
uint8_t channel_idx = 0;
ChannelDetails ch;
uint8_t unused[4];
memset(unused, 0, 4);
FILESYSTEM* fs = _getContactsChannelsFS();
const char* finalPath = "/channels2";
const char* tmpPath = "/channels2.tmp";
while (host->getChannelForSave(channel_idx, ch)) {
bool success = (file.write(unused, 4) == 4);
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
File file = openWrite(fs, tmpPath);
if (!file) {
Serial.println("DataStore: saveChannels FAILED — cannot open tmp file");
return;
}
if (!success) break; // write failed
channel_idx++;
uint8_t channel_idx = 0;
ChannelDetails ch;
uint8_t unused[4];
memset(unused, 0, 4);
bool writeOk = true;
while (host->getChannelForSave(channel_idx, ch)) {
bool success = (file.write(unused, 4) == 4);
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
if (!success) {
writeOk = false;
Serial.printf("DataStore: saveChannels write error at channel %d\n", channel_idx);
break;
}
file.close();
channel_idx++;
}
file.close();
// Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close)
size_t expectedBytes = channel_idx * 68; // 4 + 32 + 32 = 68 bytes per channel
File verify = openRead(fs, tmpPath);
size_t bytesWritten = verify ? verify.size() : 0;
if (verify) verify.close();
if (!writeOk || bytesWritten != expectedBytes) {
Serial.printf("DataStore: saveChannels ABORTED — wrote %d bytes, expected %d\n",
(int)bytesWritten, (int)expectedBytes);
fs->remove(tmpPath);
return;
}
fs->remove(finalPath);
if (fs->rename(tmpPath, finalPath)) {
Serial.printf("DataStore: saved %d channels (%d bytes)\n", channel_idx, (int)bytesWritten);
} else {
Serial.println("DataStore: channels rename failed, tmp file preserved");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,14 +5,14 @@
#include "AbstractUITask.h"
/*------------ Frame Protocol --------------*/
#define FIRMWARE_VER_CODE 8
#define FIRMWARE_VER_CODE 10
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "27 Feb 2026"
#define FIRMWARE_BUILD_DATE "8 March 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.9.5"
#define FIRMWARE_VERSION "Meck v1.0"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -84,6 +84,16 @@ struct AdvertPath {
uint8_t path[MAX_PATH_SIZE];
};
// Discovery scan — transient buffer for on-device node discovery
#define MAX_DISCOVERED_NODES 20
struct DiscoveredNode {
ContactInfo contact;
uint8_t path_len;
int8_t snr; // SNR × 4 from active discovery response (0 if pre-seeded)
bool already_in_contacts; // true if contact was auto-added or already known
};
class MyMesh : public BaseChatMesh, public DataStoreHost {
public:
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL);
@@ -101,6 +111,14 @@ public:
void enterCLIRescue();
int getRecentlyHeard(AdvertPath dest[], int max_num);
// Discovery scan — on-device node discovery
void startDiscovery(uint32_t duration_ms = 30000);
void stopDiscovery();
bool isDiscoveryActive() const { return _discoveryActive; }
int getDiscoveredCount() const { return _discoveredCount; }
const DiscoveredNode& getDiscovered(int idx) const { return _discovered[idx]; }
bool addDiscoveredToContacts(int idx); // promote a discovered node into contacts
// Queue a sent channel message for BLE app sync
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
@@ -109,7 +127,7 @@ public:
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
// Repeater admin - UI-initiated operations
bool uiLoginToRepeater(uint32_t contact_idx, const char* password);
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; }
@@ -119,7 +137,10 @@ protected:
float getAirtimeBudgetFactor() const override;
int getInterferenceThreshold() const 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;
uint8_t getExtraAckTransmitCount() const override;
uint8_t getAutoAddMaxHops() const override;
bool filterRecvFloodPacket(mesh::Packet* packet) override;
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
@@ -257,6 +278,13 @@ private:
SentMsgTrack _sent_track[SENT_TRACK_SIZE];
int _sent_track_idx; // next slot in circular buffer
int _admin_contact_idx; // contact index for active admin session (-1 if none)
// Discovery scan state
DiscoveredNode _discovered[MAX_DISCOVERED_NODES];
int _discoveredCount;
bool _discoveryActive;
unsigned long _discoveryTimeout;
uint32_t _discoveryTag; // random correlation tag for active discovery
};
extern MyMesh the_mesh;

View File

@@ -30,4 +30,9 @@ struct NodePrefs { // persisted to file
uint8_t autoadd_config; // bitmask for auto-add contacts config
int8_t utc_offset_hours; // UTC offset in hours (-12 to +14), default 0
uint8_t kb_flash_notify; // Keyboard backlight flash on new message (0=off, 1=on)
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)
};

View File

@@ -1,10 +1,11 @@
#include <Arduino.h> // needed for PlatformIO
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
#ifdef BLE_PIN_CODE
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
#endif
#include <Mesh.h>
#include "MyMesh.h"
#include "variant.h" // Board-specific defines (HAS_GPS, etc.)
#include "target.h" // For sensors, board, etc.
#include "GPSDutyCycle.h"
#include "CPUPowerManager.h"
// T-Deck Pro Keyboard support
@@ -17,6 +18,7 @@
#include "ChannelScreen.h"
#include "SettingsScreen.h"
#include "RepeaterAdminScreen.h"
#include "DiscoveryScreen.h"
#ifdef MECK_WEB_READER
#include "WebReaderScreen.h"
#endif
@@ -88,10 +90,6 @@
TouchInput touchInput(&Wire);
#endif
// Power management
#if HAS_GPS
GPSDutyCycle gpsDuty;
#endif
CPUPowerManager cpuPower;
void initKeyboard();
@@ -205,7 +203,10 @@
// Returns number of contacts exported, or -1 on error.
// -----------------------------------------------------------------------
int exportContactsToSD() {
if (!sdCardReady) return -1;
if (!sdCardReady) {
Serial.println("Export: SD card not ready");
return -1;
}
// Ensure in-memory contacts are flushed to SPIFFS first
the_mesh.saveContacts();
@@ -213,40 +214,53 @@
if (!SD.exists("/meshcore")) SD.mkdir("/meshcore");
// 1) Binary backup: SPIFFS /contacts3 → SD /meshcore/contacts.bin
if (!SPIFFS.exists("/contacts3")) return -1;
if (!copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin")) return -1;
// Non-fatal — text export reads from memory and doesn't need this.
if (SPIFFS.exists("/contacts3")) {
if (copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin")) {
Serial.println("Export: binary backup OK");
} else {
Serial.println("Export: binary copy to SD failed (continuing with text export)");
}
} else {
Serial.println("Export: /contacts3 not found on SPIFFS (skipping binary backup)");
}
// 2) Human-readable listing for inspection on a computer
// Reads from in-memory contact table — always works if SD is writable.
int count = 0;
File txt = SD.open("/meshcore/contacts_export.txt", "w", true);
if (txt) {
txt.printf("Meck Contacts Export (%d total)\n", (int)the_mesh.getNumContacts());
txt.printf("========================================\n");
txt.printf("%-5s %-30s %s\n", "Type", "Name", "PubKey (prefix)");
txt.printf("----------------------------------------\n");
ContactInfo c;
for (uint32_t i = 0; i < (uint32_t)the_mesh.getNumContacts(); i++) {
if (the_mesh.getContactByIdx(i, c)) {
const char* typeStr = "???";
switch (c.type) {
case ADV_TYPE_CHAT: typeStr = "Chat"; break;
case ADV_TYPE_REPEATER: typeStr = "Rptr"; break;
case ADV_TYPE_ROOM: typeStr = "Room"; break;
}
// First 8 bytes of pub key as hex identifier
char hexBuf[20];
mesh::Utils::toHex(hexBuf, c.id.pub_key, 8);
txt.printf("%-5s %-30s %s\n", typeStr, c.name, hexBuf);
count++;
}
}
txt.printf("========================================\n");
txt.printf("Total: %d contacts\n", count);
txt.close();
if (!txt) {
Serial.println("Export: failed to open contacts_export.txt for writing");
digitalWrite(SDCARD_CS, HIGH);
return -1;
}
txt.printf("Meck Contacts Export (%d total)\n", (int)the_mesh.getNumContacts());
txt.printf("========================================\n");
txt.printf("%-5s %-30s %s\n", "Type", "Name", "PubKey (prefix)");
txt.printf("----------------------------------------\n");
ContactInfo c;
for (uint32_t i = 0; i < (uint32_t)the_mesh.getNumContacts(); i++) {
if (the_mesh.getContactByIdx(i, c)) {
const char* typeStr = "???";
switch (c.type) {
case ADV_TYPE_CHAT: typeStr = "Chat"; break;
case ADV_TYPE_REPEATER: typeStr = "Rptr"; break;
case ADV_TYPE_ROOM: typeStr = "Room"; break;
}
// First 8 bytes of pub key as hex identifier
char hexBuf[20];
mesh::Utils::toHex(hexBuf, c.id.pub_key, 8);
txt.printf("%-5s %-30s %s\n", typeStr, c.name, hexBuf);
count++;
}
}
txt.printf("========================================\n");
txt.printf("Total: %d contacts\n", count);
txt.close();
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("Contacts exported to SD: %d contacts\n", count);
return count;
@@ -368,6 +382,13 @@ static uint32_t _atoi(const char* sp) {
#ifndef TCP_PORT
#define TCP_PORT 5000
#endif
#elif defined(MECK_WIFI_COMPANION)
#include <WiFi.h>
#include <helpers/esp32/SerialWifiInterface.h>
SerialWifiInterface serial_interface;
#ifndef TCP_PORT
#define TCP_PORT 5000
#endif
#elif defined(BLE_PIN_CODE)
#include <helpers/esp32/SerialBLEInterface.h>
SerialBLEInterface serial_interface;
@@ -415,6 +436,7 @@ static uint32_t _atoi(const char* sp) {
/* GLOBAL OBJECTS */
#ifdef DISPLAY_CLASS
#include "UITask.h"
#include "MapScreen.h" // After BLE — PNGdec headers conflict with BLE if included earlier
UITask ui_task(&board, &serial_interface);
#endif
@@ -643,9 +665,44 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - the_mesh.begin() done");
#ifdef WIFI_SSID
MESH_DEBUG_PRINTLN("setup() - WiFi mode");
MESH_DEBUG_PRINTLN("setup() - WiFi mode (compile-time credentials)");
WiFi.begin(WIFI_SSID, WIFI_PWD);
serial_interface.begin(TCP_PORT);
#elif defined(MECK_WIFI_COMPANION)
{
// WiFi companion: load credentials from SD at runtime.
// TCP server starts regardless — companion connects when WiFi comes up.
MESH_DEBUG_PRINTLN("setup() - WiFi companion mode (runtime credentials)");
WiFi.mode(WIFI_STA);
if (sdCardReady) {
File f = SD.open("/web/wifi.cfg", FILE_READ);
if (f) {
String ssid = f.readStringUntil('\n'); ssid.trim();
String pass = f.readStringUntil('\n'); pass.trim();
f.close();
digitalWrite(SDCARD_CS, HIGH);
if (ssid.length() > 0) {
MESH_DEBUG_PRINTLN("setup() - WiFi: connecting to '%s'", ssid.c_str());
WiFi.begin(ssid.c_str(), pass.c_str());
unsigned long timeout = millis() + 8000;
while (WiFi.status() != WL_CONNECTED && millis() < timeout) {
delay(100);
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("WiFi companion: connected to %s, IP: %s\n",
ssid.c_str(), WiFi.localIP().toString().c_str());
} else {
Serial.println("WiFi companion: auto-connect failed (configure in Settings)");
}
}
} else {
digitalWrite(SDCARD_CS, HIGH);
Serial.println("WiFi companion: no /web/wifi.cfg found (configure in Settings)");
}
}
serial_interface.begin(TCP_PORT);
MESH_DEBUG_PRINTLN("setup() - WiFi TCP server started on port %d", TCP_PORT);
}
#elif defined(BLE_PIN_CODE)
MESH_DEBUG_PRINTLN("setup() - about to call serial_interface.begin() with BLE");
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
@@ -673,8 +730,12 @@ void setup() {
// We need to reinitialize Serial2 to reclaim them
#if HAS_GPS
Serial2.end(); // Close any existing Serial2
Serial2.begin(GPS_BAUDRATE, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
MESH_DEBUG_PRINTLN("setup() - Reinitialized Serial2 for GPS after sensors.begin()");
{
uint32_t gps_baud = the_mesh.getNodePrefs()->gps_baudrate;
if (gps_baud == 0) gps_baud = GPS_BAUDRATE;
Serial2.begin(gps_baud, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
MESH_DEBUG_PRINTLN("setup() - Reinitialized Serial2 for GPS at %lu baud", (unsigned long)gps_baud);
}
#endif
#ifdef DISPLAY_CLASS
@@ -781,18 +842,22 @@ void setup() {
}
#endif
// GPS duty cycle — honour saved pref, default to enabled on first boot
// GPS power — honour saved pref, default to enabled on first boot
#if HAS_GPS
{
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
gpsDuty.setStreamCounter(&gpsStream);
gpsDuty.begin(gps_wanted);
if (gps_wanted) {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
#endif
sensors.setSettingValue("gps", "1");
} else {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
sensors.setSettingValue("gps", "0");
}
MESH_DEBUG_PRINTLN("setup() - GPS duty cycle started (enabled=%d)", gps_wanted);
MESH_DEBUG_PRINTLN("setup() - GPS power %s", gps_wanted ? "on" : "off");
}
#endif
@@ -811,20 +876,35 @@ void setup() {
void loop() {
the_mesh.loop();
// GPS duty cycle — check for fix and manage power state
#if HAS_GPS
{
bool gps_hw_on = gpsDuty.loop();
if (gps_hw_on) {
LocationProvider* lp = sensors.getLocationProvider();
if (lp != NULL && lp->isValid()) {
gpsDuty.notifyFix();
sensors.loop();
// Map screen: periodically update own GPS position and contact markers
if (ui_task.isOnMapScreen()) {
static unsigned long lastMapUpdate = 0;
if (millis() - lastMapUpdate > 30000) { // Every 30 seconds
lastMapUpdate = millis();
MapScreen* ms = (MapScreen*)ui_task.getMapScreen();
if (ms) {
// Update own GPS position when GPS is enabled
#if HAS_GPS
ms->updateGPSPosition(sensors.node_lat, sensors.node_lon);
#endif
// Always refresh contact markers (new contacts arrive via radio)
ms->clearMarkers();
ContactsIterator it = the_mesh.startContactsIterator();
ContactInfo ci;
while (it.hasNext(&the_mesh, ci)) {
if (ci.gps_lat != 0 || ci.gps_lon != 0) {
double lat = ((double)ci.gps_lat) / 1000000.0;
double lon = ((double)ci.gps_lon) / 1000000.0;
ms->addMarker(lat, lon, ci.name, ci.type);
}
}
}
}
}
#endif
sensors.loop();
// CPU frequency auto-timeout back to idle
cpuPower.loop();
@@ -1682,7 +1762,12 @@ void handleKeyboardInput() {
Serial.println("Opening web reader");
{
static bool webReaderWifiReady = false;
#ifdef MECK_WIFI_COMPANION
// WiFi companion: WiFi is already up from boot, no BLE to tear down
webReaderWifiReady = true;
#endif
if (!webReaderWifiReady) {
#ifdef BLE_PIN_CODE
// WiFi needs ~40KB contiguous heap. The BLE controller holds ~30KB,
// leaving only ~30KB largest block. We MUST release BLE memory first.
//
@@ -1701,14 +1786,14 @@ void handleKeyboardInput() {
Serial.printf("WebReader: heap AFTER BT release: free=%d, largest=%d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
#endif
// 3) Now init WiFi while we have maximum contiguous heap
// Init WiFi while we have maximum contiguous heap
if (WiFi.mode(WIFI_STA)) {
Serial.println("WebReader: WiFi STA init OK");
webReaderWifiReady = true;
} else {
Serial.println("WebReader: WiFi STA init FAILED even after BT release");
// Clean up partial WiFi init to avoid memory leak
Serial.println("WebReader: WiFi STA init FAILED");
WiFi.mode(WIFI_OFF);
}
@@ -1720,6 +1805,39 @@ void handleKeyboardInput() {
break;
#endif
case 'g':
// Open map screen, or re-center on GPS if already on map
if (ui_task.isOnMapScreen()) {
ui_task.injectKey('g'); // Re-center on GPS
} else {
Serial.println("Opening map");
{
MapScreen* ms = (MapScreen*)ui_task.getMapScreen();
if (ms) {
ms->setSDReady(sdCardReady);
ms->setGPSPosition(sensors.node_lat,
sensors.node_lon);
// Populate contact markers via iterator
ms->clearMarkers();
ContactsIterator it = the_mesh.startContactsIterator();
ContactInfo ci;
int markerCount = 0;
while (it.hasNext(&the_mesh, ci)) {
if (ci.gps_lat != 0 || ci.gps_lon != 0) {
double lat = ((double)ci.gps_lat) / 1000000.0;
double lon = ((double)ci.gps_lon) / 1000000.0;
ms->addMarker(lat, lon, ci.name, ci.type);
markerCount++;
Serial.printf(" marker: %s @ %.4f,%.4f (type=%d)\n", ci.name, lat, lon, ci.type);
}
}
Serial.printf("MapScreen: %d contacts with GPS position\n", markerCount);
}
}
ui_task.gotoMapScreen();
}
break;
case 'n':
// Open notes
Serial.println("Opening notes");
@@ -1735,11 +1853,13 @@ void handleKeyboardInput() {
break;
case 's':
// Open settings (from home), or navigate down on channel/contacts/admin/web
// Open settings (from home), or navigate down on channel/contacts/admin/web/map/discovery
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|| ui_task.isOnDiscoveryScreen()
#ifdef MECK_WEB_READER
|| ui_task.isOnWebReader()
#endif
|| ui_task.isOnMapScreen()
) {
ui_task.injectKey('s'); // Pass directly for scrolling
} else {
@@ -1751,9 +1871,11 @@ void handleKeyboardInput() {
case 'w':
// Navigate up/previous (scroll on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|| ui_task.isOnDiscoveryScreen()
#ifdef MECK_WEB_READER
|| ui_task.isOnWebReader()
#endif
|| ui_task.isOnMapScreen()
) {
ui_task.injectKey('w'); // Pass directly for scrolling
} else {
@@ -1764,7 +1886,7 @@ void handleKeyboardInput() {
case 'a':
// Navigate left or switch channel (on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()) {
ui_task.injectKey('a'); // Pass directly for channel/contacts switching
} else {
Serial.println("Nav: Previous");
@@ -1774,7 +1896,7 @@ void handleKeyboardInput() {
case 'd':
// Navigate right or switch channel (on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()) {
ui_task.injectKey('d'); // Pass directly for channel/contacts switching
} else {
Serial.println("Nav: Next");
@@ -1857,15 +1979,41 @@ void handleKeyboardInput() {
}
drawComposeScreen();
lastComposeRefresh = millis();
} else if (ui_task.isOnDiscoveryScreen()) {
// Discovery screen: Enter adds selected node to contacts
DiscoveryScreen* ds = (DiscoveryScreen*)ui_task.getDiscoveryScreen();
int didx = ds->getSelectedIdx();
if (didx >= 0 && didx < the_mesh.getDiscoveredCount()) {
const DiscoveredNode& node = the_mesh.getDiscovered(didx);
if (node.already_in_contacts) {
ui_task.showAlert("Already in contacts", 800);
} else if (the_mesh.addDiscoveredToContacts(didx)) {
char alertBuf[48];
snprintf(alertBuf, sizeof(alertBuf), "Added: %s", node.contact.name);
ui_task.showAlert(alertBuf, 1500);
ui_task.notify(UIEventType::ack);
} else {
ui_task.showAlert("Add failed", 1000);
}
}
} else {
// Other screens: pass Enter as generic select
ui_task.injectKey(13);
}
break;
case 'z':
// Zoom in on map screen
if (ui_task.isOnMapScreen()) {
ui_task.injectKey('z');
}
break;
case 'x':
// Export contacts to SD card (contacts screen only)
if (ui_task.isOnContactsScreen()) {
// Zoom out on map screen, or export contacts on contacts screen
if (ui_task.isOnMapScreen()) {
ui_task.injectKey('x');
} else if (ui_task.isOnContactsScreen()) {
Serial.println("Contacts: Exporting to SD...");
int exported = exportContactsToSD();
if (exported >= 0) {
@@ -1873,7 +2021,7 @@ void handleKeyboardInput() {
snprintf(alertBuf, sizeof(alertBuf), "Exported %d to SD", exported);
ui_task.showAlert(alertBuf, 2000);
} else {
ui_task.showAlert("Export failed (no SD?)", 2000);
ui_task.showAlert("Export failed (check serial)", 2000);
}
}
break;
@@ -1901,6 +2049,17 @@ void handleKeyboardInput() {
}
break;
case 'f':
// Start discovery scan from contacts screen, or rescan on discovery screen
if (ui_task.isOnContactsScreen()) {
Serial.println("Contacts: Starting discovery scan...");
the_mesh.startDiscovery();
ui_task.gotoDiscoveryScreen();
} else if (ui_task.isOnDiscoveryScreen()) {
ui_task.injectKey('f'); // pass through for rescan
}
break;
case 'q':
case '\b':
// If channel screen reply select or path overlay is showing, dismiss it
@@ -1926,6 +2085,13 @@ void handleKeyboardInput() {
}
}
#endif
// Discovery screen: Q goes back to contacts (not home)
if (ui_task.isOnDiscoveryScreen()) {
the_mesh.stopDiscovery();
Serial.println("Nav: Discovery -> Contacts");
ui_task.gotoContactsScreen();
break;
}
// Go back to home screen (admin mode handled above)
Serial.println("Nav: Back to home");
ui_task.gotoHomeScreen();
@@ -1957,6 +2123,11 @@ void handleKeyboardInput() {
break;
}
#endif
// Pass unhandled keys to map screen (+, -, i, o for zoom)
if (ui_task.isOnMapScreen()) {
ui_task.injectKey(key);
break;
}
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
break;
}

View File

@@ -4,6 +4,7 @@
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ChannelDetails.h>
#include <MeshCore.h>
#include <Packet.h>
#include "EmojiSprites.h"
// SD card message persistence
@@ -24,7 +25,7 @@
// On-disk format for message persistence (SD card)
// ---------------------------------------------------------------------------
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
#define MSG_FILE_VERSION 3 // v3: MSG_PATH_MAX increased to 20
#define MSG_FILE_VERSION 3 // v3: MSG_PATH_MAX=20, reserved→snr field
#define MSG_FILE_PATH "/meshcore/messages.bin"
struct __attribute__((packed)) MsgFileHeader {
@@ -41,7 +42,7 @@ struct __attribute__((packed)) MsgFileRecord {
uint8_t path_len;
uint8_t channel_idx;
uint8_t valid;
uint8_t reserved;
int8_t snr; // Receive SNR × 4 (was reserved; 0 = unknown)
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes (first byte of pub key)
char text[CHANNEL_MSG_TEXT_LEN];
// 188 bytes total
@@ -57,6 +58,7 @@ public:
uint32_t timestamp;
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)
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes
char text[CHANNEL_MSG_TEXT_LEN];
bool valid;
@@ -105,7 +107,7 @@ public:
// Add a new message to the history
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
const uint8_t* path_bytes = nullptr) {
const uint8_t* path_bytes = nullptr, int8_t snr = 0) {
// Move to next slot in circular buffer
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
@@ -113,12 +115,14 @@ public:
msg->timestamp = _rtc->getCurrentTime();
msg->path_len = path_len;
msg->channel_idx = channel_idx;
msg->snr = snr;
msg->valid = true;
// Store path hop hashes
memset(msg->path, 0, MSG_PATH_MAX);
if (path_bytes && path_len > 0 && path_len != 0xFF) {
int n = path_len < MSG_PATH_MAX ? path_len : MSG_PATH_MAX;
int n = mesh::Packet::getPathByteLenFor(path_len);
if (n > MSG_PATH_MAX) n = MSG_PATH_MAX;
memcpy(msg->path, path_bytes, n);
}
@@ -289,11 +293,15 @@ public:
if (!msg || msg->path_len == 0 || msg->path_len == 0xFF) return 0;
int pos = 0;
int plen = msg->path_len < MSG_PATH_MAX ? msg->path_len : MSG_PATH_MAX;
uint8_t hopCount = msg->path_len & 63;
uint8_t bytesPerHop = (msg->path_len >> 6) + 1;
for (int h = 0; h < plen && pos < bufLen - 1; h++) {
for (int h = 0; h < hopCount && pos < bufLen - 1; h++) {
if (h > 0) pos += snprintf(buf + pos, bufLen - pos, ", ");
pos += snprintf(buf + pos, bufLen - pos, "%02x", msg->path[h]);
int offset = h * bytesPerHop;
for (int b = 0; b < bytesPerHop && pos < bufLen - 1; b++) {
pos += snprintf(buf + pos, bufLen - pos, "%02x", msg->path[offset + b]);
}
}
return pos;
@@ -336,7 +344,7 @@ public:
rec.path_len = _messages[i].path_len;
rec.channel_idx = _messages[i].channel_idx;
rec.valid = _messages[i].valid ? 1 : 0;
rec.reserved = 0;
rec.snr = _messages[i].snr;
memcpy(rec.path, _messages[i].path, MSG_PATH_MAX);
memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN);
f.write((uint8_t*)&rec, sizeof(rec));
@@ -403,6 +411,7 @@ public:
_messages[i].path_len = rec.path_len;
_messages[i].channel_idx = rec.channel_idx;
_messages[i].valid = (rec.valid != 0);
_messages[i].snr = rec.snr;
memcpy(_messages[i].path, rec.path, MSG_PATH_MAX);
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
if (_messages[i].valid) loaded++;
@@ -491,6 +500,8 @@ public:
// Route type
display.setCursor(0, y);
uint8_t plen = msg->path_len;
uint8_t hopCount = plen & 63; // extract hop count from encoded path_len
uint8_t bytesPerHop = (plen >> 6) + 1; // 1, 2, or 3 bytes per hop
if (plen == 0xFF) {
display.setColor(DisplayDriver::LIGHT);
display.print("Route: Direct");
@@ -499,14 +510,26 @@ public:
display.print("Route: Local/Sent");
} else {
display.setColor(DisplayDriver::GREEN);
sprintf(tmp, "Route: %d hop%s", plen, plen == 1 ? "" : "s");
sprintf(tmp, "Route: %d hop%s (%dB)", hopCount, hopCount == 1 ? "" : "s", bytesPerHop);
display.print(tmp);
}
y += lineH + 2;
y += lineH;
// SNR (if available — value is SNR×4)
if (msg->snr != 0) {
display.setCursor(0, y);
display.setColor(DisplayDriver::YELLOW);
int snr_whole = msg->snr / 4;
int snr_frac = ((abs(msg->snr) % 4) * 10) / 4;
sprintf(tmp, "SNR: %d.%ddB", snr_whole, snr_frac);
display.print(tmp);
y += lineH;
}
y += 2;
// Show each hop resolved against contacts (scrollable)
if (plen > 0 && plen != 0xFF) {
int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX;
if (hopCount > 0 && plen != 0xFF) {
int displayHops = hopCount;
int footerReserve = 26; // footer + divider
int scrollBarW = 4;
int maxY = display.height() - footerReserve;
@@ -532,28 +555,37 @@ public:
if (endHop > displayHops) endHop = displayHops;
for (int h = startHop; h < endHop && y + lineH <= maxY; h++) {
uint8_t hopHash = msg->path[h];
int hopOffset = h * bytesPerHop; // byte offset into path[]
display.setCursor(0, y);
display.setColor(DisplayDriver::LIGHT);
sprintf(tmp, " %d: ", h + 1);
display.print(tmp);
// Always show hex prefix first
// Show hex prefix (1, 2, or 3 bytes)
display.setColor(DisplayDriver::LIGHT);
sprintf(tmp, "%02X ", hopHash);
if (bytesPerHop == 1) {
sprintf(tmp, "%02X ", msg->path[hopOffset]);
} else if (bytesPerHop == 2) {
sprintf(tmp, "%02X%02X ", msg->path[hopOffset], msg->path[hopOffset + 1]);
} else {
sprintf(tmp, "%02X%02X%02X ", msg->path[hopOffset], msg->path[hopOffset + 1], msg->path[hopOffset + 2]);
}
display.print(tmp);
// Try to resolve name: prefer repeaters, then any contact
bool resolved = false;
int numContacts = the_mesh.getNumContacts();
ContactInfo contact;
char filteredName[32];
// First pass: repeaters only
for (uint32_t ci = 0; ci < numContacts && !resolved; ci++) {
if (the_mesh.getContactByIdx(ci, contact)) {
if (contact.id.pub_key[0] == hopHash && contact.type == ADV_TYPE_REPEATER) {
if (memcmp(contact.id.pub_key, &msg->path[hopOffset], bytesPerHop) == 0
&& contact.type == ADV_TYPE_REPEATER) {
display.setColor(DisplayDriver::GREEN);
display.print(contact.name);
display.translateUTF8ToBlocks(filteredName, contact.name, sizeof(filteredName));
display.print(filteredName);
resolved = true;
}
}
@@ -562,9 +594,10 @@ public:
if (!resolved) {
for (uint32_t ci = 0; ci < numContacts; ci++) {
if (the_mesh.getContactByIdx(ci, contact)) {
if (contact.id.pub_key[0] == hopHash) {
if (memcmp(contact.id.pub_key, &msg->path[hopOffset], bytesPerHop) == 0) {
display.setColor(DisplayDriver::YELLOW);
display.print(contact.name);
display.translateUTF8ToBlocks(filteredName, contact.name, sizeof(filteredName));
display.print(filteredName);
resolved = true;
break;
}
@@ -608,7 +641,7 @@ public:
display.setColor(DisplayDriver::YELLOW);
display.print("Q:Back");
// Show scroll hint if path is scrollable
if (msg && msg->path_len > _pathHopsVisible && msg->path_len != 0xFF) {
if (msg && (msg->path_len & 63) > _pathHopsVisible && msg->path_len != 0xFF) {
const char* scrollHint = "W/S:Scrl";
int scrollW = display.getTextWidth(scrollHint);
display.setCursor((display.width() - scrollW) / 2, footerY);
@@ -723,13 +756,13 @@ public:
}
} else {
if (age < 60) {
sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : msg->path_len, age);
sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age);
} else if (age < 3600) {
sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 60);
sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 60);
} else if (age < 86400) {
sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 3600);
sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 3600);
} else {
sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400);
sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 86400);
}
}
display.print(tmp);
@@ -952,7 +985,7 @@ public:
if (c == 's' || c == 'S' || c == 0xF1) {
ChannelMessage* msg = getNewestReceivedMsg();
if (msg && msg->path_len > 0 && msg->path_len != 0xFF) {
int totalHops = msg->path_len < MSG_PATH_MAX ? msg->path_len : MSG_PATH_MAX;
int totalHops = msg->path_len & 63;
if (_pathScrollPos < totalHops - _pathHopsVisible) {
_pathScrollPos++;
}

View File

@@ -297,17 +297,17 @@ public:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
// Left: Q:Back
// Left: Q:Bk
display.setCursor(0, footerY);
display.print("Q:Back");
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: W/S:Scroll
const char* right = "W/S:Scrll";
// Right: F:Dscvr
const char* right = "F:Dscvr";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);

View File

@@ -0,0 +1,205 @@
#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/AdvertDataHelpers.h>
#include <MeshCore.h>
// Forward declarations
class UITask;
class MyMesh;
extern MyMesh the_mesh;
class DiscoveryScreen : public UIScreen {
UITask* _task;
mesh::RTCClock* _rtc;
int _scrollPos;
int _rowsPerPage;
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 '?';
}
}
static const char* typeLabel(uint8_t adv_type) {
switch (adv_type) {
case ADV_TYPE_CHAT: return "Chat";
case ADV_TYPE_REPEATER: return "Rptr";
case ADV_TYPE_ROOM: return "Room";
case ADV_TYPE_SENSOR: return "Sens";
default: return "?";
}
}
public:
DiscoveryScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _scrollPos(0), _rowsPerPage(5) {}
void resetScroll() { _scrollPos = 0; }
int getSelectedIdx() const { return _scrollPos; }
int render(DisplayDriver& display) override {
int count = the_mesh.getDiscoveredCount();
bool active = the_mesh.isDiscoveryActive();
// === Header ===
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
char hdr[32];
if (active) {
snprintf(hdr, sizeof(hdr), "Scanning... %d found", count);
} else {
snprintf(hdr, sizeof(hdr), "Scan done: %d found", count);
}
display.print(hdr);
// Divider
display.drawRect(0, 11, display.width(), 1);
// === Body — discovered node rows ===
display.setTextSize(0); // tiny font for compact rows
int lineHeight = 9;
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
int y = headerHeight;
int rowsDrawn = 0;
if (count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 28);
display.print(active ? "Listening for adverts..." : "No nodes found");
if (!active) {
display.setCursor(4, 38);
display.print("F: Scan again Q: Back");
}
} else {
// Center visible window around selected item
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 DiscoveredNode& node = the_mesh.getDiscovered(i);
bool selected = (i == _scrollPos);
// Highlight selected row
if (selected) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y + 5, display.width(), lineHeight);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
// Prefix: cursor + type
char prefix[4];
if (selected) {
snprintf(prefix, sizeof(prefix), ">%c", typeChar(node.contact.type));
} else {
snprintf(prefix, sizeof(prefix), " %c", typeChar(node.contact.type));
}
display.print(prefix);
// Build right-side info: SNR or hop count + status
char rightStr[16];
if (node.snr != 0) {
// Active discovery result — show SNR in dB (value is ×4 scaled)
int snr_db = node.snr / 4;
if (node.already_in_contacts) {
snprintf(rightStr, sizeof(rightStr), "%ddB [+]", snr_db);
} else {
snprintf(rightStr, sizeof(rightStr), "%ddB", snr_db);
}
} else {
// Pre-seeded from cache — show hop count
if (node.already_in_contacts) {
snprintf(rightStr, sizeof(rightStr), "%dh [+]", node.path_len & 63);
} else {
snprintf(rightStr, sizeof(rightStr), "%dh", node.path_len & 63);
}
}
int rightWidth = display.getTextWidth(rightStr) + 2;
// Name (truncated with ellipsis)
char filteredName[32];
display.translateUTF8ToBlocks(filteredName, node.contact.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;
rowsDrawn++;
}
_rowsPerPage = (rowsDrawn > 0) ? rowsDrawn : 1;
}
display.setTextSize(1); // restore for footer
// === Footer ===
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
display.print("Q:Back");
const char* mid = "Ent:Add";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
const char* right = "F:Rescan";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
// Faster refresh while actively scanning
return active ? 1000 : 5000;
}
bool handleInput(char c) override {
int count = the_mesh.getDiscoveredCount();
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_scrollPos > 0) {
_scrollPos--;
return true;
}
}
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_scrollPos < count - 1) {
_scrollPos++;
return true;
}
}
// F - rescan (handled here as well as in main.cpp for consistency)
if (c == 'f') {
the_mesh.startDiscovery();
_scrollPos = 0;
return true;
}
// Enter - handled by main.cpp for alert feedback
return false; // Q/back and Enter handled by main.cpp
}
};

View File

@@ -0,0 +1,886 @@
#pragma once
// =============================================================================
// MapScreen — OSM Tile Map for T-Deck Pro E-Ink Display
// =============================================================================
//
// Renders standard OSM "slippy map" PNG tiles from SD card onto the e-ink
// display at native 240×320 resolution (bypassing the 128×128 logical grid).
//
// Tiles are B&W PNGs stored at /tiles/{zoom}/{x}/{y}.png — the same format
// used by Ripple, tdeck-maps, and MTD-Script tile downloaders.
//
// REQUIREMENTS:
// 1. Add PNGdec library to platformio.ini:
// lib_deps = ... bitbank2/PNGdec@^1.0.1
//
// 2. Add raw display access to GxEPDDisplay.h (public section):
// // --- Raw pixel access for MapScreen (bypasses scaling) ---
// void drawPixelRaw(int16_t x, int16_t y, uint16_t color) {
// display.drawPixel(x, y, color);
// }
// int16_t rawWidth() { return display.width(); }
// int16_t rawHeight() { return display.height(); }
// // Force endFrame() to push to display even if CRC unchanged
// // (needed because drawPixelRaw bypasses CRC tracking)
// void invalidateFrameCRC() { last_display_crc_value = 0; }
//
// 3. Add to UITask.h:
// #include "MapScreen.h"
// UIScreen* map_screen;
// void gotoMapScreen();
// bool isOnMapScreen() const { return curr == map_screen; }
// UIScreen* getMapScreen() const { return map_screen; }
//
// 4. Initialise in UITask::begin():
// map_screen = new MapScreen(this);
//
// 5. Implement UITask::gotoMapScreen() following gotoTextReader() pattern.
//
// 6. Hook 'g' key in main.cpp for GPS/Map access:
// case 'g':
// if (ui_task.isOnMapScreen()) {
// // Already on map — 'g' re-centers on GPS
// ui_task.injectKey('g');
// } else {
// Serial.println("Opening map");
// {
// MapScreen* ms = (MapScreen*)ui_task.getMapScreen();
// if (ms) {
// ms->setSDReady(sdCardReady);
// ms->setGPSPosition(sensors.node_lat,
// sensors.node_lon);
// // Populate contact markers via iterator
// ms->clearMarkers();
// ContactsIterator it = the_mesh.startContactsIterator();
// ContactInfo ci;
// while (it.hasNext(&the_mesh, ci)) {
// double lat = ((double)ci.gps_lat) / 1000000.0;
// double lon = ((double)ci.gps_lon) / 1000000.0;
// ms->addMarker(lat, lon, ci.name, ci.type);
// }
// }
// }
// ui_task.gotoMapScreen();
// }
// break;
//
// 7. Route WASD/zoom keys to map screen in main.cpp (in existing handlers):
// For 'w', 's', 'a', 'd' cases, add:
// if (ui_task.isOnMapScreen()) { ui_task.injectKey(key); break; }
// For the default case, add map screen passthrough:
// if (ui_task.isOnMapScreen()) { ui_task.injectKey(key); break; }
// This covers +, -, i, o, g (re-center) keys too.
//
// TILE SOURCES (B&W recommended for e-ink):
// - MTD-Script: github.com/fistulareffigy/MTD-Script
// - tdeck-maps: github.com/JustDr00py/tdeck-maps
// - Stamen Toner style gives best e-ink contrast
// =============================================================================
#include <Arduino.h>
#include <SD.h>
#include <PNGdec.h>
#undef local // PNGdec's zutil.h defines 'local' as 'static' — breaks any variable named 'local'
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/GxEPDDisplay.h>
// ---------------------------------------------------------------------------
// Layout constants (physical pixel coordinates, 240×320 display)
// ---------------------------------------------------------------------------
#define MAP_DISPLAY_W 240
#define MAP_DISPLAY_H 320
// Footer bar occupies the bottom — matches other screens' setTextSize(1) footer
#define MAP_FOOTER_H 24 // ~24px at bottom for nav hints
#define MAP_VIEWPORT_Y 0 // Map starts at top
#define MAP_VIEWPORT_H (MAP_DISPLAY_H - MAP_FOOTER_H) // 296px for map
#define MAP_TILE_SIZE 256 // Standard OSM tile size in pixels
#define MAP_DEFAULT_ZOOM 13
#define MAP_MIN_ZOOM 1
#define MAP_MAX_ZOOM 17
// PNG decode buffer size — 256×256 RGB = 196KB, but PNGdec streams row-by-row
// We only need a line buffer. Allocate in PSRAM for safety.
#define MAP_PNG_BUF_SIZE (65536) // 64KB for PNG file read buffer
// Tile path on SD card
#define MAP_TILE_ROOT "/tiles"
// Contact type (for label display — matches AdvertDataHelpers.h)
#ifndef ADV_TYPE_REPEATER
#define ADV_TYPE_REPEATER 2
#endif
// Pan step: fraction of viewport to move per keypress
#define MAP_PAN_FRACTION 4 // 1/4 of viewport per press
// Max contact markers (PSRAM-allocated, ~37 bytes each)
#define MAP_MAX_MARKERS 500
class MapScreen : public UIScreen {
public:
MapScreen(UITask* task)
: _task(task),
_einkDisplay(nullptr),
_sdReady(false),
_needsRedraw(true),
_hasFix(false),
_centerLat(-33.8688), // Default: Sydney (most Ripple users)
_centerLon(151.2093),
_gpsLat(0.0),
_gpsLon(0.0),
_zoom(MAP_DEFAULT_ZOOM),
_zoomMin(MAP_MIN_ZOOM),
_zoomMax(MAP_MAX_ZOOM),
_pngBuf(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");
}
}
~MapScreen() {
if (_pngBuf) { free(_pngBuf); _pngBuf = nullptr; }
if (_markers) { free(_markers); _markers = nullptr; }
}
void setSDReady(bool ready) { _sdReady = ready; }
// Set initial GPS position (called when opening map — centers viewport)
void setGPSPosition(double lat, double lon) {
if (lat != 0.0 || lon != 0.0) {
_gpsLat = lat;
_gpsLon = lon;
_centerLat = lat;
_centerLon = lon;
_hasFix = true;
_needsRedraw = true;
}
}
// Update own GPS position without moving viewport (called periodically)
void updateGPSPosition(double lat, double lon) {
if (lat == 0.0 && lon == 0.0) return;
if (lat != _gpsLat || lon != _gpsLon) {
_gpsLat = lat;
_gpsLon = lon;
_hasFix = true;
_needsRedraw = true; // Redraw to move own-position marker
}
}
// 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;
if (lat == 0.0 && lon == 0.0) return; // Skip no-location contacts
_markers[_numMarkers].lat = lat;
_markers[_numMarkers].lon = lon;
_markers[_numMarkers].type = type;
strncpy(_markers[_numMarkers].name, name, sizeof(_markers[0].name) - 1);
_markers[_numMarkers].name[sizeof(_markers[0].name) - 1] = '\0';
_numMarkers++;
}
// Refresh contact markers (called periodically from main loop)
// Clears and rebuilds — caller iterates contacts and calls addMarker()
int getNumMarkers() const { return _numMarkers; }
// Called when navigating to map screen
void enter(DisplayDriver& display) {
_einkDisplay = static_cast<GxEPDDisplay*>(&display);
_needsRedraw = true;
// Allocate PNG read buffer in PSRAM on first use
if (!_pngBuf) {
_pngBuf = (uint8_t*)ps_malloc(MAP_PNG_BUF_SIZE);
if (!_pngBuf) {
Serial.println("MapScreen: PSRAM alloc failed, trying heap");
_pngBuf = (uint8_t*)malloc(MAP_PNG_BUF_SIZE);
}
if (_pngBuf) {
Serial.printf("MapScreen: PNG buffer allocated (%d bytes)\n", MAP_PNG_BUF_SIZE);
} else {
Serial.println("MapScreen: PNG buffer alloc FAILED");
}
}
// Detect available zoom levels from SD card directories
detectZoomRange();
}
// ---- UIScreen interface ----
int render(DisplayDriver& display) override {
if (!_einkDisplay) {
_einkDisplay = static_cast<GxEPDDisplay*>(&display);
}
if (!_sdReady) {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(10, 20);
display.print("SD card not found");
display.setCursor(10, 35);
display.print("Insert SD with");
display.setCursor(10, 48);
display.print("/tiles/{z}/{x}/{y}.png");
return 5000;
}
// Always render tiles — UITask clears the buffer via startFrame() before
// calling us, so we must redraw every time (e.g. after alert overlays)
bool wasRedraw = _needsRedraw;
_needsRedraw = false;
// Render map tiles into the viewport
renderMapViewport();
// Overlay contact markers
renderContactMarkers();
// Crosshair at viewport center
renderCrosshair();
// Footer bar (uses normal display API with scaling)
renderFooter(display);
// Raw pixel writes bypass CRC tracking — force refresh
_einkDisplay->invalidateFrameCRC();
// If user panned/zoomed, allow quick re-render; otherwise idle longer
return wasRedraw ? 1000 : 30000;
}
bool handleInput(char c) override {
// Pan distances in degrees — adaptive to zoom level
// At zoom Z, one tile covers 360/2^Z degrees of longitude
double tileLonSpan = 360.0 / (1 << _zoom);
double tileLatSpan = tileLonSpan * cos(_centerLat * PI / 180.0); // Rough approx
// Pan by 1/MAP_PAN_FRACTION of viewport (viewport ≈ 1 tile)
double panLon = tileLonSpan / MAP_PAN_FRACTION;
double panLat = tileLatSpan / MAP_PAN_FRACTION;
switch (c) {
// ---- WASD panning ----
case 'w':
case 'W':
_centerLat += panLat;
if (_centerLat > 85.05) _centerLat = 85.05; // Web Mercator limit
_needsRedraw = true;
return true;
case 's':
case 'S':
_centerLat -= panLat;
if (_centerLat < -85.05) _centerLat = -85.05;
_needsRedraw = true;
return true;
case 'a':
case 'A':
_centerLon -= panLon;
if (_centerLon < -180.0) _centerLon += 360.0;
_needsRedraw = true;
return true;
case 'd':
case 'D':
_centerLon += panLon;
if (_centerLon > 180.0) _centerLon -= 360.0;
_needsRedraw = true;
return true;
// ---- Zoom controls ----
case 'z':
case 'Z':
if (_zoom < _zoomMax) {
_zoom++;
_needsRedraw = true;
Serial.printf("MapScreen: zoom in -> %d\n", _zoom);
}
return true;
case 'x':
case 'X':
if (_zoom > _zoomMin) {
_zoom--;
_needsRedraw = true;
Serial.printf("MapScreen: zoom out -> %d\n", _zoom);
}
return true;
// ---- Re-center on GPS fix ----
case 'g':
if (_hasFix) {
_centerLat = _gpsLat;
_centerLon = _gpsLon;
_needsRedraw = true;
Serial.println("MapScreen: re-center on GPS");
}
return true;
default:
return false;
}
}
private:
UITask* _task;
GxEPDDisplay* _einkDisplay;
bool _sdReady;
bool _needsRedraw;
bool _hasFix;
// Map state
double _centerLat;
double _centerLon;
double _gpsLat; // Own GPS position (separate from viewport center)
double _gpsLon;
int _zoom;
int _zoomMin; // Detected from SD card
int _zoomMax; // Detected from SD card
// PNG decode buffer (PSRAM)
uint8_t* _pngBuf;
bool _tileFound; // Did last tile load succeed?
// PNGdec instance
PNG _png;
// Contacts for marker overlay
struct MapMarker {
double lat;
double lon;
char name[20]; // Truncated display name
uint8_t type; // ADV_TYPE_CHAT, ADV_TYPE_REPEATER, etc.
};
MapMarker* _markers = nullptr; // PSRAM-allocated
int _numMarkers = 0;
// ---- Rendering state passed to PNG callback ----
// PNGdec calls our callback per scanline — we need to know where to draw.
// Also carries a PNG* so the static callback can call getLineAsRGB565().
struct DrawContext {
GxEPDDisplay* display;
PNG* png; // Pointer to the decoder (for getLineAsRGB565)
int offsetX; // Screen X offset for this tile
int offsetY; // Screen Y offset for this tile
int viewportY; // Top of viewport (MAP_VIEWPORT_Y)
int viewportH; // Height of viewport (MAP_VIEWPORT_H)
};
DrawContext _drawCtx;
// ==========================================================================
// Detect available zoom levels from /tiles/{z}/ directories on SD
// ==========================================================================
void detectZoomRange() {
if (!_sdReady) return;
_zoomMin = MAP_MAX_ZOOM;
_zoomMax = MAP_MIN_ZOOM;
char path[32];
for (int z = MAP_MIN_ZOOM; z <= MAP_MAX_ZOOM; z++) {
snprintf(path, sizeof(path), MAP_TILE_ROOT "/%d", z);
if (SD.exists(path)) {
if (z < _zoomMin) _zoomMin = z;
if (z > _zoomMax) _zoomMax = z;
}
}
// If no tiles found, reset to defaults
if (_zoomMin > _zoomMax) {
_zoomMin = MAP_MIN_ZOOM;
_zoomMax = MAP_MAX_ZOOM;
Serial.println("MapScreen: no tile directories found");
} else {
Serial.printf("MapScreen: detected zoom range %d-%d\n", _zoomMin, _zoomMax);
}
// Clamp current zoom to available range
if (_zoom > _zoomMax) _zoom = _zoomMax;
if (_zoom < _zoomMin) _zoom = _zoomMin;
}
// ==========================================================================
// Tile coordinate math (Web Mercator / Slippy Map convention)
// ==========================================================================
// Convert lat/lon to tile X,Y and sub-tile pixel offset at given zoom
static void latLonToTileXY(double lat, double lon, int zoom,
int& tileX, int& tileY,
int& pixelX, int& pixelY)
{
int n = 1 << zoom;
// Tile X (longitude is linear)
double x = (lon + 180.0) / 360.0 * n;
tileX = (int)floor(x);
pixelX = (int)((x - tileX) * MAP_TILE_SIZE);
// Tile Y (latitude uses Mercator projection)
double latRad = lat * PI / 180.0;
double y = (1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / PI) / 2.0 * n;
tileY = (int)floor(y);
pixelY = (int)((y - tileY) * MAP_TILE_SIZE);
}
// Convert tile X,Y + pixel offset back to lat/lon
static void tileXYToLatLon(int tileX, int tileY, int pixelX, int pixelY,
int zoom, double& lat, double& lon)
{
int n = 1 << zoom;
double x = tileX + (double)pixelX / MAP_TILE_SIZE;
double y = tileY + (double)pixelY / MAP_TILE_SIZE;
lon = x / n * 360.0 - 180.0;
double latRad = atan(sinh(PI * (1.0 - 2.0 * y / n)));
lat = latRad * 180.0 / PI;
}
// Convert a lat/lon to pixel position within the current viewport
// Returns false if off-screen
bool latLonToScreen(double lat, double lon, int& screenX, int& screenY) {
int centerTileX, centerTileY, centerPixelX, centerPixelY;
latLonToTileXY(_centerLat, _centerLon, _zoom,
centerTileX, centerTileY, centerPixelX, centerPixelY);
int targetTileX, targetTileY, targetPixelX, targetPixelY;
latLonToTileXY(lat, lon, _zoom,
targetTileX, targetTileY, targetPixelX, targetPixelY);
// Calculate pixel delta from center
int dx = (targetTileX - centerTileX) * MAP_TILE_SIZE + (targetPixelX - centerPixelX);
int dy = (targetTileY - centerTileY) * MAP_TILE_SIZE + (targetPixelY - centerPixelY);
screenX = MAP_DISPLAY_W / 2 + dx;
screenY = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2 + dy;
return (screenX >= 0 && screenX < MAP_DISPLAY_W &&
screenY >= MAP_VIEWPORT_Y && screenY < MAP_VIEWPORT_Y + MAP_VIEWPORT_H);
}
// ==========================================================================
// Tile loading and rendering
// ==========================================================================
// Build tile file path: /tiles/{zoom}/{x}/{y}.png
static void buildTilePath(char* buf, int bufSize, int zoom, int x, int y) {
snprintf(buf, bufSize, MAP_TILE_ROOT "/%d/%d/%d.png", zoom, x, y);
}
// 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;
char path[64];
buildTilePath(path, sizeof(path), _zoom, tileX, tileY);
// Check existence first to avoid noisy ESP32 VFS error logs
if (!SD.exists(path)) return false;
File f = SD.open(path, FILE_READ);
if (!f) return false;
// Read entire PNG into buffer
int fileSize = f.size();
if (fileSize > MAP_PNG_BUF_SIZE) {
Serial.printf("MapScreen: tile too large: %s (%d bytes)\n", path, fileSize);
f.close();
return false;
}
int bytesRead = f.read(_pngBuf, fileSize);
f.close();
if (bytesRead != fileSize) {
Serial.printf("MapScreen: short read: %s (%d/%d)\n", path, bytesRead, fileSize);
return false;
}
// Set up draw context for the PNG callback
_drawCtx.display = _einkDisplay;
_drawCtx.png = &_png;
_drawCtx.offsetX = screenX;
_drawCtx.offsetY = screenY;
_drawCtx.viewportY = MAP_VIEWPORT_Y;
_drawCtx.viewportH = MAP_VIEWPORT_H;
// Open PNG from memory buffer
int rc = _png.openRAM(_pngBuf, fileSize, pngDrawCallback);
if (rc != PNG_SUCCESS) {
Serial.printf("MapScreen: PNG open failed: %s (rc=%d)\n", path, rc);
return false;
}
// Decode — triggers pngDrawCallback for each scanline.
// First arg is user pointer, passed as pDraw->pUser in callback.
rc = _png.decode(&_drawCtx, 0);
_png.close();
if (rc != PNG_SUCCESS) {
Serial.printf("MapScreen: PNG decode failed: %s (rc=%d)\n", path, rc);
return false;
}
return true;
}
// PNGdec scanline callback — called once per row of the decoded image.
// Draws directly to the e-ink display at raw pixel coordinates.
// 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;
int screenY = ctx->offsetY + pDraw->y;
// Clip to viewport vertically
if (screenY < ctx->viewportY || screenY >= ctx->viewportY + ctx->viewportH) return 1;
// Debug: log format on first row of first tile only
if (pDraw->y == 0 && ctx->offsetX >= 0 && ctx->offsetY >= 0) {
static bool logged = false;
if (!logged) {
Serial.printf("MapScreen: PNG iBpp=%d iWidth=%d\n", pDraw->iBpp, pDraw->iWidth);
logged = true;
}
}
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);
for (int x = 0; x < lineWidth; x++) {
int screenX = ctx->offsetX + x;
if (screenX < 0 || screenX >= MAP_DISPLAY_W) continue;
// RGB565 little-endian on ESP32: standard bit layout
// R[15:11] G[10:5] B[4:0]
uint16_t pixel = lineBuf[x];
// For B&W tiles this is 0x0000 (black) or 0xFFFF (white)
// Simple threshold on full 16-bit value handles both cleanly
uint16_t color = (pixel > 0x7FFF) ? GxEPD_WHITE : GxEPD_BLACK;
ctx->display->drawPixelRaw(screenX, screenY, color);
}
return 1;
}
// ==========================================================================
// Viewport rendering — stitch tiles to fill the screen
// ==========================================================================
void renderMapViewport() {
if (!_einkDisplay) return;
// Find which tile the center point falls in
int centerTileX, centerTileY, centerPixelX, centerPixelY;
latLonToTileXY(_centerLat, _centerLon, _zoom,
centerTileX, centerTileY, centerPixelX, centerPixelY);
Serial.printf("MapScreen: center tile %d/%d/%d px(%d,%d)\n",
_zoom, centerTileX, centerTileY, centerPixelX, centerPixelY);
// Screen position where the center tile's (0,0) corner should be placed
// such that the GPS point ends up at viewport center
int viewCenterX = MAP_DISPLAY_W / 2;
int viewCenterY = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2;
int baseTileScreenX = viewCenterX - centerPixelX;
int baseTileScreenY = viewCenterY - centerPixelY;
// Determine tile grid range needed to cover the entire viewport
int startDX = 0, startDY = 0;
int endDX = 0, endDY = 0;
while (baseTileScreenX + startDX * MAP_TILE_SIZE > 0) startDX--;
while (baseTileScreenY + startDY * MAP_TILE_SIZE > MAP_VIEWPORT_Y) startDY--;
while (baseTileScreenX + (endDX + 1) * MAP_TILE_SIZE < MAP_DISPLAY_W) endDX++;
while (baseTileScreenY + (endDY + 1) * MAP_TILE_SIZE < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) endDY++;
int maxTile = (1 << _zoom) - 1;
int loaded = 0, missing = 0;
for (int dy = startDY; dy <= endDY; dy++) {
for (int dx = startDX; dx <= endDX; dx++) {
int tx = centerTileX + dx;
int ty = centerTileY + dy;
// Longitude wraps
if (tx < 0) tx += (1 << _zoom);
if (tx > maxTile) tx -= (1 << _zoom);
// Latitude doesn't wrap — skip out-of-range
if (ty < 0 || ty > maxTile) continue;
int screenX = baseTileScreenX + dx * MAP_TILE_SIZE;
int screenY = baseTileScreenY + dy * MAP_TILE_SIZE;
if (loadAndRenderTile(tx, ty, screenX, screenY)) {
loaded++;
} else {
missing++;
}
}
}
Serial.printf("MapScreen: rendered %d tiles, %d missing\n", loaded, missing);
_tileFound = (loaded > 0);
}
// ==========================================================================
// Contact marker overlay
// ==========================================================================
void renderContactMarkers() {
if (!_einkDisplay || !_markers) return;
int visible = 0;
for (int i = 0; i < _numMarkers; i++) {
int sx, sy;
if (latLonToScreen(_markers[i].lat, _markers[i].lon, sx, sy)) {
int r = markerRadius();
drawDiamond(sx, sy, r);
// Draw name label for repeaters (and at higher zoom for all contacts)
if (_markers[i].name[0] != '\0' &&
(_markers[i].type == ADV_TYPE_REPEATER || _zoom >= 14)) {
drawLabel(sx, sy - r - 2, _markers[i].name);
}
visible++;
}
}
// Render own GPS position as a distinct marker (circle)
if (_hasFix) {
int sx, sy;
if (latLonToScreen(_gpsLat, _gpsLon, sx, sy)) {
drawOwnPosition(sx, sy);
visible++;
}
}
}
// Marker radius scaled by zoom level
// z10→3px, z11→4, z12→5, z13→6, z14→7, z15→8, z16→9, z17→10
int markerRadius() {
int r = _zoom - 7;
if (r < 3) r = 3;
if (r > 10) r = 10;
return r;
}
// Draw a filled diamond marker at screen coordinates with given radius
void drawDiamond(int cx, int cy, int r) {
// White outline first (1px larger than fill)
for (int dy = -(r + 1); dy <= (r + 1); dy++) {
int span = (r + 1) - abs(dy);
int innerSpan = r - abs(dy);
for (int dx = -span; dx <= span; dx++) {
if (abs(dy) <= r && abs(dx) <= innerSpan) continue;
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < MAP_DISPLAY_W &&
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(px, py, GxEPD_WHITE);
}
}
}
// Filled black diamond
for (int dy = -r; dy <= r; dy++) {
int span = r - abs(dy);
for (int dx = -span; dx <= span; dx++) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < MAP_DISPLAY_W &&
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(px, py, GxEPD_BLACK);
}
}
}
}
// Strip non-ASCII characters (emoji, flags, symbols) from label text.
// Copies only printable ASCII (0x20-0x7E) into dest buffer.
// Skips leading whitespace after stripping. Returns length.
static int extractAsciiLabel(const char* src, char* dest, int destSize) {
int j = 0;
for (int i = 0; src[i] != '\0' && j < destSize - 1; i++) {
uint8_t ch = (uint8_t)src[i];
if (ch >= 0x20 && ch <= 0x7E) {
dest[j++] = src[i];
}
// Skip continuation bytes of multi-byte UTF-8 sequences
}
dest[j] = '\0';
// Trim leading spaces (left after stripping emoji prefix)
int start = 0;
while (dest[start] == ' ') start++;
if (start > 0) {
memmove(dest, dest + start, j - start + 1);
j -= start;
}
return j;
}
// Draw a text label above a marker with white background for readability
// Built-in font is 5×7 pixels per character
void drawLabel(int cx, int topY, const char* text) {
// Clean emoji/non-ASCII from label
char clean[24];
int len = extractAsciiLabel(text, clean, sizeof(clean));
if (len == 0) return; // Nothing printable
if (len > 14) len = 14; // Truncate long names
clean[len] = '\0';
int textW = len * 6; // 5px char + 1px spacing
int textH = 8; // 7px + 1px padding
int lx = cx - textW / 2;
int ly = topY - textH;
// Clamp to viewport
if (lx < 1) lx = 1;
if (lx + textW >= MAP_DISPLAY_W - 1) lx = MAP_DISPLAY_W - textW - 1;
if (ly < MAP_VIEWPORT_Y) ly = MAP_VIEWPORT_Y;
if (ly + textH >= MAP_VIEWPORT_Y + MAP_VIEWPORT_H) return;
// White background rectangle
for (int y = ly - 1; y <= ly + textH; y++) {
for (int x = lx - 1; x <= lx + textW; x++) {
if (x >= 0 && x < MAP_DISPLAY_W &&
y >= MAP_VIEWPORT_Y && y < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(x, y, GxEPD_WHITE);
}
}
}
// Draw text using raw font rendering
_einkDisplay->drawTextRaw(lx, ly, clean, GxEPD_BLACK);
}
// Draw own-position marker: bold circle with filled center dot
// Fixed size (doesn't scale with zoom) so it's always clearly visible
void drawOwnPosition(int cx, int cy) {
int r = 8; // Outer radius — always prominent
// White halo (clears map underneath)
for (int dy = -(r + 2); dy <= (r + 2); dy++) {
for (int dx = -(r + 2); dx <= (r + 2); dx++) {
if (dx * dx + dy * dy <= (r + 2) * (r + 2)) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < MAP_DISPLAY_W &&
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(px, py, GxEPD_WHITE);
}
}
}
}
// Thick black circle outline (2px wide ring)
for (int dy = -r; dy <= r; dy++) {
for (int dx = -r; dx <= r; dx++) {
int d2 = dx * dx + dy * dy;
if (d2 >= (r - 2) * (r - 2) && d2 <= r * r) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < MAP_DISPLAY_W &&
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(px, py, GxEPD_BLACK);
}
}
}
}
// Filled black center dot (radius 3)
for (int dy = -3; dy <= 3; dy++) {
for (int dx = -3; dx <= 3; dx++) {
if (dx * dx + dy * dy <= 9) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < MAP_DISPLAY_W &&
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(px, py, GxEPD_BLACK);
}
}
}
}
}
// ==========================================================================
// Crosshair at viewport center
// ==========================================================================
void renderCrosshair() {
if (!_einkDisplay) return;
int cx = MAP_DISPLAY_W / 2;
int cy = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2;
int len = markerRadius() + 2; // Scales with zoom
// Draw thin crosshair: black line with white border for contrast
// Horizontal arm
for (int x = cx - len; x <= cx + len; x++) {
if (x >= 0 && x < MAP_DISPLAY_W) {
if (cy - 1 >= MAP_VIEWPORT_Y)
_einkDisplay->drawPixelRaw(x, cy - 1, GxEPD_WHITE);
if (cy + 1 < MAP_VIEWPORT_Y + MAP_VIEWPORT_H)
_einkDisplay->drawPixelRaw(x, cy + 1, GxEPD_WHITE);
_einkDisplay->drawPixelRaw(x, cy, GxEPD_BLACK);
}
}
// Vertical arm
for (int y = cy - len; y <= cy + len; y++) {
if (y >= MAP_VIEWPORT_Y && y < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
if (cx - 1 >= 0)
_einkDisplay->drawPixelRaw(cx - 1, y, GxEPD_WHITE);
if (cx + 1 < MAP_DISPLAY_W)
_einkDisplay->drawPixelRaw(cx + 1, y, GxEPD_WHITE);
_einkDisplay->drawPixelRaw(cx, y, GxEPD_BLACK);
}
}
}
// ==========================================================================
// Footer bar — zoom level, GPS status, navigation hints
// ==========================================================================
void renderFooter(DisplayDriver& display) {
// Use the standard footer pattern: setTextSize(1) at height()-12
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
int footerY = display.height() - 12;
// Separator line
display.drawRect(0, footerY - 2, display.width(), 1);
// Left: zoom level
char left[8];
snprintf(left, sizeof(left), "Z%d", _zoom);
display.setCursor(0, footerY);
display.print(left);
// Right: navigation hint
const char* right = "WASD:pan Z/X:zoom";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
};

View File

@@ -33,6 +33,10 @@ void ModemManager::begin() {
_operator[0] = '\0';
_callPhone[0] = '\0';
_callStartTime = 0;
_ringtoneEnabled = false;
_ringing = false;
_nextRingTone = 0;
_toneActive = false;
_urcPos = 0;
_imei[0] = '\0';
_imsi[0] = '\0';
@@ -605,6 +609,46 @@ bool ModemManager::doSetVolume(uint8_t level) {
return ok;
}
// ---------------------------------------------------------------------------
// Incoming call ringtone — tone bursts via AT+SIMTONE on modem speaker
// Pattern: 400ms tone → 1200ms silence → repeat
// ---------------------------------------------------------------------------
void ModemManager::handleRingtone() {
bool nowRinging = (_state == ModemState::RINGING_IN);
if (nowRinging && !_ringing) {
// Just started ringing
_ringing = true;
_nextRingTone = 0; // Play first burst immediately
_toneActive = false;
} else if (!nowRinging && _ringing) {
// Ringing stopped (answered, rejected, missed)
_ringing = false;
if (_toneActive) {
sendAT("AT+SIMTONE=0", "OK", 500);
_toneActive = false;
}
return;
}
if (!_ringing || !_ringtoneEnabled) return;
unsigned long now = millis();
if (now < _nextRingTone) return;
if (!_toneActive) {
// Play tone burst: 1000 Hz, level 5000 (of 50-25500), 400ms duration
sendAT("AT+SIMTONE=1,1000,5000,400", "OK", 500);
_toneActive = true;
_nextRingTone = now + 400; // Tone plays for 400ms
} else {
// Tone just finished — gap before next burst
_toneActive = false;
_nextRingTone = now + 1200; // 1.2s silence (classic ring cadence)
}
}
// ---------------------------------------------------------------------------
// FreeRTOS Task
// ---------------------------------------------------------------------------
@@ -829,6 +873,11 @@ restart:
// ================================================================
drainURCs();
// ================================================================
// Step 1b: Ringtone — play tone bursts while incoming call rings
// ================================================================
handleRingtone();
// ================================================================
// Step 2: Process call commands from main loop
// ================================================================

View File

@@ -142,6 +142,10 @@ public:
bool setCallVolume(uint8_t level); // Set volume 0-5
bool pollCallEvent(CallEvent& out); // Poll from main loop
// Ringtone control — called from main loop
void setRingtoneEnabled(bool en) { _ringtoneEnabled = en; }
bool isRingtoneEnabled() const { return _ringtoneEnabled; }
// --- State queries (lock-free reads) ---
ModemState getState() const { return _state; }
int getSignalBars() const; // 0-5
@@ -203,6 +207,12 @@ private:
char _callPhone[SMS_PHONE_LEN] = {0}; // Current call number
volatile uint32_t _callStartTime = 0; // millis() when call connected
// Ringtone state
volatile bool _ringtoneEnabled = false;
bool _ringing = false; // Shadow of RINGING_IN for tone logic
unsigned long _nextRingTone = 0; // Next tone burst timestamp (modem task)
bool _toneActive = false; // Is a tone currently sounding
TaskHandle_t _taskHandle = nullptr;
// SMS queues
@@ -242,6 +252,7 @@ private:
bool doSendDTMF(char digit);
bool doSetVolume(uint8_t level);
void queueCallEvent(CallEventType type, const char* phone = nullptr, uint32_t duration = 0);
void handleRingtone(); // Play tone bursts while incoming call rings
// FreeRTOS task
static void taskEntry(void* param);

View File

@@ -0,0 +1,34 @@
#pragma once
// ---------------------------------------------------------------------------
// Radio presets — shared between SettingsScreen (UI) and MyMesh (Serial CLI)
// ---------------------------------------------------------------------------
struct RadioPreset {
const char* name;
float freq;
float bw;
uint8_t sf;
uint8_t cr;
uint8_t tx_power;
};
static const RadioPreset RADIO_PRESETS[] = {
{ "Australia", 915.800f, 250.0f, 10, 5, 22 },
{ "Australia (Narrow)", 916.575f, 62.5f, 7, 8, 22 },
{ "Australia: SA, WA", 923.125f, 62.5f, 8, 8, 22 },
{ "Australia: QLD", 923.125f, 62.5f, 8, 5, 22 },
{ "EU/UK (Narrow)", 869.618f, 62.5f, 8, 8, 14 },
{ "EU/UK (Long Range)", 869.525f, 250.0f, 11, 5, 14 },
{ "EU/UK (Medium Range)", 869.525f, 250.0f, 10, 5, 14 },
{ "Czech Republic (Narrow)",869.432f, 62.5f, 7, 5, 14 },
{ "EU 433 (Long Range)", 433.650f, 250.0f, 11, 5, 14 },
{ "New Zealand", 917.375f, 250.0f, 11, 5, 22 },
{ "New Zealand (Narrow)", 917.375f, 62.5f, 7, 5, 22 },
{ "Portugal 433", 433.375f, 62.5f, 9, 6, 14 },
{ "Portugal 868", 869.618f, 62.5f, 7, 6, 14 },
{ "Switzerland", 869.618f, 62.5f, 8, 8, 14 },
{ "USA/Canada (Recommended)",910.525f, 62.5f, 7, 5, 22 },
{ "Vietnam", 920.250f, 250.0f, 11, 5, 22 },
};
#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]))

View File

@@ -197,6 +197,7 @@ private:
// Timing
unsigned long _cmdSentAt;
unsigned long _loginTimeoutMs; // computed timeout for login (ms), falls back to ADMIN_TIMEOUT_MS
bool _waitingForLogin;
// Password cache
@@ -428,7 +429,7 @@ public:
_catSel(0), _cmdSel(0), _scrollOffset(0),
_paramLen(0), _pendingCmd(nullptr),
_responseLen(0), _responseScroll(0), _responseTotalLines(0),
_cmdSentAt(0), _waitingForLogin(false), _pwdCacheCount(0),
_cmdSentAt(0), _loginTimeoutMs(ADMIN_TIMEOUT_MS), _waitingForLogin(false), _pwdCacheCount(0),
_telemVoltage(0), _telemTempC(0),
_telemHasVoltage(false), _telemHasTemp(false), _telemRequested(false) {
_password[0] = '\0';
@@ -529,19 +530,23 @@ public:
}
void poll() override {
if ((_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) &&
_cmdSentAt > 0 && (millis() - _cmdSentAt) > ADMIN_TIMEOUT_MS) {
if (_pendingCmd && (_pendingCmd->flags & CMDF_EXPECT_TIMEOUT)) {
snprintf(_response, sizeof(_response), "Command sent.\nTimeout is expected\n(device is rebooting/updating).");
_responseLen = strlen(_response);
_responseTotalLines = countLines(_response);
_state = STATE_RESPONSE_VIEW;
} else {
snprintf(_response, sizeof(_response), "Timeout - no response.");
_responseLen = strlen(_response);
_state = STATE_ERROR;
if (_cmdSentAt > 0) {
unsigned long elapsed = millis() - _cmdSentAt;
unsigned long timeout = (_state == STATE_LOGGING_IN) ? _loginTimeoutMs : ADMIN_TIMEOUT_MS;
if ((_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) && elapsed > timeout) {
if (_pendingCmd && (_pendingCmd->flags & CMDF_EXPECT_TIMEOUT)) {
snprintf(_response, sizeof(_response), "Command sent.\nTimeout is expected\n(device is rebooting/updating).");
_responseLen = strlen(_response);
_responseTotalLines = countLines(_response);
_state = STATE_RESPONSE_VIEW;
} else {
snprintf(_response, sizeof(_response), "Timeout - no response.");
_responseLen = strlen(_response);
_state = STATE_ERROR;
}
_task->forceRefresh(); // Immediate redraw on state change
}
_task->forceRefresh(); // Immediate redraw on state change
}
}
@@ -1160,9 +1165,13 @@ private:
inline bool RepeaterAdminScreen::doLogin() {
if (_contactIdx < 0 || _pwdLen == 0) return false;
if (the_mesh.uiLoginToRepeater(_contactIdx, _password)) {
uint32_t timeout_ms = 0;
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;
_waitingForLogin = true;
return true;
} else {

View File

@@ -10,42 +10,39 @@
#include "ModemManager.h"
#endif
#ifdef MECK_WIFI_COMPANION
#include <WiFi.h>
#include <SD.h>
#endif
// Forward declarations
class UITask;
class MyMesh;
extern MyMesh the_mesh;
// ---------------------------------------------------------------------------
// Radio presets
// Auto-add config bitmask (mirrored from MyMesh.cpp for UI access)
// ---------------------------------------------------------------------------
struct RadioPreset {
const char* name;
float freq;
float bw;
uint8_t sf;
uint8_t cr;
uint8_t tx_power;
};
#define AUTO_ADD_OVERWRITE_OLDEST (1 << 0) // 0x01 - overwrite oldest non-favourite when full
#define AUTO_ADD_CHAT (1 << 1) // 0x02 - auto-add Chat (Companion) (ADV_TYPE_CHAT)
#define AUTO_ADD_REPEATER (1 << 2) // 0x04 - auto-add Repeater (ADV_TYPE_REPEATER)
#define AUTO_ADD_ROOM_SERVER (1 << 3) // 0x08 - auto-add Room Server (ADV_TYPE_ROOM)
#define AUTO_ADD_SENSOR (1 << 4) // 0x10 - auto-add Sensor (ADV_TYPE_SENSOR)
static const RadioPreset RADIO_PRESETS[] = {
{ "Australia", 915.800f, 250.0f, 10, 5, 22 },
{ "Australia (Narrow)", 916.575f, 62.5f, 7, 8, 22 },
{ "Australia: SA, WA", 923.125f, 62.5f, 8, 8, 22 },
{ "Australia: QLD", 923.125f, 62.5f, 8, 5, 22 },
{ "EU/UK (Narrow)", 869.618f, 62.5f, 8, 8, 14 },
{ "EU/UK (Long Range)", 869.525f, 250.0f, 11, 5, 14 },
{ "EU/UK (Medium Range)", 869.525f, 250.0f, 10, 5, 14 },
{ "Czech Republic (Narrow)",869.432f, 62.5f, 7, 5, 14 },
{ "EU 433 (Long Range)", 433.650f, 250.0f, 11, 5, 14 },
{ "New Zealand", 917.375f, 250.0f, 11, 5, 22 },
{ "New Zealand (Narrow)", 917.375f, 62.5f, 7, 5, 22 },
{ "Portugal 433", 433.375f, 62.5f, 9, 6, 14 },
{ "Portugal 868", 869.618f, 62.5f, 7, 6, 14 },
{ "Switzerland", 869.618f, 62.5f, 8, 8, 14 },
{ "USA/Canada (Recommended)",910.525f, 62.5f, 7, 5, 22 },
{ "Vietnam", 920.250f, 250.0f, 11, 5, 22 },
};
#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]))
// All type bits combined (excludes overwrite flag)
#define AUTO_ADD_ALL_TYPES (AUTO_ADD_CHAT | AUTO_ADD_REPEATER | \
AUTO_ADD_ROOM_SERVER | AUTO_ADD_SENSOR)
// Contact mode indices for picker
#define CONTACT_MODE_AUTO_ALL 0 // Add all contacts automatically
#define CONTACT_MODE_CUSTOM 1 // Per-type toggles
#define CONTACT_MODE_MANUAL 2 // No auto-add, companion app only
#define CONTACT_MODE_COUNT 3
// ---------------------------------------------------------------------------
// Radio presets (shared with Serial CLI in MyMesh.cpp)
// ---------------------------------------------------------------------------
#include "RadioPresets.h"
// ---------------------------------------------------------------------------
// Settings row types
@@ -60,9 +57,22 @@ enum SettingsRowType : uint8_t {
ROW_TX_POWER, // TX power (1-20 dBm)
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
ROW_MSG_NOTIFY, // Keyboard flash on new msg toggle
ROW_PATH_HASH_SIZE, // Path hash size (1, 2, or 3 bytes per hop)
#ifdef MECK_WIFI_COMPANION
ROW_WIFI_SETUP, // WiFi SSID/password configuration
ROW_WIFI_TOGGLE, // WiFi radio on/off toggle
#endif
#ifdef HAS_4G_MODEM
ROW_MODEM_TOGGLE, // 4G modem enable/disable toggle (4G builds only)
// ROW_RINGTONE, // Incoming call ringtone toggle (4G builds only)
#endif
ROW_CONTACT_HEADER, // "--- Contacts ---" separator
ROW_CONTACT_MODE, // Contact auto-add mode picker (Auto All / Custom / Manual)
ROW_AUTOADD_CHAT, // Toggle: auto-add Chat clients
ROW_AUTOADD_REPEATER,// Toggle: auto-add Repeaters
ROW_AUTOADD_ROOM, // Toggle: auto-add Room Servers
ROW_AUTOADD_SENSOR, // Toggle: auto-add Sensors
ROW_AUTOADD_OVERWRITE, // Toggle: overwrite oldest non-favourite when full
ROW_CH_HEADER, // "--- Channels ---" separator
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
@@ -82,16 +92,23 @@ enum SettingsRowType : uint8_t {
enum EditMode : uint8_t {
EDIT_NONE, // Just browsing
EDIT_TEXT, // Typing into a text buffer (name, channel name)
EDIT_PICKER, // A/D cycles options (radio preset)
EDIT_PICKER, // A/D cycles options (radio preset, contact mode)
EDIT_NUMBER, // W/S adjusts value (freq, BW, SF, CR, TX, UTC)
EDIT_CONFIRM, // Confirmation dialog (delete channel, apply radio)
#ifdef MECK_WIFI_COMPANION
EDIT_WIFI, // WiFi scan/select/password flow
#endif
};
// Max rows in the settings list
#ifdef HAS_4G_MODEM
#define SETTINGS_MAX_ROWS 46 // Extra rows for IMEI, Carrier, APN
// Max rows in the settings list (increased for contact sub-toggles + WiFi)
#if defined(HAS_4G_MODEM) && defined(MECK_WIFI_COMPANION)
#define SETTINGS_MAX_ROWS 56 // Extra rows for IMEI, Carrier, APN, contacts, WiFi
#elif defined(HAS_4G_MODEM)
#define SETTINGS_MAX_ROWS 54 // Extra rows for IMEI, Carrier, APN + contacts
#elif defined(MECK_WIFI_COMPANION)
#define SETTINGS_MAX_ROWS 50 // Extra rows for contacts + WiFi
#else
#define SETTINGS_MAX_ROWS 40
#define SETTINGS_MAX_ROWS 48 // Contacts section
#endif
#define SETTINGS_TEXT_BUF 33 // 32 chars + null
@@ -117,7 +134,7 @@ private:
EditMode _editMode;
char _editBuf[SETTINGS_TEXT_BUF];
int _editPos;
int _editPickerIdx; // for preset picker
int _editPickerIdx; // for preset picker / contact mode picker
float _editFloat; // for freq/BW editing
int _editInt; // for SF/CR/TX/UTC editing
int _confirmAction; // 0=none, 1=delete channel, 2=apply radio
@@ -133,6 +150,75 @@ private:
bool _modemEnabled;
#endif
#ifdef MECK_WIFI_COMPANION
// WiFi setup sub-screen state
enum WifiSetupPhase : uint8_t {
WIFI_PHASE_IDLE,
WIFI_PHASE_SCANNING,
WIFI_PHASE_SELECT, // W/S to pick SSID, Enter to select
WIFI_PHASE_PASSWORD, // Type password, Enter to connect
WIFI_PHASE_CONNECTING,
};
WifiSetupPhase _wifiPhase;
String _wifiSSIDs[10];
int _wifiSSIDCount;
int _wifiSSIDSelected;
char _wifiPassBuf[64];
int _wifiPassLen;
unsigned long _wifiFormLastChar; // For brief password reveal
#endif
// ---------------------------------------------------------------------------
// Contact mode helpers
// ---------------------------------------------------------------------------
// Determine current contact mode from prefs
int getContactMode() const {
if ((_prefs->manual_add_contacts & 1) == 0) {
return CONTACT_MODE_AUTO_ALL;
}
// manual_add_contacts bit 0 is set — check if any type bits are enabled
if ((_prefs->autoadd_config & AUTO_ADD_ALL_TYPES) != 0) {
return CONTACT_MODE_CUSTOM;
}
return CONTACT_MODE_MANUAL;
}
// Get display label for a contact mode
static const char* contactModeLabel(int mode) {
switch (mode) {
case CONTACT_MODE_AUTO_ALL: return "Auto All";
case CONTACT_MODE_CUSTOM: return "Custom";
case CONTACT_MODE_MANUAL: return "Manual Only";
default: return "?";
}
}
// Apply a contact mode selection from picker
void applyContactMode(int mode) {
switch (mode) {
case CONTACT_MODE_AUTO_ALL:
_prefs->manual_add_contacts &= ~1; // clear bit 0 → auto all
break;
case CONTACT_MODE_CUSTOM:
_prefs->manual_add_contacts |= 1; // set bit 0 → selective
// If no type bits are set, default to all types enabled
if ((_prefs->autoadd_config & AUTO_ADD_ALL_TYPES) == 0) {
_prefs->autoadd_config |= AUTO_ADD_ALL_TYPES;
}
break;
case CONTACT_MODE_MANUAL:
_prefs->manual_add_contacts |= 1; // set bit 0 → selective
_prefs->autoadd_config &= ~AUTO_ADD_ALL_TYPES; // clear all type bits
// Note: keeps AUTO_ADD_OVERWRITE_OLDEST bit unchanged
break;
}
the_mesh.savePrefs();
rebuildRows(); // show/hide sub-toggles
Serial.printf("Settings: Contact mode = %s (manual=%d, autoadd=0x%02X)\n",
contactModeLabel(mode), _prefs->manual_add_contacts, _prefs->autoadd_config);
}
// ---------------------------------------------------------------------------
// Row table management
// ---------------------------------------------------------------------------
@@ -149,9 +235,30 @@ private:
addRow(ROW_TX_POWER);
addRow(ROW_UTC_OFFSET);
addRow(ROW_MSG_NOTIFY);
addRow(ROW_PATH_HASH_SIZE);
#ifdef MECK_WIFI_COMPANION
addRow(ROW_WIFI_SETUP);
addRow(ROW_WIFI_TOGGLE);
#endif
#ifdef HAS_4G_MODEM
addRow(ROW_MODEM_TOGGLE);
// addRow(ROW_RINGTONE);
#endif
// --- Contacts section ---
addRow(ROW_CONTACT_HEADER);
addRow(ROW_CONTACT_MODE);
// Show per-type sub-toggles only in Custom mode
if (getContactMode() == CONTACT_MODE_CUSTOM) {
addRow(ROW_AUTOADD_CHAT);
addRow(ROW_AUTOADD_REPEATER);
addRow(ROW_AUTOADD_ROOM);
addRow(ROW_AUTOADD_SENSOR);
addRow(ROW_AUTOADD_OVERWRITE);
}
// --- Channels section ---
addRow(ROW_CH_HEADER);
// Enumerate current channels
@@ -192,7 +299,7 @@ private:
bool isSelectable(int idx) const {
if (idx < 0 || idx >= _numRows) return false;
SettingsRowType t = _rows[idx].type;
return t != ROW_CH_HEADER && t != ROW_INFO_HEADER
return t != ROW_CH_HEADER && t != ROW_INFO_HEADER && t != ROW_CONTACT_HEADER
#ifdef HAS_4G_MODEM
&& t != ROW_IMEI && t != ROW_OPERATOR_INFO
#endif
@@ -326,6 +433,14 @@ public:
#ifdef HAS_4G_MODEM
_modemEnabled = ModemManager::loadEnabledConfig();
#endif
#ifdef MECK_WIFI_COMPANION
_wifiPhase = WIFI_PHASE_IDLE;
_wifiSSIDCount = 0;
_wifiSSIDSelected = 0;
_wifiPassLen = 0;
memset(_wifiPassBuf, 0, sizeof(_wifiPassBuf));
_wifiFormLastChar = 0;
#endif
rebuildRows();
}
@@ -341,6 +456,64 @@ public:
bool isEditing() const { return _editMode != EDIT_NONE; }
bool hasRadioChanges() const { return _radioChanged; }
// ---------------------------------------------------------------------------
// WiFi scan helpers
// ---------------------------------------------------------------------------
#ifdef MECK_WIFI_COMPANION
// Perform a blocking WiFi scan. Populates _wifiSSIDs/_wifiSSIDCount and
// advances _wifiPhase to SELECT (even on zero results, so the overlay
// stays visible and the user can rescan with 'r').
void performWifiScan() {
_wifiPhase = WIFI_PHASE_SCANNING;
_wifiSSIDCount = 0;
_wifiSSIDSelected = 0;
// Disconnect any active WiFi connection first — the ESP32 driver
// returns -2 (WIFI_SCAN_FAILED) if the radio is busy with an
// existing connection or the TCP companion server socket.
WiFi.disconnect(false); // false = don't turn off WiFi radio
delay(100); // let the driver settle
WiFi.mode(WIFI_STA);
// 500ms per-channel dwell helps detect phone hotspots that are slow
// to respond to probe requests (default 300ms often misses them).
int n = WiFi.scanNetworks(false, false, false, 500);
Serial.printf("Settings: WiFi scan found %d networks\n", n);
if (n > 0) {
_wifiSSIDCount = min(n, 10);
for (int si = 0; si < _wifiSSIDCount; si++) {
_wifiSSIDs[si] = WiFi.SSID(si);
Serial.printf(" [%d] %s (RSSI %d)\n", si,
_wifiSSIDs[si].c_str(), WiFi.RSSI(si));
}
} else if (n < 0) {
Serial.printf("Settings: WiFi scan error %d\n", n);
}
WiFi.scanDelete();
_wifiPhase = WIFI_PHASE_SELECT; // always show overlay (even if 0)
}
// After WiFi setup exits (connect success or user quit), try to
// reconnect to saved credentials so the companion TCP server works.
void wifiReconnectSaved() {
File f = SD.open("/web/wifi.cfg", FILE_READ);
if (f) {
String ssid = f.readStringUntil('\n'); ssid.trim();
String pass = f.readStringUntil('\n'); pass.trim();
f.close();
digitalWrite(SDCARD_CS, HIGH);
if (ssid.length() > 0) {
Serial.printf("Settings: Reconnecting to saved WiFi '%s'\n", ssid.c_str());
WiFi.begin(ssid.c_str(), pass.c_str());
}
} else {
digitalWrite(SDCARD_CS, HIGH);
}
}
#endif
// ---------------------------------------------------------------------------
// Edit mode starters
// ---------------------------------------------------------------------------
@@ -510,14 +683,93 @@ public:
display.print(tmp);
break;
case ROW_PATH_HASH_SIZE:
if (editing && _editMode == EDIT_NUMBER) {
snprintf(tmp, sizeof(tmp), "Path Hash Size: %d-byte <W/S>", _editInt);
} else {
snprintf(tmp, sizeof(tmp), "Path Hash Size: %d-byte", _prefs->path_hash_mode + 1);
}
display.print(tmp);
break;
#ifdef MECK_WIFI_COMPANION
case ROW_WIFI_SETUP:
if (WiFi.status() == WL_CONNECTED) {
snprintf(tmp, sizeof(tmp), "WiFi: %s", WiFi.SSID().c_str());
} else {
strcpy(tmp, "WiFi: (not connected)");
}
display.print(tmp);
break;
case ROW_WIFI_TOGGLE:
snprintf(tmp, sizeof(tmp), "WiFi Radio: %s",
(WiFi.getMode() != WIFI_OFF) ? "ON" : "OFF");
display.print(tmp);
break;
#endif
#ifdef HAS_4G_MODEM
case ROW_MODEM_TOGGLE:
snprintf(tmp, sizeof(tmp), "4G Modem: %s",
_modemEnabled ? "ON" : "OFF");
display.print(tmp);
break;
//case ROW_RINGTONE:
// snprintf(tmp, sizeof(tmp), "Incoming Call Ring: %s",
// _prefs->ringtone_enabled ? "ON" : "OFF");
// display.print(tmp);
// break;
#endif
// --- Contacts section ---
case ROW_CONTACT_HEADER:
display.setColor(DisplayDriver::YELLOW);
display.print("--- Contacts ---");
break;
case ROW_CONTACT_MODE:
if (editing && _editMode == EDIT_PICKER) {
snprintf(tmp, sizeof(tmp), "< Add Mode: %s >",
contactModeLabel(_editPickerIdx));
} else {
snprintf(tmp, sizeof(tmp), "Add Mode: %s",
contactModeLabel(getContactMode()));
}
display.print(tmp);
break;
case ROW_AUTOADD_CHAT:
snprintf(tmp, sizeof(tmp), " Chat: %s",
(_prefs->autoadd_config & AUTO_ADD_CHAT) ? "ON" : "OFF");
display.print(tmp);
break;
case ROW_AUTOADD_REPEATER:
snprintf(tmp, sizeof(tmp), " Repeater: %s",
(_prefs->autoadd_config & AUTO_ADD_REPEATER) ? "ON" : "OFF");
display.print(tmp);
break;
case ROW_AUTOADD_ROOM:
snprintf(tmp, sizeof(tmp), " Room Server: %s",
(_prefs->autoadd_config & AUTO_ADD_ROOM_SERVER) ? "ON" : "OFF");
display.print(tmp);
break;
case ROW_AUTOADD_SENSOR:
snprintf(tmp, sizeof(tmp), " Sensor: %s",
(_prefs->autoadd_config & AUTO_ADD_SENSOR) ? "ON" : "OFF");
display.print(tmp);
break;
case ROW_AUTOADD_OVERWRITE:
snprintf(tmp, sizeof(tmp), " Overwrite Oldest: %s",
(_prefs->autoadd_config & AUTO_ADD_OVERWRITE_OLDEST) ? "ON" : "OFF");
display.print(tmp);
break;
// --- Channels section ---
case ROW_CH_HEADER:
display.setColor(DisplayDriver::YELLOW);
display.print("--- Channels ---");
@@ -652,6 +904,88 @@ public:
display.setTextSize(1);
}
#ifdef MECK_WIFI_COMPANION
// === WiFi setup overlay ===
if (_editMode == EDIT_WIFI) {
int bx = 2, by = 14, bw = display.width() - 4;
int bh = display.height() - 28;
display.setColor(DisplayDriver::DARK);
display.fillRect(bx, by, bw, bh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, bh);
display.setTextSize(0);
int wy = by + 4;
if (_wifiPhase == WIFI_PHASE_SCANNING) {
display.drawTextCentered(display.width() / 2, wy, "Scanning for networks...");
} else if (_wifiPhase == WIFI_PHASE_SELECT) {
if (_wifiSSIDCount == 0) {
// No networks found — show message with rescan prompt
display.setCursor(bx + 4, wy);
display.print("No networks found.");
wy += 12;
display.setCursor(bx + 4, wy);
display.print("Check your hotspot is on");
wy += 8;
display.setCursor(bx + 4, wy);
display.print("and set to 2.4GHz.");
wy += 12;
display.setCursor(bx + 4, wy);
display.print("Press R or Enter to rescan.");
} else {
display.setCursor(bx + 4, wy);
display.print("Select network:");
wy += 10;
for (int wi = 0; wi < _wifiSSIDCount && wy < by + bh - 16; wi++) {
bool sel = (wi == _wifiSSIDSelected);
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(bx + 2, wy + 5, bw - 4, 8);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(bx + 4, wy);
char ssidLine[40];
if (sel) {
snprintf(ssidLine, sizeof(ssidLine), "> %.33s", _wifiSSIDs[wi].c_str());
} else {
snprintf(ssidLine, sizeof(ssidLine), " %.33s", _wifiSSIDs[wi].c_str());
}
display.print(ssidLine);
wy += 8;
}
}
} else if (_wifiPhase == WIFI_PHASE_PASSWORD) {
display.setCursor(bx + 4, wy);
snprintf(tmp, sizeof(tmp), "SSID: %s", _wifiSSIDs[_wifiSSIDSelected].c_str());
display.print(tmp);
wy += 12;
display.setCursor(bx + 4, wy);
display.print("Password:");
wy += 10;
display.setCursor(bx + 4, wy);
// Masked password with brief reveal of last char
char passBuf[66];
for (int pi = 0; pi < _wifiPassLen; pi++) passBuf[pi] = '*';
if (_wifiPassLen > 0 && _wifiFormLastChar > 0 &&
(millis() - _wifiFormLastChar) < 800) {
passBuf[_wifiPassLen - 1] = _wifiPassBuf[_wifiPassLen - 1];
}
passBuf[_wifiPassLen] = '_';
passBuf[_wifiPassLen + 1] = '\0';
display.print(passBuf);
} else if (_wifiPhase == WIFI_PHASE_CONNECTING) {
display.drawTextCentered(display.width() / 2, wy + 10, "Connecting...");
}
display.setTextSize(1);
}
#endif
// === Footer ===
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
@@ -660,6 +994,20 @@ public:
if (_editMode == EDIT_TEXT) {
display.print("Type, Enter:Ok Q:Cancel");
#ifdef MECK_WIFI_COMPANION
} else if (_editMode == EDIT_WIFI) {
if (_wifiPhase == WIFI_PHASE_SELECT) {
if (_wifiSSIDCount == 0) {
display.print("R/Enter:Rescan Q:Back");
} else {
display.print("W/S:Pick Enter:Sel R:Rescan");
}
} else if (_wifiPhase == WIFI_PHASE_PASSWORD) {
display.print("Type, Enter:Connect Q:Bck");
} else {
display.print("Please wait...");
}
#endif
} else if (_editMode == EDIT_PICKER) {
display.print("A/D:Choose Enter:Ok");
} else if (_editMode == EDIT_NUMBER) {
@@ -705,6 +1053,113 @@ public:
return true; // consume all keys in confirm mode
}
#ifdef MECK_WIFI_COMPANION
// --- WiFi setup flow ---
if (_editMode == EDIT_WIFI) {
if (_wifiPhase == WIFI_PHASE_SELECT) {
if (c == 'w' || c == 'W') {
if (_wifiSSIDSelected > 0) _wifiSSIDSelected--;
return true;
}
if (c == 's' || c == 'S') {
if (_wifiSSIDSelected < _wifiSSIDCount - 1) _wifiSSIDSelected++;
return true;
}
if (c == 'r' || c == 'R') {
// Rescan — lets user toggle hotspot on then retry
performWifiScan();
return true;
}
if (c == '\r' || c == 13) {
if (_wifiSSIDCount == 0) {
// No networks — Enter rescans (same as R)
performWifiScan();
return true;
}
// Selected an SSID — move to password entry
_wifiPhase = WIFI_PHASE_PASSWORD;
_wifiPassLen = 0;
memset(_wifiPassBuf, 0, sizeof(_wifiPassBuf));
_wifiFormLastChar = 0;
return true;
}
if (c == 'q' || c == 'Q') {
_editMode = EDIT_NONE;
_wifiPhase = WIFI_PHASE_IDLE;
if (_onboarding) _onboarding = false; // Skip WiFi, finish onboarding
wifiReconnectSaved(); // Restore connection after scan disconnect
return true;
}
return true;
}
if (_wifiPhase == WIFI_PHASE_PASSWORD) {
if (c == '\r' || c == 13) {
// Attempt connection
_wifiPassBuf[_wifiPassLen] = '\0';
_wifiPhase = WIFI_PHASE_CONNECTING;
// Save credentials to SD first (so web reader can reuse them)
if (SD.exists("/web") || SD.mkdir("/web")) {
File f = SD.open("/web/wifi.cfg", FILE_WRITE);
if (f) {
f.println(_wifiSSIDs[_wifiSSIDSelected]);
f.println(_wifiPassBuf);
f.close();
}
digitalWrite(SDCARD_CS, HIGH);
}
WiFi.disconnect(false);
WiFi.begin(_wifiSSIDs[_wifiSSIDSelected].c_str(), _wifiPassBuf);
// Brief blocking wait — fine for e-ink (screen won't update during this anyway)
unsigned long timeout = millis() + 8000;
while (WiFi.status() != WL_CONNECTED && millis() < timeout) {
delay(100);
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("Settings: WiFi connected to %s, IP: %s\n",
_wifiSSIDs[_wifiSSIDSelected].c_str(),
WiFi.localIP().toString().c_str());
_editMode = EDIT_NONE;
_wifiPhase = WIFI_PHASE_IDLE;
if (_onboarding) _onboarding = false; // Finish onboarding
} else {
Serial.println("Settings: WiFi connection failed");
// Go back to SSID selection so user can retry
_wifiPhase = WIFI_PHASE_SELECT;
}
return true;
}
if (c == 'q' || c == 'Q') {
// Back to SSID selection
_wifiPhase = WIFI_PHASE_SELECT;
return true;
}
if (c == '\b') {
if (_wifiPassLen > 0) {
_wifiPassLen--;
_wifiPassBuf[_wifiPassLen] = '\0';
}
return true;
}
// Printable character
if (c >= 32 && c < 127 && _wifiPassLen < 63) {
_wifiPassBuf[_wifiPassLen++] = c;
_wifiPassBuf[_wifiPassLen] = '\0';
_wifiFormLastChar = millis();
return true;
}
return true;
}
// Scanning and connecting phases consume all keys
return true;
}
#endif
// --- Text editing mode ---
if (_editMode == EDIT_TEXT) {
if (c == '\r' || c == 13) {
@@ -775,34 +1230,65 @@ public:
return true; // consume all keys in text edit
}
// --- Picker mode (radio preset) ---
// --- Picker mode (radio preset or contact mode) ---
if (_editMode == EDIT_PICKER) {
SettingsRowType type = _rows[_cursor].type;
if (c == 'a' || c == 'A') {
_editPickerIdx--;
if (_editPickerIdx < 0) _editPickerIdx = (int)NUM_RADIO_PRESETS - 1;
if (type == ROW_CONTACT_MODE) {
_editPickerIdx--;
if (_editPickerIdx < 0) _editPickerIdx = CONTACT_MODE_COUNT - 1;
} else {
// Radio preset
_editPickerIdx--;
if (_editPickerIdx < 0) _editPickerIdx = (int)NUM_RADIO_PRESETS - 1;
}
return true;
}
if (c == 'd' || c == 'D') {
_editPickerIdx++;
if (_editPickerIdx >= (int)NUM_RADIO_PRESETS) _editPickerIdx = 0;
if (type == ROW_CONTACT_MODE) {
_editPickerIdx++;
if (_editPickerIdx >= CONTACT_MODE_COUNT) _editPickerIdx = 0;
} else {
// Radio preset
_editPickerIdx++;
if (_editPickerIdx >= (int)NUM_RADIO_PRESETS) _editPickerIdx = 0;
}
return true;
}
if (c == '\r' || c == 13) {
// Apply preset
if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) {
const RadioPreset& p = RADIO_PRESETS[_editPickerIdx];
_prefs->freq = p.freq;
_prefs->bw = p.bw;
_prefs->sf = p.sf;
_prefs->cr = p.cr;
_prefs->tx_power_dbm = p.tx_power;
_radioChanged = true;
}
_editMode = EDIT_NONE;
if (_onboarding) {
// Apply and finish onboarding
applyRadioParams();
_onboarding = false;
if (type == ROW_CONTACT_MODE) {
applyContactMode(_editPickerIdx);
_editMode = EDIT_NONE;
} else {
// Apply radio preset
if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) {
const RadioPreset& p = RADIO_PRESETS[_editPickerIdx];
_prefs->freq = p.freq;
_prefs->bw = p.bw;
_prefs->sf = p.sf;
_prefs->cr = p.cr;
_prefs->tx_power_dbm = p.tx_power;
_radioChanged = true;
}
_editMode = EDIT_NONE;
if (_onboarding) {
applyRadioParams();
#ifdef MECK_WIFI_COMPANION
// Move to WiFi setup before finishing onboarding
for (int r = 0; r < _numRows; r++) {
if (_rows[r].type == ROW_WIFI_SETUP) {
_cursor = r;
break;
}
}
// Auto-launch the WiFi scan
_editMode = EDIT_WIFI;
performWifiScan();
#else
_onboarding = false;
#endif
}
}
return true;
}
@@ -831,6 +1317,7 @@ public:
case ROW_CR: if (_editInt < 8) _editInt++; break;
case ROW_TX_POWER: if (_editInt < MAX_LORA_TX_POWER) _editInt++; break;
case ROW_UTC_OFFSET: if (_editInt < 14) _editInt++; break;
case ROW_PATH_HASH_SIZE: if (_editInt < 3) _editInt++; break;
default: break;
}
return true;
@@ -847,6 +1334,7 @@ public:
case ROW_CR: if (_editInt > 5) _editInt--; break;
case ROW_TX_POWER: if (_editInt > 1) _editInt--; break;
case ROW_UTC_OFFSET: if (_editInt > -12) _editInt--; break;
case ROW_PATH_HASH_SIZE: if (_editInt > 1) _editInt--; break;
default: break;
}
return true;
@@ -874,6 +1362,10 @@ public:
_prefs->utc_offset_hours = (int8_t)constrain(_editInt, -12, 14);
the_mesh.savePrefs();
break;
case ROW_PATH_HASH_SIZE:
_prefs->path_hash_mode = (uint8_t)constrain(_editInt - 1, 0, 2); // display 1-3, store 0-2
the_mesh.savePrefs();
break;
default: break;
}
_editMode = EDIT_NONE;
@@ -944,6 +1436,52 @@ public:
Serial.printf("Settings: Msg flash notify = %s\n",
_prefs->kb_flash_notify ? "ON" : "OFF");
break;
case ROW_PATH_HASH_SIZE:
startEditInt(_prefs->path_hash_mode + 1); // display as 1-3
break;
#ifdef MECK_WIFI_COMPANION
case ROW_WIFI_SETUP: {
// Launch WiFi scan → select → password → connect flow
_editMode = EDIT_WIFI;
performWifiScan();
break;
}
case ROW_WIFI_TOGGLE:
if (WiFi.getMode() != WIFI_OFF) {
// Turn WiFi OFF
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
Serial.println("Settings: WiFi radio OFF");
} else {
// Turn WiFi ON — reconnect using saved credentials
WiFi.mode(WIFI_STA);
if (SD.exists("/web/wifi.cfg")) {
File f = SD.open("/web/wifi.cfg", FILE_READ);
if (f) {
String ssid = f.readStringUntil('\n'); ssid.trim();
String pass = f.readStringUntil('\n'); pass.trim();
f.close();
digitalWrite(SDCARD_CS, HIGH);
if (ssid.length() > 0) {
WiFi.begin(ssid.c_str(), pass.c_str());
unsigned long timeout = millis() + 8000;
while (WiFi.status() != WL_CONNECTED && millis() < timeout) {
delay(100);
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("Settings: WiFi ON, connected to %s\n", ssid.c_str());
} else {
Serial.println("Settings: WiFi ON, but connection failed");
}
}
} else {
digitalWrite(SDCARD_CS, HIGH);
}
}
Serial.println("Settings: WiFi radio ON");
}
break;
#endif
#ifdef HAS_4G_MODEM
case ROW_MODEM_TOGGLE:
_modemEnabled = !_modemEnabled;
@@ -956,6 +1494,13 @@ public:
Serial.println("Settings: 4G modem DISABLED (shutdown)");
}
break;
// case ROW_RINGTONE:
// _prefs->ringtone_enabled = _prefs->ringtone_enabled ? 0 : 1;
// modemManager.setRingtoneEnabled(_prefs->ringtone_enabled);
// the_mesh.savePrefs();
// Serial.printf("Settings: Ringtone = %s\n",
// _prefs->ringtone_enabled ? "ON" : "OFF");
// break;
case ROW_APN: {
// Start text editing with current APN as initial value
const char* currentApn = modemManager.getAPN();
@@ -963,6 +1508,44 @@ public:
break;
}
#endif
// --- Contact mode picker ---
case ROW_CONTACT_MODE:
startEditPicker(getContactMode());
break;
// --- Contact sub-toggles (flip bit and save) ---
case ROW_AUTOADD_CHAT:
_prefs->autoadd_config ^= AUTO_ADD_CHAT;
the_mesh.savePrefs();
Serial.printf("Settings: Auto-add Chat = %s\n",
(_prefs->autoadd_config & AUTO_ADD_CHAT) ? "ON" : "OFF");
break;
case ROW_AUTOADD_REPEATER:
_prefs->autoadd_config ^= AUTO_ADD_REPEATER;
the_mesh.savePrefs();
Serial.printf("Settings: Auto-add Repeater = %s\n",
(_prefs->autoadd_config & AUTO_ADD_REPEATER) ? "ON" : "OFF");
break;
case ROW_AUTOADD_ROOM:
_prefs->autoadd_config ^= AUTO_ADD_ROOM_SERVER;
the_mesh.savePrefs();
Serial.printf("Settings: Auto-add Room = %s\n",
(_prefs->autoadd_config & AUTO_ADD_ROOM_SERVER) ? "ON" : "OFF");
break;
case ROW_AUTOADD_SENSOR:
_prefs->autoadd_config ^= AUTO_ADD_SENSOR;
the_mesh.savePrefs();
Serial.printf("Settings: Auto-add Sensor = %s\n",
(_prefs->autoadd_config & AUTO_ADD_SENSOR) ? "ON" : "OFF");
break;
case ROW_AUTOADD_OVERWRITE:
_prefs->autoadd_config ^= AUTO_ADD_OVERWRITE_OLDEST;
the_mesh.savePrefs();
Serial.printf("Settings: Overwrite oldest = %s\n",
(_prefs->autoadd_config & AUTO_ADD_OVERWRITE_OLDEST) ? "ON" : "OFF");
break;
case ROW_ADD_CHANNEL:
startEditText("");
break;

View File

@@ -3,9 +3,10 @@
#include "../MyMesh.h"
#include "NotesScreen.h"
#include "RepeaterAdminScreen.h"
#include "DiscoveryScreen.h"
#include "MapScreen.h"
#include "target.h"
#include "GPSDutyCycle.h"
#ifdef WIFI_SSID
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
#include <WiFi.h>
#endif
@@ -97,6 +98,8 @@ class HomeScreen : public UIScreen {
RADIO,
#ifdef BLE_PIN_CODE
BLUETOOTH,
#elif defined(MECK_WIFI_COMPANION)
WIFI_STATUS,
#endif
ADVERT,
#if ENV_INCLUDE_GPS == 1
@@ -301,14 +304,16 @@ public:
display.drawTextCentered(display.width() / 2, y, tmp);
y += 18;
#ifdef WIFI_SSID
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
IPAddress ip = WiFi.localIP();
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 12;
if (ip != IPAddress(0,0,0,0)) {
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 12;
}
#endif
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID)
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
@@ -335,16 +340,20 @@ public:
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
y += 10;
#ifdef HAS_4G_MODEM
display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] Phone ");
#elif defined(MECK_AUDIO_VARIANT)
display.drawTextCentered(display.width() / 2, y, "[E] Reader [P] Audiobooks");
#else
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
#endif
#ifdef MECK_WEB_READER
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
y += 10;
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
#elif defined(HAS_4G_MODEM)
display.drawTextCentered(display.width() / 2, y, "[T] Phone ");
#elif defined(MECK_AUDIO_VARIANT) && defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser ");
#elif defined(MECK_AUDIO_VARIANT)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks ");
#elif defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
#else
y -= 10; // reclaim the row for standalone
#endif
y += 14;
@@ -415,6 +424,44 @@ public:
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 72, "toggle: " PRESS_LABEL);
#endif
#ifdef MECK_WIFI_COMPANION
} else if (_page == HomePage::WIFI_STATUS) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 18, "WiFi Companion");
int wy = 36;
display.setTextSize(0);
if (WiFi.status() == WL_CONNECTED) {
display.setColor(DisplayDriver::GREEN);
snprintf(tmp, sizeof(tmp), "SSID: %s", WiFi.SSID().c_str());
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 10;
IPAddress ip = WiFi.localIP();
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 10;
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 12;
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, wy, "< App Connected >");
} else {
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, wy, "Waiting for app...");
}
} else {
display.setColor(DisplayDriver::RED);
display.drawTextCentered(display.width() / 2, wy, "Not connected");
wy += 12;
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, wy, "Configure in Settings");
}
display.setTextSize(1);
#endif
} else if (_page == HomePage::ADVERT) {
display.setColor(DisplayDriver::GREEN);
@@ -422,34 +469,16 @@ public:
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
#if ENV_INCLUDE_GPS == 1
} else if (_page == HomePage::GPS) {
extern GPSDutyCycle gpsDuty;
extern GPSStreamCounter gpsStream;
LocationProvider* nmea = sensors.getLocationProvider();
char buf[50];
int y = 18;
// GPS state line with duty cycle info
// GPS state line
if (!_node_prefs->gps_enabled) {
strcpy(buf, "gps off");
} else {
switch (gpsDuty.getState()) {
case GPSDutyState::ACQUIRING: {
uint32_t elapsed = gpsDuty.acquireElapsedSecs();
sprintf(buf, "acquiring %us", (unsigned)elapsed);
break;
}
case GPSDutyState::SLEEPING: {
uint32_t remain = gpsDuty.sleepRemainingSecs();
if (remain >= 60) {
sprintf(buf, "sleep %um%02us", (unsigned)(remain / 60), (unsigned)(remain % 60));
} else {
sprintf(buf, "sleep %us", (unsigned)remain);
}
break;
}
default:
strcpy(buf, "gps off");
}
strcpy(buf, "gps on");
}
display.drawTextLeftAlign(0, y, buf);
@@ -465,9 +494,9 @@ public:
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
// NMEA sentence counter — confirms baud rate and data flow
// NMEA sentence counter confirms baud rate and data flow
display.drawTextLeftAlign(0, y, "sentences");
if (gpsDuty.isHardwareOn()) {
if (_node_prefs->gps_enabled) {
uint16_t sps = gpsStream.getSentencesPerSec();
uint32_t total = gpsStream.getSentenceCount();
sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total);
@@ -901,6 +930,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
digitalWrite(KB_BL_PIN, LOW);
#endif
#ifdef HAS_4G_MODEM
// Sync ringtone enabled state to modem manager
modemManager.setRingtoneEnabled(node_prefs->ringtone_enabled);
#endif
ui_started_at = millis();
_alert_expiry = 0;
@@ -913,10 +947,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
notes_screen = new NotesScreen(this);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
#ifdef HAS_4G_MODEM
sms_screen = new SMSScreen(this);
#endif
map_screen = new MapScreen(this);
setCurrScreen(splash);
}
@@ -964,7 +1000,7 @@ void UITask::msgRead(int msgcount) {
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path) {
const uint8_t* path, int8_t snr) {
_msgcount = msgcount;
// Add to preview screen (for notifications on non-keyboard devices)
@@ -982,8 +1018,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
}
}
// Add to channel history screen with channel index and path data
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path);
// Add to channel history screen with channel index, path data, and SNR
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr);
// If user is currently viewing this channel, mark it as read immediately
// (they can see the message arrive in real-time)
@@ -1086,10 +1122,11 @@ void UITask::shutdown(bool restart){
// Disable GPS if active
#if ENV_INCLUDE_GPS == 1
{
extern GPSDutyCycle gpsDuty;
if (_sensors != NULL && _node_prefs != NULL && _node_prefs->gps_enabled) {
_sensors->setSettingValue("gps", "0");
gpsDuty.disable();
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
}
}
#endif
@@ -1184,11 +1221,60 @@ void UITask::loop() {
// Turn off keyboard flash after timeout
#ifdef KB_BL_PIN
if (_kb_flash_off_at && millis() >= _kb_flash_off_at) {
#ifdef HAS_4G_MODEM
// Don't turn off LED if incoming call flash is active
if (!_incomingCallRinging) {
digitalWrite(KB_BL_PIN, LOW);
}
#else
digitalWrite(KB_BL_PIN, LOW);
#endif
_kb_flash_off_at = 0;
}
#endif
// Incoming call LED flash — rapid repeated pulse while ringing
#if defined(HAS_4G_MODEM) && defined(KB_BL_PIN)
{
bool ringing = modemManager.isRinging();
if (ringing && !_incomingCallRinging) {
// Ringing just started
_incomingCallRinging = true;
_callFlashState = false;
_nextCallFlash = 0; // Start immediately
// Wake display for incoming call
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + 60000; // Keep display on while ringing (60s)
} else if (!ringing && _incomingCallRinging) {
// Ringing stopped
_incomingCallRinging = false;
// Only turn off LED if message flash isn't also active
if (!_kb_flash_off_at) {
digitalWrite(KB_BL_PIN, LOW);
}
_callFlashState = false;
}
// Rapid LED flash while ringing (if kb_flash_notify is ON)
if (_incomingCallRinging && _node_prefs->kb_flash_notify) {
unsigned long now = millis();
if (now >= _nextCallFlash) {
_callFlashState = !_callFlashState;
digitalWrite(KB_BL_PIN, _callFlashState ? HIGH : LOW);
// 250ms on, 250ms off — fast pulse to distinguish from single msg flash
_nextCallFlash = now + 250;
}
// Extend auto-off while ringing
_auto_off = millis() + 60000;
}
}
#endif
#ifdef PIN_BUZZER
if (buzzer.isPlaying()) buzzer.loop();
#endif
@@ -1229,10 +1315,11 @@ if (curr) curr->poll();
if (millis() > next_batt_chck) {
uint16_t milliVolts = getBattMilliVolts();
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
_low_batt_count++;
if (_low_batt_count >= 3) { // 3 consecutive low readings (~24s) to avoid transient sags
// show low battery shutdown alert
// we should only do this for eink displays, which will persist after power loss
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO)
// show low battery shutdown alert on e-ink (persists after power loss)
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO) || defined(LilyGo_TDeck_Pro)
if (_display != NULL) {
_display->startFrame();
_display->setTextSize(2);
@@ -1244,7 +1331,9 @@ if (curr) curr->poll();
#endif
shutdown();
}
} else {
_low_batt_count = 0;
}
next_batt_chck = millis() + 8000;
}
@@ -1295,20 +1384,22 @@ bool UITask::getGPSState() {
void UITask::toggleGPS() {
#if ENV_INCLUDE_GPS == 1
extern GPSDutyCycle gpsDuty;
if (_sensors != NULL) {
if (_node_prefs->gps_enabled) {
// Disable GPS — cut hardware power
// Disable GPS cut hardware power
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
gpsDuty.disable();
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
notify(UIEventType::ack);
} else {
// Enable GPS — start duty cycle
// Enable GPS — power on hardware
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
gpsDuty.enable();
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
#endif
notify(UIEventType::ack);
}
the_mesh.savePrefs();
@@ -1517,6 +1608,16 @@ void UITask::gotoRepeaterAdmin(int contactIdx) {
_next_refresh = 100;
}
void UITask::gotoDiscoveryScreen() {
((DiscoveryScreen*)discovery_screen)->resetScroll();
setCurrScreen(discovery_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#ifdef MECK_WEB_READER
void UITask::gotoWebReader() {
// Lazy-initialize on first use (same pattern as audiobook player)
@@ -1542,6 +1643,19 @@ void UITask::gotoWebReader() {
}
#endif
void UITask::gotoMapScreen() {
MapScreen* map = (MapScreen*)map_screen;
if (_display != NULL) {
map->enter(*_display);
}
setCurrScreen(map_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);

View File

@@ -30,6 +30,9 @@
#include "WebReaderScreen.h"
#endif
// MapScreen.h included in UITask.cpp and main.cpp only (PNGdec headers
// conflict with BLE if pulled into the global include chain)
class UITask : public AbstractUITask {
DisplayDriver* _display;
SensorManager* _sensors;
@@ -41,11 +44,17 @@ class UITask : public AbstractUITask {
#endif
unsigned long _next_refresh, _auto_off;
unsigned long _kb_flash_off_at; // Keyboard flash turn-off timer
#ifdef HAS_4G_MODEM
bool _incomingCallRinging; // Currently ringing (incoming call)
unsigned long _nextCallFlash; // Next LED toggle time
bool _callFlashState; // Current LED state during ring
#endif
NodePrefs* _node_prefs;
char _alert[80];
unsigned long _alert_expiry;
int _msgcount;
unsigned long ui_started_at, next_batt_chck;
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
int next_backlight_btn_check = 0;
#ifdef PIN_STATUS_LED
int led_state = 0;
@@ -70,9 +79,11 @@ class UITask : public AbstractUITask {
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
#endif
UIScreen* repeater_admin; // Repeater admin screen
UIScreen* discovery_screen; // Node discovery scan screen
#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;
void userLedHandler();
@@ -90,6 +101,11 @@ public:
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
next_batt_chck = _next_refresh = 0;
_kb_flash_off_at = 0;
#ifdef HAS_4G_MODEM
_incomingCallRinging = false;
_nextCallFlash = 0;
_callFlashState = false;
#endif
ui_started_at = 0;
curr = NULL;
}
@@ -104,6 +120,8 @@ public:
void gotoOnboarding(); // Navigate to settings in onboarding mode
void gotoAudiobookPlayer(); // Navigate to audiobook player
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
void gotoDiscoveryScreen(); // Navigate to node discovery scan
void gotoMapScreen(); // Navigate to map tile screen
#ifdef MECK_WEB_READER
void gotoWebReader(); // Navigate to web reader (browser)
#endif
@@ -131,6 +149,8 @@ public:
bool isOnSettingsScreen() const { return curr == settings_screen; }
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
bool isOnMapScreen() const { return curr == map_screen; }
#ifdef MECK_WEB_READER
bool isOnWebReader() const { return curr == web_reader; }
#endif
@@ -174,6 +194,8 @@ public:
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
UIScreen* getMapScreen() const { return map_screen; }
#ifdef MECK_WEB_READER
UIScreen* getWebReaderScreen() const { return web_reader; }
#endif
@@ -181,7 +203,7 @@ public:
// from AbstractUITask
void msgRead(int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path = nullptr) override;
const uint8_t* path = nullptr, int8_t snr = 0) override;
void notify(UIEventType t = UIEventType::none) override;
void loop() override;

View File

@@ -2152,6 +2152,8 @@ private:
needsFreshTls = true; // Recreate at top of next iteration (after http destructor)
// After 2 failures, try WiFi reconnect — the lwIP stack may be wedged
// Skip on WiFi companion variant — disconnect kills the companion TCP server
#ifndef MECK_WIFI_COMPANION
if (totalRetries == 2 && isWiFiConnected()) {
Serial.println("WebReader: WiFi reconnect after persistent failures");
// Destroy TLS before WiFi teardown
@@ -2170,6 +2172,7 @@ private:
Serial.printf("WebReader: WiFi reconnected, IP: %s\n",
WiFi.localIP().toString().c_str());
}
#endif
int retryDelay = 1000 + totalRetries * 1000; // 2s, 3s, 4s, 5s
Serial.printf("WebReader: Connection error %d, retrying in %dms... (attempt %d/4)\n",
@@ -2246,6 +2249,8 @@ private:
clearCloudflareCookies(domain);
// WiFi reconnect after 2 total failures (same logic as conn errors)
// Skip on WiFi companion variant — disconnect kills the companion TCP server
#ifndef MECK_WIFI_COMPANION
if (totalRetries == 2 && isWiFiConnected()) {
Serial.println("WebReader: WiFi reconnect after persistent failures");
if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
@@ -2262,6 +2267,7 @@ private:
Serial.printf("WebReader: WiFi reconnected, IP: %s\n",
WiFi.localIP().toString().c_str());
}
#endif
int retryDelay = 1000 + totalRetries * 1000; // 2s, 3s, 4s, 5s
Serial.printf("WebReader: Server error %d, retrying in %dms... (attempt %d/4)\n",
@@ -2947,7 +2953,7 @@ private:
display.setCursor(0, y);
if (_searchEditing) {
char searchDisp[140];
int maxShow = maxChars - 8; // "Search: " prefix + cursor
int maxShow = maxChars - 8; // "Search: " prefix + cursor
int start = 0;
if (_searchLen > maxShow) start = _searchLen - maxShow;
snprintf(searchDisp, sizeof(searchDisp), "Search: %s_", _searchBuffer + start);
@@ -3099,7 +3105,7 @@ private:
if (_urlEditing) {
display.print("Type URL Ent:Go");
} else if (_searchEditing) {
display.print("Type query Ent:Search");
display.print("Type query Ent:Search");
} else {
char footerBuf[48];
bool hasData = (_cookieCount > 0 || !_history.empty());
@@ -5017,6 +5023,7 @@ public:
// Called when entering the web reader screen
void enter(DisplayDriver& display) {
_display = &display;
_fetchError = ""; // Clear stale errors from previous session
initLayout(display);
loadBookmarks();
loadHistory();
@@ -5110,10 +5117,16 @@ public:
if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
_tlsHost = String();
#ifdef MECK_WIFI_COMPANION
// WiFi companion: keep WiFi alive for the companion TCP server.
// Don't disconnect or change mode — just reset our internal state.
_wifiState = WIFI_CONNECTED; // WiFi is still up
#else
// Shut down WiFi to reclaim ~50-70KB internal RAM
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
_wifiState = WIFI_IDLE;
#endif
// Reset mode so re-entry starts fresh
_mode = HOME;
@@ -5131,7 +5144,6 @@ public:
bool wantsTextReader() const { return _requestTextReader; }
void clearTextReaderRequest() { _requestTextReader = false; }
bool isUrlEditing() const { return _urlEditing && _mode == HOME; }
bool isSearchEditing() const { return _searchEditing && _mode == HOME; }
bool isWifiSetup() const { return _mode == WIFI_SETUP; }
bool isPasswordEntry() const {
return _mode == WIFI_SETUP && _wifiState == WIFI_ENTERING_PASS;
@@ -5139,6 +5151,9 @@ public:
bool isFormFilling() const {
return _mode == FORM_FILL && _formFieldEditing;
}
bool isSearchEditing() const {
return _searchEditing && _mode == HOME;
}
bool isIRCMode() const { return _mode == IRC_CHAT || _mode == IRC_SETUP; }
bool isIRCTextEntry() const {
return (_mode == IRC_CHAT && _ircComposing) ||

View File

@@ -1,8 +1,11 @@
#include "UITask.h"
#include <helpers/TxtDataHelpers.h>
#include "../MyMesh.h"
#include "NotesScreen.h"
#include "RepeaterAdminScreen.h"
#include "MapScreen.h"
#include "target.h"
#ifdef WIFI_SSID
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
#include <WiFi.h>
#endif
@@ -34,11 +37,18 @@
#include "ContactsScreen.h"
#include "TextReaderScreen.h"
#include "SettingsScreen.h"
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.h"
#endif
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
#include "ModemManager.h"
#endif
class SplashScreen : public UIScreen {
UITask* _task;
unsigned long dismiss_after;
char _version_info[12];
char _version_info[24];
public:
SplashScreen(UITask* task) : _task(task) {
@@ -85,13 +95,20 @@ class HomeScreen : public UIScreen {
FIRST,
RECENT,
RADIO,
#ifdef BLE_PIN_CODE
BLUETOOTH,
#elif defined(MECK_WIFI_COMPANION)
WIFI_STATUS,
#endif
ADVERT,
#if ENV_INCLUDE_GPS == 1
GPS,
#endif
#if UI_SENSORS_PAGE == 1
SENSORS,
#endif
#if HAS_BQ27220
BATTERY,
#endif
SHUTDOWN,
Count // keep as last
@@ -103,12 +120,13 @@ class HomeScreen : public UIScreen {
NodePrefs* _node_prefs;
uint8_t _page;
bool _shutdown_init;
unsigned long _shutdown_at; // earliest time to proceed with shutdown (after e-ink refresh)
bool _editing_utc;
int8_t _saved_utc_offset; // for cancel/undo
AdvertPath recent[UI_RECENT_LIST_SIZE];
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
// Use voltage-based estimation to match BLE app readings
uint8_t batteryPercentage = 0;
if (batteryMilliVolts > 0) {
@@ -137,6 +155,8 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
int iconX = display.width() - totalWidth;
int iconY = 0; // vertically align with node name text
if (outIconX) *outIconX = iconX;
// battery outline
display.drawRect(iconX, iconY, iconWidth, iconHeight);
@@ -156,6 +176,24 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
display.setTextSize(1); // restore default text size
}
#ifdef MECK_AUDIO_VARIANT
// ---- Audio background playback indicator ----
// Shows a small play symbol to the left of the battery icon when an
// audiobook is actively playing in the background.
// Uses the font renderer (not manual pixel drawing) since it handles
// the e-ink coordinate scaling correctly.
void renderAudioIndicator(DisplayDriver& display, int batteryLeftX) {
if (!_task->isAudioPlayingInBackground()) return;
display.setColor(DisplayDriver::GREEN);
display.setTextSize(0); // tiny font (same as clock & battery %)
int x = batteryLeftX - display.getTextWidth(">>") - 2;
display.setCursor(x, -3); // align vertically with battery text
display.print(">>");
display.setTextSize(1); // restore
}
#endif
CayenneLPP sensors_lpp;
int sensors_nb = 0;
bool sensors_scroll = false;
@@ -186,7 +224,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
public:
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
_shutdown_init(false), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
_shutdown_init(false), _shutdown_at(0), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
bool isEditingUTC() const { return _editing_utc; }
void cancelEditUTC() {
@@ -197,23 +235,31 @@ public:
}
void poll() override {
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
if (_shutdown_init && millis() >= _shutdown_at && !_task->isButtonPressed()) {
_task->shutdown();
}
}
int render(DisplayDriver& display) override {
char tmp[80];
// node name
display.setTextSize(1);
// node name (tinyfont to avoid overlapping clock)
display.setTextSize(0);
display.setColor(DisplayDriver::GREEN);
char filtered_name[sizeof(_node_prefs->node_name)];
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
display.setCursor(0, 0);
display.setCursor(0, -3);
display.print(filtered_name);
// battery voltage
#ifdef MECK_AUDIO_VARIANT
int battLeftX = display.width(); // default if battery doesn't render
renderBatteryIndicator(display, _task->getBattMilliVolts(), &battLeftX);
// audio background playback indicator (>> icon next to battery)
renderAudioIndicator(display, battLeftX);
#else
renderBatteryIndicator(display, _task->getBattMilliVolts());
#endif
// centered clock (tinyfont) - only show when time is valid
{
@@ -250,28 +296,70 @@ public:
}
if (_page == HomePage::FIRST) {
int y = 20;
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(2);
sprintf(tmp, "MSG: %d", _task->getMsgCount());
display.drawTextCentered(display.width() / 2, 20, tmp);
sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount());
display.drawTextCentered(display.width() / 2, y, tmp);
y += 18;
#ifdef WIFI_SSID
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
IPAddress ip = WiFi.localIP();
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 54, tmp);
if (ip != IPAddress(0,0,0,0)) {
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 12;
}
#endif
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 43, "< Connected >");
} else if (the_mesh.getBLEPin() != 0) { // BT pin
display.drawTextCentered(display.width() / 2, y, "< Connected >");
y += 12;
#ifdef BLE_PIN_CODE
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, 43, tmp);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 18;
#endif
}
#endif
// Menu shortcuts - tinyfont monospaced grid
y += 6;
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0); // tinyfont 6x8 monospaced
display.drawTextCentered(display.width() / 2, y, "Press:");
y += 12;
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
y += 10;
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
#elif defined(HAS_4G_MODEM)
display.drawTextCentered(display.width() / 2, y, "[T] Phone ");
#elif defined(MECK_AUDIO_VARIANT) && defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser ");
#elif defined(MECK_AUDIO_VARIANT)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks ");
#elif defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
#else
y -= 10; // reclaim the row for standalone
#endif
y += 14;
// Nav hint
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
display.setTextSize(1); // restore
} else if (_page == HomePage::RECENT) {
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
display.setColor(DisplayDriver::GREEN);
@@ -316,34 +404,83 @@ public:
display.setCursor(0, 53);
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
display.print(tmp);
#ifdef BLE_PIN_CODE
} else if (_page == HomePage::BLUETOOTH) {
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18,
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
32, 32);
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 53, "< Connected >");
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, 53, tmp);
}
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
display.drawTextCentered(display.width() / 2, 72, "toggle: " PRESS_LABEL);
#endif
#ifdef MECK_WIFI_COMPANION
} else if (_page == HomePage::WIFI_STATUS) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 18, "WiFi Companion");
int wy = 36;
display.setTextSize(0);
if (WiFi.status() == WL_CONNECTED) {
display.setColor(DisplayDriver::GREEN);
snprintf(tmp, sizeof(tmp), "SSID: %s", WiFi.SSID().c_str());
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 10;
IPAddress ip = WiFi.localIP();
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 10;
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 12;
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, wy, "< App Connected >");
} else {
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, wy, "Waiting for app...");
}
} else {
display.setColor(DisplayDriver::RED);
display.drawTextCentered(display.width() / 2, wy, "Not connected");
wy += 12;
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, wy, "Configure in Settings");
}
display.setTextSize(1);
#endif
} else if (_page == HomePage::ADVERT) {
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
#if ENV_INCLUDE_GPS == 1
} else if (_page == HomePage::GPS) {
extern GPSStreamCounter gpsStream;
LocationProvider* nmea = sensors.getLocationProvider();
char buf[50];
int y = 18;
bool gps_state = _task->getGPSState();
#ifdef PIN_GPS_SWITCH
bool hw_gps_state = digitalRead(PIN_GPS_SWITCH);
if (gps_state != hw_gps_state) {
strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)");
// GPS state line
if (!_node_prefs->gps_enabled) {
strcpy(buf, "gps off");
} else {
strcpy(buf, gps_state ? "gps on" : "gps off");
strcpy(buf, "gps on");
}
#else
strcpy(buf, gps_state ? "gps on" : "gps off");
#endif
display.drawTextLeftAlign(0, y, buf);
if (nmea == NULL) {
y = y + 12;
display.drawTextLeftAlign(0, y, "Can't access GPS");
@@ -355,6 +492,19 @@ public:
sprintf(buf, "%d", nmea->satellitesCount());
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
// NMEA sentence counter — confirms baud rate and data flow
display.drawTextLeftAlign(0, y, "sentences");
if (_node_prefs->gps_enabled) {
uint16_t sps = gpsStream.getSentencesPerSec();
uint32_t total = gpsStream.getSentenceCount();
sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total);
} else {
strcpy(buf, "hw off");
}
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
display.drawTextLeftAlign(0, y, "pos");
sprintf(buf, "%.4f %.4f",
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
@@ -473,6 +623,68 @@ public:
}
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
else sensors_scroll_offset = 0;
#endif
#if HAS_BQ27220
} else if (_page == HomePage::BATTERY) {
char buf[30];
int y = 18;
// Title
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Battery Gauge");
y += 12;
display.setColor(DisplayDriver::LIGHT);
// Time to empty
uint16_t tte = board.getTimeToEmpty();
display.drawTextLeftAlign(0, y, "remaining");
if (tte == 0xFFFF || tte == 0) {
strcpy(buf, tte == 0 ? "depleted" : "charging");
} else if (tte >= 60) {
sprintf(buf, "%dh %dm", tte / 60, tte % 60);
} else {
sprintf(buf, "%d min", tte);
}
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Average current
int16_t avgCur = board.getAvgCurrent();
display.drawTextLeftAlign(0, y, "avg current");
sprintf(buf, "%d mA", avgCur);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Average power
int16_t avgPow = board.getAvgPower();
display.drawTextLeftAlign(0, y, "avg power");
sprintf(buf, "%d mW", avgPow);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Voltage (already available)
uint16_t mv = board.getBattMilliVolts();
display.drawTextLeftAlign(0, y, "voltage");
sprintf(buf, "%d.%03d V", mv / 1000, mv % 1000);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Remaining capacity (clamped to design capacity — gauge FCC may be
// stale from factory defaults until a full charge cycle re-learns it)
uint16_t remCap = board.getRemainingCapacity();
uint16_t desCap = board.getDesignCapacity();
if (desCap > 0 && remCap > desCap) remCap = desCap;
display.drawTextLeftAlign(0, y, "remaining cap");
sprintf(buf, "%d mAh", remCap);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Battery temperature
int16_t battTemp = board.getBattTemperature();
display.drawTextLeftAlign(0, y, "temperature");
sprintf(buf, "%d.%d C", battTemp / 10, abs(battTemp % 10));
display.drawTextRightAlign(display.width()-1, y, buf);
#endif
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::GREEN);
@@ -533,6 +745,7 @@ public:
}
return true;
}
#ifdef BLE_PIN_CODE
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
_task->disableSerial();
@@ -541,6 +754,7 @@ public:
}
return true;
}
#endif
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
_task->notify(UIEventType::ack);
if (the_mesh.advert()) {
@@ -569,7 +783,8 @@ public:
}
#endif
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
_shutdown_init = true; // need to wait for button to be released
_shutdown_init = true;
_shutdown_at = millis() + 900; // allow e-ink refresh (644ms) before shutdown
return true;
}
return false;
@@ -708,6 +923,17 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
vibration.begin();
#endif
// Keyboard backlight for message flash notifications
#ifdef KB_BL_PIN
pinMode(KB_BL_PIN, OUTPUT);
digitalWrite(KB_BL_PIN, LOW);
#endif
#ifdef HAS_4G_MODEM
// Sync ringtone enabled state to modem manager
modemManager.setRingtoneEnabled(node_prefs->ringtone_enabled);
#endif
ui_started_at = millis();
_alert_expiry = 0;
@@ -717,7 +943,14 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
channel_screen = new ChannelScreen(this, &rtc_clock);
contacts_screen = new ContactsScreen(this, &rtc_clock);
text_reader = new TextReaderScreen(this);
notes_screen = new NotesScreen(this);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
#ifdef HAS_4G_MODEM
sms_screen = new SMSScreen(this);
#endif
map_screen = new MapScreen(this);
setCurrScreen(splash);
}
@@ -759,12 +992,13 @@ switch(t){
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
if (msgcount == 0 && curr == msg_preview) {
gotoHomeScreen();
}
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path) {
_msgcount = msgcount;
// Add to preview screen (for notifications on non-keyboard devices)
@@ -782,15 +1016,25 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
}
}
// Add to channel history screen with channel index
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text);
// Add to channel history screen with channel index and path data
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path);
// If user is currently viewing this channel, mark it as read immediately
// (they can see the message arrive in real-time)
if (isOnChannelScreen() &&
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
}
#if defined(LilyGo_TDeck_Pro)
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
// Messages are stored in channel history, accessible via 'M' key
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
// Suppress alert entirely on admin screen - it needs focused interaction
if (!isOnRepeaterAdmin()) {
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
}
#else
// Other devices: Show full preview screen (legacy behavior)
setCurrScreen(msg_preview);
@@ -805,6 +1049,14 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
_next_refresh = 100; // trigger refresh
}
}
// Keyboard flash notification
#ifdef KB_BL_PIN
if (_node_prefs->kb_flash_notify) {
digitalWrite(KB_BL_PIN, HIGH);
_kb_flash_off_at = millis() + 200; // 200ms flash
}
#endif
}
void UITask::userLedHandler() {
@@ -854,8 +1106,32 @@ void UITask::shutdown(bool restart){
if (restart) {
_board->reboot();
} else {
_display->turnOff();
// Disable BLE if active
if (_serial != NULL && _serial->isEnabled()) {
_serial->disable();
}
// Disable WiFi if active
#ifdef WIFI_SSID
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
#endif
// Disable GPS if active
#if ENV_INCLUDE_GPS == 1
{
if (_sensors != NULL && _node_prefs != NULL && _node_prefs->gps_enabled) {
_sensors->setSettingValue("gps", "0");
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
}
}
#endif
// Power off LoRa radio, display, and board
radio_driver.powerOff();
_display->turnOff();
_board->powerOff();
}
}
@@ -940,6 +1216,63 @@ void UITask::loop() {
userLedHandler();
// Turn off keyboard flash after timeout
#ifdef KB_BL_PIN
if (_kb_flash_off_at && millis() >= _kb_flash_off_at) {
#ifdef HAS_4G_MODEM
// Don't turn off LED if incoming call flash is active
if (!_incomingCallRinging) {
digitalWrite(KB_BL_PIN, LOW);
}
#else
digitalWrite(KB_BL_PIN, LOW);
#endif
_kb_flash_off_at = 0;
}
#endif
// Incoming call LED flash — rapid repeated pulse while ringing
#if defined(HAS_4G_MODEM) && defined(KB_BL_PIN)
{
bool ringing = modemManager.isRinging();
if (ringing && !_incomingCallRinging) {
// Ringing just started
_incomingCallRinging = true;
_callFlashState = false;
_nextCallFlash = 0; // Start immediately
// Wake display for incoming call
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + 60000; // Keep display on while ringing (60s)
} else if (!ringing && _incomingCallRinging) {
// Ringing stopped
_incomingCallRinging = false;
// Only turn off LED if message flash isn't also active
if (!_kb_flash_off_at) {
digitalWrite(KB_BL_PIN, LOW);
}
_callFlashState = false;
}
// Rapid LED flash while ringing (if kb_flash_notify is ON)
if (_incomingCallRinging && _node_prefs->kb_flash_notify) {
unsigned long now = millis();
if (now >= _nextCallFlash) {
_callFlashState = !_callFlashState;
digitalWrite(KB_BL_PIN, _callFlashState ? HIGH : LOW);
// 250ms on, 250ms off — fast pulse to distinguish from single msg flash
_nextCallFlash = now + 250;
}
// Extend auto-off while ringing
_auto_off = millis() + 60000;
}
}
#endif
#ifdef PIN_BUZZER
if (buzzer.isPlaying()) buzzer.loop();
#endif
@@ -980,10 +1313,11 @@ if (curr) curr->poll();
if (millis() > next_batt_chck) {
uint16_t milliVolts = getBattMilliVolts();
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
_low_batt_count++;
if (_low_batt_count >= 3) { // 3 consecutive low readings (~24s) to avoid transient sags
// show low battery shutdown alert
// we should only do this for eink displays, which will persist after power loss
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO)
// show low battery shutdown alert on e-ink (persists after power loss)
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO) || defined(LilyGo_TDeck_Pro)
if (_display != NULL) {
_display->startFrame();
_display->setTextSize(2);
@@ -995,7 +1329,9 @@ if (curr) curr->poll();
#endif
shutdown();
}
} else {
_low_batt_count = 0;
}
next_batt_chck = millis() + 8000;
}
@@ -1037,39 +1373,38 @@ char UITask::handleTripleClick(char c) {
}
bool UITask::getGPSState() {
if (_sensors != NULL) {
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
return !strcmp(_sensors->getSettingValue(i), "1");
}
}
}
return false;
#if ENV_INCLUDE_GPS == 1
return _node_prefs != NULL && _node_prefs->gps_enabled;
#else
return false;
#endif
}
void UITask::toggleGPS() {
#if ENV_INCLUDE_GPS == 1
if (_sensors != NULL) {
// toggle GPS on/off
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
notify(UIEventType::ack);
} else {
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
break;
if (_node_prefs->gps_enabled) {
// Disable GPS — cut hardware power
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
notify(UIEventType::ack);
} else {
// Enable GPS — power on hardware
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
#endif
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
}
}
#endif
}
void UITask::toggleBuzzer() {
@@ -1126,6 +1461,10 @@ bool UITask::isEditingHomeScreen() const {
void UITask::gotoChannelScreen() {
((ChannelScreen *) channel_screen)->resetScroll();
// Mark the currently viewed channel as read
((ChannelScreen *) channel_screen)->markChannelRead(
((ChannelScreen *) channel_screen)->getViewChannelIdx()
);
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -1157,8 +1496,21 @@ void UITask::gotoTextReader() {
_next_refresh = 100;
}
void UITask::gotoNotesScreen() {
NotesScreen* notes = (NotesScreen*)notes_screen;
if (_display != NULL) {
notes->enter(*_display);
}
setCurrScreen(notes_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoSettingsScreen() {
((SettingsScreen*)settings_screen)->enter();
((SettingsScreen *) settings_screen)->enter();
setCurrScreen(settings_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -1168,7 +1520,7 @@ void UITask::gotoSettingsScreen() {
}
void UITask::gotoOnboarding() {
((SettingsScreen*)settings_screen)->enterOnboarding();
((SettingsScreen *) settings_screen)->enterOnboarding();
setCurrScreen(settings_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -1177,10 +1529,43 @@ void UITask::gotoOnboarding() {
_next_refresh = 100;
}
void UITask::gotoAudiobookPlayer() {
#ifdef MECK_AUDIO_VARIANT
if (audiobook_screen == nullptr) return; // No audio hardware
AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)audiobook_screen;
if (_display != NULL) {
abPlayer->enter(*_display);
}
setCurrScreen(audiobook_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
#endif
}
#ifdef HAS_4G_MODEM
void UITask::gotoSMSScreen() {
SMSScreen* smsScr = (SMSScreen*)sms_screen;
smsScr->activate();
setCurrScreen(sms_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
uint8_t UITask::getChannelScreenViewIdx() const {
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
}
int UITask::getUnreadMsgCount() const {
return ((ChannelScreen *) channel_screen)->getTotalUnread();
}
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
// Format the message as "Sender: message"
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
@@ -1188,4 +1573,109 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
// Add to channel history with path_len=0 (local message)
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
}
}
void UITask::markChannelReadFromBLE(uint8_t channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
// Trigger a refresh so the home screen unread count updates in real-time
_next_refresh = millis() + 200;
}
void UITask::gotoRepeaterAdmin(int contactIdx) {
// Lazy-initialize on first use (same pattern as audiobook player)
if (repeater_admin == nullptr) {
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
}
// Get contact name for the screen header
ContactInfo contact;
char name[32] = "Unknown";
if (the_mesh.getContactByIdx(contactIdx, contact)) {
strncpy(name, contact.name, sizeof(name) - 1);
name[sizeof(name) - 1] = '\0';
}
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
admin->openForContact(contactIdx, name);
setCurrScreen(repeater_admin);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#ifdef MECK_WEB_READER
void UITask::gotoWebReader() {
// Lazy-initialize on first use (same pattern as audiobook player)
if (web_reader == nullptr) {
Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
web_reader = new WebReaderScreen(this);
Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap());
}
WebReaderScreen* wr = (WebReaderScreen*)web_reader;
if (_display != NULL) {
wr->enter(*_display);
}
// Heap diagnostic — check state after web reader entry (WiFi connects later)
Serial.printf("[HEAP] WebReader enter - free: %u, largest: %u, PSRAM: %u\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap(), ESP.getFreePsram());
setCurrScreen(web_reader);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
void UITask::gotoMapScreen() {
MapScreen* map = (MapScreen*)map_screen;
if (_display != NULL) {
map->enter(*_display);
}
setCurrScreen(map_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
_next_refresh = 100; // trigger re-render
}
}
void UITask::onAdminCliResponse(const char* from_name, const char* text) {
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
_next_refresh = 100; // trigger re-render
}
}
void UITask::onAdminTelemetryResult(const uint8_t* data, uint8_t len) {
Serial.printf("[UITask] onAdminTelemetryResult: %d bytes, onAdmin=%d\n", len, isOnRepeaterAdmin());
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onTelemetryResult(data, len);
_next_refresh = 100; // trigger re-render
}
}
#ifdef MECK_AUDIO_VARIANT
bool UITask::isAudioPlayingInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isAudioActive();
}
bool UITask::isAudioPausedInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isBookOpen() && !player->isAudioActive();
}
#endif

View File

@@ -68,7 +68,7 @@ void Dispatcher::loop() {
next_tx_time = futureMillis(t * getAirtimeBudgetFactor());
_radio->onSendFinished();
logTx(outbound, 2 + outbound->path_len + outbound->payload_len);
logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
if (outbound->isRouteFlood()) {
n_sent_flood++;
} else {
@@ -80,7 +80,7 @@ void Dispatcher::loop() {
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): WARNING: outbound packed send timed out!", getLogDateTime());
_radio->onSendFinished();
logTxFail(outbound, 2 + outbound->path_len + outbound->payload_len);
logTxFail(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
releasePacket(outbound); // return to pool
outbound = NULL;
@@ -141,12 +141,13 @@ void Dispatcher::checkRecv() {
}
pkt->path_len = raw[i++];
if (pkt->path_len > MAX_PATH_SIZE || i + pkt->path_len > len) {
uint16_t path_byte_len = pkt->getPathByteLen();
if (path_byte_len > MAX_PATH_SIZE || i + path_byte_len > len) {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(): partial or corrupt packet received, len=%d", getLogDateTime(), len);
_mgr->free(pkt); // put back into pool
pkt = NULL;
} else {
memcpy(pkt->path, &raw[i], pkt->path_len); i += pkt->path_len;
memcpy(pkt->path, &raw[i], path_byte_len); i += path_byte_len;
pkt->payload_len = len - i; // payload is remainder
if (pkt->payload_len > sizeof(pkt->payload)) {
@@ -258,7 +259,8 @@ void Dispatcher::checkSend() {
memcpy(&raw[len], &outbound->transport_codes[1], 2); len += 2;
}
raw[len++] = outbound->path_len;
memcpy(&raw[len], outbound->path, outbound->path_len); len += outbound->path_len;
uint16_t out_pbl = outbound->getPathByteLen();
memcpy(&raw[len], outbound->path, out_pbl); len += out_pbl;
if (len + outbound->payload_len > MAX_TRANS_UNIT) {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): FATAL: Invalid packet queued... too long, len=%d", getLogDateTime(), len + outbound->payload_len);
@@ -312,8 +314,8 @@ void Dispatcher::releasePacket(Packet* packet) {
}
void Dispatcher::sendPacket(Packet* packet, uint8_t priority, uint32_t delay_millis) {
if (packet->path_len > MAX_PATH_SIZE || packet->payload_len > MAX_PACKET_PAYLOAD) {
MESH_DEBUG_PRINTLN("%s Dispatcher::sendPacket(): ERROR: invalid packet... path_len=%d, payload_len=%d", getLogDateTime(), (uint32_t) packet->path_len, (uint32_t) packet->payload_len);
if (packet->getPathByteLen() > MAX_PATH_SIZE || packet->payload_len > MAX_PACKET_PAYLOAD) {
MESH_DEBUG_PRINTLN("%s Dispatcher::sendPacket(): ERROR: invalid packet... path_len=%d (byte_len=%d), payload_len=%d", getLogDateTime(), (uint32_t) packet->path_len, (uint32_t) packet->getPathByteLen(), (uint32_t) packet->payload_len);
_mgr->free(packet);
} else {
_mgr->queueOutbound(packet, priority, futureMillis(delay_millis));

View File

@@ -20,6 +20,10 @@ public:
memcpy(dest, pub_key, PATH_HASH_SIZE); // hash is just prefix of pub_key
return PATH_HASH_SIZE;
}
int copyHashTo(uint8_t* dest, uint8_t len) const {
memcpy(dest, pub_key, len);
return len;
}
bool isHashMatch(const uint8_t* hash) const {
return memcmp(hash, pub_key, PATH_HASH_SIZE) == 0;
}
@@ -90,5 +94,4 @@ public:
void readFrom(const uint8_t* src, size_t len);
};
}
}

View File

@@ -15,7 +15,7 @@ bool Mesh::allowPacketForward(const mesh::Packet* packet) {
return false; // by default, Transport NOT enabled
}
uint32_t Mesh::getRetransmitDelay(const mesh::Packet* packet) {
uint32_t t = (_radio->getEstAirtimeFor(packet->getRawLength()) * 52 / 50) / 2;
uint32_t t = (uint32_t)(_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.5f);
return _rng->nextInt(0, 5)*t;
}
@@ -77,7 +77,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
return ACTION_RELEASE;
}
if (pkt->isRouteDirect() && pkt->path_len >= PATH_HASH_SIZE) {
if (pkt->isRouteDirect() && (pkt->path_len & 63) > 0) {
uint8_t dir_bph = (pkt->path_len >> 6) + 1; // bytes per hop for this packet
// check for 'early received' ACK
if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) {
int i = 0;
@@ -88,7 +90,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
}
}
if (self_id.isHashMatch(pkt->path) && allowPacketForward(pkt)) {
if (self_id.isHashMatch(pkt->path, dir_bph) && allowPacketForward(pkt)) {
if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) {
return forwardMultipartDirect(pkt);
} else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) {
@@ -158,7 +160,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) {
int k = 0;
uint8_t path_len = data[k++];
uint8_t* path = &data[k]; k += path_len;
uint8_t* path = &data[k]; k += Packet::getPathByteLenFor(path_len);
uint8_t extra_type = data[k++] & 0x0F; // upper 4 bits reserved for future use
uint8_t* extra = &data[k];
uint8_t extra_len = len - k; // remainder of packet (may be padded with zeroes!)
@@ -293,8 +295,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { // a multipart ACK
Packet tmp;
tmp.header = pkt->header;
tmp.path_len = pkt->path_len;
memcpy(tmp.path, pkt->path, pkt->path_len);
tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len);
tmp.payload_len = pkt->payload_len - 1;
memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len);
@@ -320,28 +321,34 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
}
void Mesh::removeSelfFromPath(Packet* pkt) {
// remove our hash from 'path'
pkt->path_len -= PATH_HASH_SIZE;
#if 0
memcpy(pkt->path, &pkt->path[PATH_HASH_SIZE], pkt->path_len);
#elif PATH_HASH_SIZE == 1
for (int k = 0; k < pkt->path_len; k++) { // shuffle bytes by 1
pkt->path[k] = pkt->path[k + 1];
}
#else
#error "need path remove impl"
#endif
uint8_t bph = (pkt->path_len >> 6) + 1; // bytes per hop
uint8_t hops = pkt->path_len & 63;
if (hops == 0) return;
uint16_t new_byte_len = (hops - 1) * bph;
// remove first bph bytes (our hash) from path, shift remainder
memmove(pkt->path, &pkt->path[bph], new_byte_len);
// decrement hop count, preserve mode bits
pkt->path_len = (pkt->path_len & 0xC0) | ((hops - 1) & 63);
}
DispatcherAction Mesh::routeRecvPacket(Packet* packet) {
if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit()
&& packet->path_len + PATH_HASH_SIZE <= MAX_PATH_SIZE && allowPacketForward(packet)) {
// append this node's hash to 'path'
packet->path_len += self_id.copyHashTo(&packet->path[packet->path_len]);
&& allowPacketForward(packet)) {
uint8_t bph = (packet->path_len >> 6) + 1; // bytes per hop
uint8_t hops = packet->path_len & 63;
uint16_t byte_len = hops * bph;
uint32_t d = getRetransmitDelay(packet);
// as this propagates outwards, give it lower and lower priority
return ACTION_RETRANSMIT_DELAYED(packet->path_len, d); // give priority to closer sources, than ones further away
if (byte_len + bph <= MAX_PATH_SIZE) {
// append this node's hash (bph bytes of pub_key) to path
memcpy(&packet->path[byte_len], self_id.pub_key, bph);
// increment hop count, preserve mode bits
packet->path_len = (packet->path_len & 0xC0) | ((hops + 1) & 63);
uint32_t d = getRetransmitDelay(packet);
// as this propagates outwards, give it lower and lower priority
return ACTION_RETRANSMIT_DELAYED(hops + 1, d); // give priority to closer sources, than ones further away
}
}
return ACTION_RELEASE;
}
@@ -353,8 +360,7 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) {
if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { // a multipart ACK
Packet tmp;
tmp.header = pkt->header;
tmp.path_len = pkt->path_len;
memcpy(tmp.path, pkt->path, pkt->path_len);
tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len);
tmp.payload_len = pkt->payload_len - 1;
memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len);
@@ -376,7 +382,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
delay_millis += getDirectRetransmitDelay(packet) + 300;
auto a1 = createMultiAck(crc, extra);
if (a1) {
memcpy(a1->path, packet->path, a1->path_len = packet->path_len);
a1->path_len = Packet::copyPath(a1->path, packet->path, packet->path_len);
a1->header &= ~PH_ROUTE_MASK;
a1->header |= ROUTE_TYPE_DIRECT;
sendPacket(a1, 0, delay_millis);
@@ -386,7 +392,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
auto a2 = createAck(crc);
if (a2) {
memcpy(a2->path, packet->path, a2->path_len = packet->path_len);
a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len);
a2->header &= ~PH_ROUTE_MASK;
a2->header |= ROUTE_TYPE_DIRECT;
sendPacket(a2, 0, delay_millis);
@@ -624,7 +630,7 @@ Packet* Mesh::createControlData(const uint8_t* data, size_t len) {
return packet;
}
void Mesh::sendFlood(Packet* packet, uint32_t delay_millis) {
void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_bytes_per_hop) {
if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) {
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime());
return;
@@ -632,7 +638,9 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis) {
packet->header &= ~PH_ROUTE_MASK;
packet->header |= ROUTE_TYPE_FLOOD;
packet->path_len = 0;
// encode bytes-per-hop mode in upper 2 bits of path_len, 0 hops initially
uint8_t mode = (path_bytes_per_hop > 1) ? (path_bytes_per_hop - 1) : 0;
packet->path_len = (mode << 6);
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
@@ -647,7 +655,7 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis) {
sendPacket(packet, pri, delay_millis);
}
void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis) {
void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis, uint8_t path_bytes_per_hop) {
if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) {
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime());
return;
@@ -657,7 +665,9 @@ void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_m
packet->header |= ROUTE_TYPE_TRANSPORT_FLOOD;
packet->transport_codes[0] = transport_codes[0];
packet->transport_codes[1] = transport_codes[1];
packet->path_len = 0;
// encode bytes-per-hop mode in upper 2 bits of path_len, 0 hops initially
uint8_t mode = (path_bytes_per_hop > 1) ? (path_bytes_per_hop - 1) : 0;
packet->path_len = (mode << 6);
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
@@ -685,7 +695,7 @@ void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uin
packet->path_len = 0;
pri = 5; // maybe make this configurable
} else {
memcpy(packet->path, path, packet->path_len = path_len);
packet->path_len = Packet::copyPath(packet->path, path, path_len);
if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) {
pri = 1; // slightly less priority
} else {

View File

@@ -195,14 +195,16 @@ public:
/**
* \brief send a locally-generated Packet with flood routing
* \param path_bytes_per_hop number of bytes per path hop (1=legacy, 2, or 3)
*/
void sendFlood(Packet* packet, uint32_t delay_millis=0);
void sendFlood(Packet* packet, uint32_t delay_millis=0, uint8_t path_bytes_per_hop=1);
/**
* \brief send a locally-generated Packet with flood routing
* \param transport_codes array of 2 codes to attach to packet
* \param path_bytes_per_hop number of bytes per path hop (1=legacy, 2, or 3)
*/
void sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis=0);
void sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis=0, uint8_t path_bytes_per_hop=1);
/**
* \brief send a locally-generated Packet with Direct routing
@@ -222,4 +224,4 @@ public:
};
}
}

View File

@@ -11,7 +11,7 @@ Packet::Packet() {
}
int Packet::getRawLength() const {
return 2 + path_len + payload_len + (hasTransportCodes() ? 4 : 0);
return 2 + getPathByteLen() + payload_len + (hasTransportCodes() ? 4 : 0);
}
void Packet::calculatePacketHash(uint8_t* hash) const {
@@ -33,7 +33,8 @@ uint8_t Packet::writeTo(uint8_t dest[]) const {
memcpy(&dest[i], &transport_codes[1], 2); i += 2;
}
dest[i++] = path_len;
memcpy(&dest[i], path, path_len); i += path_len;
uint16_t pbl = getPathByteLen();
memcpy(&dest[i], path, pbl); i += pbl;
memcpy(&dest[i], payload, payload_len); i += payload_len;
return i;
}
@@ -48,8 +49,9 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) {
transport_codes[0] = transport_codes[1] = 0;
}
path_len = src[i++];
if (path_len > sizeof(path)) return false; // bad encoding
memcpy(path, &src[i], path_len); i += path_len;
uint16_t pbl = getPathByteLen();
if (pbl > sizeof(path)) return false; // bad encoding
memcpy(path, &src[i], pbl); i += pbl;
if (i >= len) return false; // bad encoding
payload_len = len - i;
if (payload_len > sizeof(payload)) return false; // bad encoding

View File

@@ -1,6 +1,7 @@
#pragma once
#include <MeshCore.h>
#include <string.h>
namespace mesh {
@@ -81,6 +82,43 @@ public:
float getSNR() const { return ((float)_snr) / 4.0f; }
/**
* \returns the actual byte length of path data.
* path_len encodes: lower 6 bits = hop count, upper 2 bits = bytes-per-hop mode
* mode 0 = 1 byte/hop (legacy), mode 1 = 2 bytes/hop, mode 2 = 3 bytes/hop
*/
uint16_t getPathByteLen() const {
uint8_t hops = path_len & 63;
uint8_t bph = (path_len >> 6) + 1;
return hops * bph;
}
/** Static variant for computing byte length from any path_len value */
static uint16_t getPathByteLenFor(uint8_t path_len) {
return (path_len & 63) * ((path_len >> 6) + 1);
}
/** Validate that encoded path_len won't exceed buffer */
static bool isValidPathLen(uint8_t path_len) {
return getPathByteLenFor(path_len) <= MAX_PATH_SIZE;
}
/** Copy path bytes using encoded path_len; returns path_len unchanged */
static uint8_t copyPath(uint8_t* dest, const uint8_t* src, uint8_t path_len) {
uint16_t bl = getPathByteLenFor(path_len);
if (bl > MAX_PATH_SIZE) bl = MAX_PATH_SIZE;
memcpy(dest, src, bl);
return path_len;
}
/** Write path bytes to buffer; returns number of bytes written */
static uint8_t writePath(uint8_t* dest, const uint8_t* src, uint8_t path_len) {
uint16_t bl = getPathByteLenFor(path_len);
if (bl > MAX_PATH_SIZE) bl = MAX_PATH_SIZE;
memcpy(dest, src, bl);
return (uint8_t)bl;
}
/**
* \returns the encoded/wire format length of this packet
*/
@@ -101,4 +139,4 @@ public:
bool readFrom(const uint8_t src[], uint8_t len);
};
}
}

View File

@@ -39,7 +39,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name, double lat, doubl
}
void BaseChatMesh::sendAckTo(const ContactInfo& dest, uint32_t ack_hash) {
if (dest.out_path_len < 0) {
if (dest.out_path_len == OUT_PATH_UNKNOWN) {
mesh::Packet* ack = createAck(ack_hash);
if (ack) sendFloodScoped(dest, ack, TXT_ACK_DELAY);
} else {
@@ -92,7 +92,7 @@ ContactInfo* BaseChatMesh::allocateContactSlot() {
void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp) {
memset(&ci, 0, sizeof(ci));
ci.id = id;
ci.out_path_len = -1; // initially out_path is unknown
ci.out_path_len = OUT_PATH_UNKNOWN; // initially out_path is unknown
StrHelper::strncpy(ci.name, parser.getName(), sizeof(ci.name));
ci.type = parser.getType();
if (parser.hasLatLon()) {
@@ -263,7 +263,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender
} else {
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len);
if (reply) {
if (from.out_path_len >= 0) { // we have an out_path, so send DIRECT
if (from.out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY);
} else {
sendFloodScoped(from, reply, SERVER_RESPONSE_DELAY);
@@ -273,7 +273,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender
}
} else if (type == PAYLOAD_TYPE_RESPONSE && len > 0) {
onContactResponse(from, data, len);
if (packet->isRouteFlood() && from.out_path_len >= 0) {
if (packet->isRouteFlood() && from.out_path_len != OUT_PATH_UNKNOWN) {
// we have direct path, but other node is still sending flood response, so maybe they didn't receive reciprocal path properly(?)
handleReturnPathRetry(from, packet->path, packet->path_len);
}
@@ -295,7 +295,8 @@ bool BaseChatMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const ui
bool BaseChatMesh::onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) {
// NOTE: default impl, we just replace the current 'out_path' regardless, whenever sender sends us a new out_path.
// FUTURE: could store multiple out_paths per contact, and try to find which is the 'best'(?)
memcpy(from.out_path, out_path, from.out_path_len = out_path_len); // store a copy of path, for sendDirect()
from.out_path_len = out_path_len;
mesh::Packet::copyPath(from.out_path, out_path, out_path_len); // store a copy of path, for sendDirect()
from.lastmod = getRTCClock()->getCurrentTime();
onContactPathUpdated(from);
@@ -317,7 +318,7 @@ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) {
txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer
packet->markDoNotRetransmit(); // ACK was for this node, so don't retransmit
if (packet->isRouteFlood() && from->out_path_len >= 0) {
if (packet->isRouteFlood() && from->out_path_len != OUT_PATH_UNKNOWN) {
// we have direct path, but other node is still sending flood, so maybe they didn't receive reciprocal path properly(?)
handleReturnPathRetry(*from, packet->path, packet->path_len);
}
@@ -386,7 +387,7 @@ int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp,
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
int rc;
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t));
rc = MSG_SEND_SENT_FLOOD;
@@ -412,7 +413,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
int rc;
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t));
rc = MSG_SEND_SENT_FLOOD;
@@ -500,7 +501,7 @@ int BaseChatMesh::sendLogin(const ContactInfo& recipient, const char* password,
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
est_timeout = calcFloodTimeoutMillisFor(t);
return MSG_SEND_SENT_FLOOD;
@@ -525,7 +526,7 @@ int BaseChatMesh::sendAnonReq(const ContactInfo& recipient, const uint8_t* data,
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
est_timeout = calcFloodTimeoutMillisFor(t);
return MSG_SEND_SENT_FLOOD;
@@ -552,7 +553,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
est_timeout = calcFloodTimeoutMillisFor(t);
return MSG_SEND_SENT_FLOOD;
@@ -579,7 +580,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
est_timeout = calcFloodTimeoutMillisFor(t);
return MSG_SEND_SENT_FLOOD;
@@ -683,7 +684,7 @@ void BaseChatMesh::checkConnections() {
MESH_DEBUG_PRINTLN("checkConnections(): Keep_alive contact not found!");
continue;
}
if (contact->out_path_len < 0) {
if (contact->out_path_len == OUT_PATH_UNKNOWN) {
MESH_DEBUG_PRINTLN("checkConnections(): Keep_alive contact, no out_path!");
continue;
}
@@ -710,7 +711,7 @@ void BaseChatMesh::checkConnections() {
}
void BaseChatMesh::resetPathTo(ContactInfo& recipient) {
recipient.out_path_len = -1;
recipient.out_path_len = OUT_PATH_UNKNOWN;
}
static ContactInfo* table; // pass via global :-(
@@ -875,4 +876,4 @@ void BaseChatMesh::loop() {
releasePacket(_pendingLoopback); // undo the obtainNewPacket()
_pendingLoopback = NULL;
}
}
}

View File

@@ -113,6 +113,7 @@ protected:
virtual bool shouldAutoAddContactType(uint8_t type) const { return true; }
virtual void onContactsFull() {};
virtual bool shouldOverwriteWhenFull() const { return false; }
virtual uint8_t getAutoAddMaxHops() const { return 0; } // 0 = no limit
virtual void onContactOverwrite(const uint8_t* pub_key) {};
virtual void onDiscoveredContact(ContactInfo& contact, bool is_new, uint8_t path_len, const uint8_t* path) = 0;
virtual ContactInfo* processAck(const uint8_t *data) = 0;

View File

@@ -114,7 +114,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
memset(c, 0, sizeof(*c));
c->permissions = init_perms;
c->id = id;
c->out_path_len = -1; // initially out_path is unknown
c->out_path_len = OUT_PATH_UNKNOWN; // initially out_path is unknown
return c;
}
@@ -140,4 +140,4 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8
self_id.calcSharedSecret(c->shared_secret, pubkey);
}
return true;
}
}

View File

@@ -4,6 +4,10 @@
#include <Mesh.h>
#include <helpers/IdentityStore.h>
#ifndef OUT_PATH_UNKNOWN
#define OUT_PATH_UNKNOWN 0xFF
#endif
#define PERM_ACL_ROLE_MASK 3 // lower 2 bits
#define PERM_ACL_GUEST 0
#define PERM_ACL_READ_ONLY 1
@@ -13,7 +17,7 @@
struct ClientInfo {
mesh::Identity id;
uint8_t permissions;
int8_t out_path_len;
uint8_t out_path_len; // OUT_PATH_UNKNOWN = no known path
uint8_t out_path[MAX_PATH_SIZE];
uint8_t shared_secret[PUB_KEY_SIZE];
uint32_t last_timestamp; // by THEIR clock (transient)
@@ -55,4 +59,4 @@ public:
int getNumClients() const { return num_clients; }
ClientInfo* getClientByIdx(int idx) { return &clients[idx]; }
};
};

View File

@@ -3,12 +3,14 @@
#include <Arduino.h>
#include <Mesh.h>
#define OUT_PATH_UNKNOWN 0xFF // no known path — triggers flood routing
struct ContactInfo {
mesh::Identity id;
char name[32];
uint8_t type; // on of ADV_TYPE_*
uint8_t flags;
int8_t out_path_len;
uint8_t out_path_len; // encoded: bits[7:6]=mode, bits[5:0]=hops. OUT_PATH_UNKNOWN=no path
mutable bool shared_secret_valid; // flag to indicate if shared_secret has been calculated
uint8_t out_path[MAX_PATH_SIZE];
uint32_t last_advert_timestamp; // by THEIR clock
@@ -26,4 +28,4 @@ struct ContactInfo {
private:
mutable uint8_t shared_secret[PUB_KEY_SIZE];
};
};

View File

@@ -12,7 +12,31 @@
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeSansBold12pt7b.h>
#include <Fonts/FreeSans18pt7b.h>
#include <CRC32.h>
// Inline CRC32 for frame change detection (replaces bakercp/CRC32
// to avoid naming collision with PNGdec's bundled CRC32.h)
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; }
};
#include "DisplayDriver.h"
@@ -34,7 +58,7 @@ class GxEPDDisplay : public DisplayDriver {
bool _init = false;
bool _isOn = false;
uint16_t _curr_color;
CRC32 display_crc;
FrameCRC32 display_crc;
int last_display_crc_value = 0;
public:
@@ -60,4 +84,24 @@ public:
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) {
display.drawPixel(x, y, color);
}
int16_t rawWidth() { return display.width(); }
int16_t rawHeight() { return display.height(); }
// Draw text at raw (unscaled) physical coordinates using built-in 5x7 font
void drawTextRaw(int16_t x, int16_t y, const char* text, uint16_t color) {
display.setFont(NULL); // Built-in 5x7 font
display.setTextSize(1);
display.setTextColor(color);
display.setCursor(x, y);
display.print(text);
}
// Force endFrame() to push to display even if CRC unchanged
// (needed because drawPixelRaw bypasses CRC tracking)
void invalidateFrameCRC() { last_display_crc_value = 0; }
};

View File

@@ -1,185 +0,0 @@
#pragma once
#include <Arduino.h>
#include "variant.h"
#include "GPSStreamCounter.h"
// GPS Duty Cycle Manager
// Controls the hardware GPS enable pin (PIN_GPS_EN) to save power.
// When enabled, cycles between acquiring a fix and sleeping with power cut.
//
// States:
// OFF User has disabled GPS. Hardware power is cut.
// ACQUIRING GPS module powered on, waiting for a fix or timeout.
// SLEEPING GPS module powered off, timer counting down to next cycle.
#if HAS_GPS
// How long to leave GPS powered on while acquiring a fix (ms)
#ifndef GPS_ACQUIRE_TIMEOUT_MS
#define GPS_ACQUIRE_TIMEOUT_MS 180000 // 3 minutes
#endif
// How long to sleep between acquisition cycles (ms)
#ifndef GPS_SLEEP_DURATION_MS
#define GPS_SLEEP_DURATION_MS 900000 // 15 minutes
#endif
// If we get a fix quickly, power off immediately but still respect
// a minimum on-time so the RTC can sync properly
#ifndef GPS_MIN_ON_TIME_MS
#define GPS_MIN_ON_TIME_MS 5000 // 5 seconds after fix
#endif
enum class GPSDutyState : uint8_t {
OFF = 0, // User-disabled, hardware power off
ACQUIRING, // Hardware on, waiting for fix
SLEEPING // Hardware off, timer running
};
class GPSDutyCycle {
public:
GPSDutyCycle() : _state(GPSDutyState::OFF), _state_entered(0),
_last_fix_time(0), _got_fix(false), _time_synced(false),
_stream(nullptr) {}
// Attach the stream counter so we can reset it on power cycles
void setStreamCounter(GPSStreamCounter* stream) { _stream = stream; }
// Call once in setup() after board.begin() and GPS serial init.
void begin(bool initial_enable) {
if (initial_enable) {
_powerOn();
_setState(GPSDutyState::ACQUIRING);
} else {
_powerOff();
_setState(GPSDutyState::OFF);
}
}
// Call every iteration of loop().
// Returns true if GPS hardware is currently powered on.
bool loop() {
switch (_state) {
case GPSDutyState::OFF:
return false;
case GPSDutyState::ACQUIRING: {
unsigned long elapsed = millis() - _state_entered;
if (_got_fix && elapsed >= GPS_MIN_ON_TIME_MS) {
MESH_DEBUG_PRINTLN("GPS duty: fix acquired, powering off for %u min",
(unsigned)(GPS_SLEEP_DURATION_MS / 60000));
_powerOff();
_setState(GPSDutyState::SLEEPING);
return false;
}
if (elapsed >= GPS_ACQUIRE_TIMEOUT_MS) {
MESH_DEBUG_PRINTLN("GPS duty: acquire timeout (%us), sleeping",
(unsigned)(GPS_ACQUIRE_TIMEOUT_MS / 1000));
_powerOff();
_setState(GPSDutyState::SLEEPING);
return false;
}
return true;
}
case GPSDutyState::SLEEPING: {
if (millis() - _state_entered >= GPS_SLEEP_DURATION_MS) {
MESH_DEBUG_PRINTLN("GPS duty: waking up for next acquisition cycle");
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
return true;
}
return false;
}
}
return false;
}
void notifyFix() {
if (_state == GPSDutyState::ACQUIRING && !_got_fix) {
_got_fix = true;
_last_fix_time = millis();
MESH_DEBUG_PRINTLN("GPS duty: fix notification received");
}
}
void notifyTimeSync() {
_time_synced = true;
}
void enable() {
if (_state == GPSDutyState::OFF) {
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
MESH_DEBUG_PRINTLN("GPS duty: enabled, starting acquisition");
}
}
void disable() {
_powerOff();
_setState(GPSDutyState::OFF);
_got_fix = false;
MESH_DEBUG_PRINTLN("GPS duty: disabled, power off");
}
void forceWake() {
if (_state == GPSDutyState::SLEEPING) {
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
MESH_DEBUG_PRINTLN("GPS duty: forced wake for user request");
}
}
GPSDutyState getState() const { return _state; }
bool isHardwareOn() const { return _state == GPSDutyState::ACQUIRING; }
bool hadFix() const { return _got_fix; }
bool hasTimeSynced() const { return _time_synced; }
uint32_t sleepRemainingSecs() const {
if (_state != GPSDutyState::SLEEPING) return 0;
unsigned long elapsed = millis() - _state_entered;
if (elapsed >= GPS_SLEEP_DURATION_MS) return 0;
return (GPS_SLEEP_DURATION_MS - elapsed) / 1000;
}
uint32_t acquireElapsedSecs() const {
if (_state != GPSDutyState::ACQUIRING) return 0;
return (millis() - _state_entered) / 1000;
}
private:
void _powerOn() {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
delay(10);
#endif
if (_stream) _stream->resetCounters();
}
void _powerOff() {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
}
void _setState(GPSDutyState s) {
_state = s;
_state_entered = millis();
}
GPSDutyState _state;
unsigned long _state_entered;
unsigned long _last_fix_time;
bool _got_fix;
bool _time_synced;
GPSStreamCounter* _stream;
};
#endif // HAS_GPS

View File

@@ -78,6 +78,25 @@ void TDeckBoard::begin() {
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - Battery voltage: %d mV", voltage);
configureFuelGauge();
#endif
// --- Early low-voltage protection ---
// If we boot below the shutdown threshold, go straight to deep sleep
// WITHOUT touching the filesystem. This breaks the brown-out reboot
// loop that corrupts contacts when battery is deeply depleted (~2.5V).
#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);
// Don't mount SD, don't load contacts, don't pass Go.
// Only wake on user button press (presumably after plugging in charger).
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(); // CPU halts here
}
}
#endif
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - complete");
}
@@ -161,7 +180,7 @@ static bool bq27220_writeControl(uint16_t subcmd) {
#endif
// ---- BQ27220 Design Capacity configuration ----
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 1400 mAh
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 2000 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).
@@ -178,29 +197,23 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
if (currentDC == designCapacity_mAh) {
// Design Capacity correct, but check if Full Charge Capacity is sane.
// After a Design Capacity change, FCC may still hold the old factory
// value (e.g. 3000 mAh) until a RESET forces reinitialization.
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.
// The gauge derives FCC from Design Energy (not just Design Capacity).
// Design Energy = capacity × nominal voltage (3.7V for LiPo).
// If Design Energy still reflects 3000 mAh, FCC stays at 3000.
// Fix: enter CFG_UPDATE and write correct Design Energy.
Serial.printf("BQ27220: FCC %d >> DC %d, updating Design Energy\n",
fcc, designCapacity_mAh);
// 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: Target Design Energy = %d mWh\n", designEnergy);
Serial.printf("BQ27220: FCC %d >> DC %d, checking Design Energy (target %d mWh)\n",
fcc, designCapacity_mAh, designEnergy);
// Unseal
// 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
// Read current Design Energy from data memory to check if it needs writing
// Enter CFG_UPDATE to access data memory
bq27220_writeControl(0x0090);
bool ready = false;
for (int i = 0; i < 50; i++) {
@@ -209,52 +222,135 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
if (opSt & 0x0400) { ready = true; break; }
}
if (ready) {
// Design Energy is at data memory address 0x92A1 (2 bytes after DC at 0x929F)
// Read old values for checksum calculation
// 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);
uint8_t oldChk = bq27220_read8(0x60);
uint8_t dLen = bq27220_read8(0x61);
uint16_t currentDE = (oldMSB << 8) | oldLSB;
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);
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=0x%02X%02X new=0x%02X%02X chk=0x%02X\n",
oldMSB, oldLSB, newMSB, newLSB, newChk);
Serial.printf("BQ27220: DE old=%d new=%d mWh, writing\n", currentDE, designEnergy);
// Write new Design Energy
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
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);
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 CFG_UPDATE with reinit
bq27220_writeControl(0x0091);
delay(200);
Serial.println("BQ27220: Design Energy updated, exited CFG_UPDATE");
// 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=2000, DE=7400, Update Status=0x00, but FCC is stuck at 3000.
// Diagnostic scan found the culprits:
// 0x9106 = Qmax Cell 0 (IT Cfg class) — the raw capacity the
// gauge uses for FCC calculation. Factory default 3000.
// 0x929D = Stored FCC reference (Gas Gauging class, 2 bytes
// before Design Capacity). Also stuck at 3000.
//
// Fix: overwrite both with designCapacity_mAh (2000).
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 fix");
bq27220_writeControl(0x0092); // Exit cleanly
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE check");
}
// Seal
bq27220_writeControl(0x0030);
// 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.
// This is the actual fix when DC and DE are correct but FCC is stuck.
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 Design Energy update: %d mAh\n", fcc);
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;
}
@@ -415,6 +511,17 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
bq27220_writeControl(0x0030);
delay(5);
// Step 7: Force full gauge RESET to reinitialize FCC from new DC/DE.
// Without this, the Impedance Track algorithm retains the old FCC
// (often 3000 mAh from factory) until a full charge/discharge cycle.
bq27220_writeControl(0x0041); // RESET
delay(1000); // Gauge needs time to fully reinitialize
// Re-verify after hard reset
verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
newFCC = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: Post-RESET DC=%d FCC=%d mAh\n", verifyDC, newFCC);
return verifyDC == designCapacity_mAh;
#else
return false;
@@ -455,7 +562,12 @@ uint16_t TDeckBoard::getRemainingCapacity() {
uint16_t TDeckBoard::getFullChargeCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_FULL_CAP);
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
// Clamp to design capacity — the gauge may report a stale factory FCC
// (e.g. 3000 mAh) until it completes a full learning cycle. Never let
// the reported FCC exceed what the actual cell can hold.
if (fcc > BQ27220_DESIGN_CAPACITY_MAH) fcc = BQ27220_DESIGN_CAPACITY_MAH;
return fcc;
#else
return 0;
#endif

View File

@@ -62,6 +62,8 @@ build_flags =
-D EINK_MOSI=33
-D EINK_BL=45
-D EINK_NOT_HIBERNATE=1
-D HAS_BQ27220=1
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
-D EINK_LIMIT_FASTREFRESH=10
-D EINK_LIMIT_GHOSTING_PX=2000
-D DISPLAY_ROTATION=0
@@ -89,10 +91,10 @@ lib_deps =
${sensor_base.lib_deps}
zinggjm/GxEPD2@^1.5.9
adafruit/Adafruit GFX Library@^1.11.0
bakercp/CRC32@^2.0.0
bitbank2/PNGdec@^1.0.1
; ---------------------------------------------------------------------------
; Meck unified builds — one codebase, three variants via build flags
; Meck unified builds — one codebase, six variants via build flags
; ---------------------------------------------------------------------------
; Audio + BLE companion (audio-player hardware with BLE phone bridging)
@@ -120,6 +122,39 @@ lib_deps =
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
; Audio + WiFi companion (audio-player hardware with WiFi app bridging)
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
; Connect via MeshCore web app, meshcore.js, or Python CLI over local network.
; No BLE protocol ceiling on contacts; bumped to 1500 (PSRAM-backed).
; WiFi always on from boot — web reader works without teardown, extra free heap.
; WiFi credentials loaded from SD card at runtime (/web/wifi.cfg).
; Configure via Settings > WiFi Setup, or through the web reader.
[env:meck_audio_wifi]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_GROUP_CHANNELS=20
-D MECK_WIFI_COMPANION=1
-D TCP_PORT=5000
-D WIFI_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.0.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.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
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
@@ -157,7 +192,37 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v0.9.5.4G"'
-D FIRMWARE_VERSION='"Meck v1.0.4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.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
; 4G + WiFi companion (4G modem hardware with WiFi app bridging, no audio)
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
; Connect via MeshCore web app, meshcore.js, or Python CLI over local network.
; WiFi credentials loaded from SD card at runtime (/web/wifi.cfg).
; Configure via Settings > WiFi Setup, or through the web reader.
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
[env:meck_4g_wifi]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_GROUP_CHANNELS=20
-D MECK_WIFI_COMPANION=1
-D TCP_PORT=5000
-D WIFI_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.0.4G.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -183,7 +248,7 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v0.9.5.4G.SA"'
-D FIRMWARE_VERSION='"Meck v1.0.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>

View File

@@ -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) {