26 Commits

Author SHA1 Message Date
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
22 changed files with 4206 additions and 689 deletions

View File

@@ -1,6 +1,8 @@
## 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.
<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)
- [Navigation (Home Screen)](#navigation-home-screen)
@@ -347,4 +349,4 @@ However, this firmware links against libraries with different license terms. Bec
| [base64](https://github.com/Densaugeo/base64_arduino) | MIT | densaugeo |
| [Arduino Crypto](https://github.com/rweather/arduinolibs) | MIT | Rhys Weatherley |
Full license texts for each dependency are available in their respective repositories linked above.
Full license texts for each dependency are available in their respective repositories linked above.

315
Serial Settings Guide.md Normal file
View File

@@ -0,0 +1,315 @@
# 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 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
```
### 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

@@ -230,6 +230,18 @@ 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;
file.close();
}
}
@@ -265,14 +277,64 @@ 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.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 +364,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 +468,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");
}
}

View File

@@ -2,6 +2,11 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include "RadioPresets.h" // Shared radio presets (serial CLI + settings screen)
#ifdef HAS_4G_MODEM
#include "ModemManager.h" // Serial CLI modem commands
#endif
#define CMD_APP_START 1
#define CMD_SEND_TXT_MSG 2
@@ -53,6 +58,10 @@
#define CMD_SET_FLOOD_SCOPE 54 // v8+
#define CMD_SEND_CONTROL_DATA 55 // v8+
#define CMD_GET_STATS 56 // v8+, second byte is stats type
// Control data sub-types for active node discovery
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80
#define CTL_TYPE_NODE_DISCOVER_RESP 0x90
#define CMD_SEND_ANON_REQ 57
#define CMD_SET_AUTOADD_CONFIG 58
#define CMD_GET_AUTOADD_CONFIG 59
@@ -357,6 +366,32 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path
memcpy(p->path, path, p->path_len);
}
// Buffer for on-device discovery UI
if (_discoveryActive && _discoveredCount < MAX_DISCOVERED_NODES) {
bool dup = false;
for (int i = 0; i < _discoveredCount; i++) {
if (contact.id.matches(_discovered[i].contact.id)) {
// Update existing entry with fresher data
_discovered[i].contact = contact;
_discovered[i].path_len = path_len;
_discovered[i].already_in_contacts = !is_new;
// Preserve snr if already set by active discovery response
dup = true;
Serial.printf("[Discovery] Updated: %s (hops=%d)\n", contact.name, path_len);
break;
}
}
if (!dup) {
_discovered[_discoveredCount].contact = contact;
_discovered[_discoveredCount].path_len = path_len;
_discovered[_discoveredCount].snr = 0; // no SNR from passive advert
_discovered[_discoveredCount].already_in_contacts = !is_new;
_discoveredCount++;
Serial.printf("[Discovery] Found: %s (hops=%d, is_new=%d, total=%d)\n",
contact.name, path_len, is_new, _discoveredCount);
}
}
if (!is_new) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // only schedule lazy write for contacts that are in contacts[]
}
@@ -902,6 +937,62 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
}
void MyMesh::onControlDataRecv(mesh::Packet *packet) {
// --- Active discovery response interception ---
if (_discoveryActive && packet->payload_len >= 6) {
uint8_t resp_type = packet->payload[0] & 0xF0;
if (resp_type == CTL_TYPE_NODE_DISCOVER_RESP) {
uint8_t node_type = packet->payload[0] & 0x0F;
int8_t snr_scaled = (int8_t)packet->payload[1]; // SNR × 4 (how well repeater heard us)
uint32_t tag;
memcpy(&tag, &packet->payload[2], 4);
// Validate: tag must match ours AND payload must include full 32-byte pubkey
if (tag == _discoveryTag && packet->payload_len >= 6 + PUB_KEY_SIZE) {
const uint8_t* pubkey = &packet->payload[6];
// Dedup check against existing buffer entries (pre-seeded or earlier responses)
for (int i = 0; i < _discoveredCount; i++) {
if (_discovered[i].contact.id.matches(pubkey)) {
// Already in buffer — update SNR (active discovery data is fresher)
_discovered[i].snr = snr_scaled;
Serial.printf("[Discovery] Updated SNR for %s: %d\n",
_discovered[i].contact.name, snr_scaled);
return;
}
}
// New node — add if room
if (_discoveredCount < MAX_DISCOVERED_NODES) {
DiscoveredNode& node = _discovered[_discoveredCount];
memset(&node.contact, 0, sizeof(ContactInfo));
memcpy(node.contact.id.pub_key, pubkey, PUB_KEY_SIZE);
node.contact.type = node_type;
node.snr = snr_scaled;
node.path_len = packet->path_len;
// Try to resolve name from contacts table
ContactInfo* existing = lookupContactByPubKey(pubkey, PUB_KEY_SIZE);
if (existing) {
strncpy(node.contact.name, existing->name, sizeof(node.contact.name) - 1);
node.already_in_contacts = true;
} else {
// Show hex prefix as placeholder name
snprintf(node.contact.name, sizeof(node.contact.name),
"%02X%02X%02X%02X",
pubkey[0], pubkey[1], pubkey[2], pubkey[3]);
node.already_in_contacts = false;
}
_discoveredCount++;
Serial.printf("[Discovery] Active response: %s type=%d snr=%d hops=%d (total=%d)\n",
node.contact.name, node_type, snr_scaled, packet->path_len, _discoveredCount);
}
}
return; // consumed — don't forward discovery responses to BLE
}
}
// --- Original BLE forwarding for non-discovery control data ---
if (packet->payload_len + 4 > sizeof(out_frame)) {
MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len);
return;
@@ -998,6 +1089,10 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
memset(_sent_track, 0, sizeof(_sent_track));
_sent_track_idx = 0;
_admin_contact_idx = -1;
_discoveredCount = 0;
_discoveryActive = false;
_discoveryTimeout = 0;
_discoveryTag = 0;
// defaults
memset(&_prefs, 0, sizeof(_prefs));
@@ -1401,6 +1496,7 @@ void MyMesh::handleCmdFrame(size_t len) {
}
} else if (cmd_frame[0] == CMD_IMPORT_CONTACT && len > 2 + 32 + 64) {
if (importContact(&cmd_frame[1], len - 1)) {
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
@@ -2003,15 +2099,447 @@ void MyMesh::checkCLIRescueCmd() {
if (len > 0 && cli_command[len - 1] == '\r') { // received complete line
cli_command[len - 1] = 0; // replace newline with C string null terminator
if (memcmp(cli_command, "set ", 4) == 0) {
// =====================================================================
// GET commands — read settings
// =====================================================================
if (memcmp(cli_command, "get ", 4) == 0) {
const char* key = &cli_command[4];
if (strcmp(key, "name") == 0) {
Serial.printf(" > %s\n", _prefs.node_name);
} else if (strcmp(key, "freq") == 0) {
Serial.printf(" > %.3f\n", _prefs.freq);
} else if (strcmp(key, "bw") == 0) {
Serial.printf(" > %.1f\n", _prefs.bw);
} else if (strcmp(key, "sf") == 0) {
Serial.printf(" > %d\n", _prefs.sf);
} else if (strcmp(key, "cr") == 0) {
Serial.printf(" > %d\n", _prefs.cr);
} else if (strcmp(key, "tx") == 0) {
Serial.printf(" > %d\n", _prefs.tx_power_dbm);
} else if (strcmp(key, "utc") == 0) {
Serial.printf(" > %d\n", _prefs.utc_offset_hours);
} else if (strcmp(key, "notify") == 0) {
Serial.printf(" > %s\n", _prefs.kb_flash_notify ? "on" : "off");
} else if (strcmp(key, "gps") == 0) {
Serial.printf(" > %s (interval: %ds)\n",
_prefs.gps_enabled ? "on" : "off", _prefs.gps_interval);
} else if (strcmp(key, "pin") == 0) {
Serial.printf(" > %06d\n", _prefs.ble_pin);
} else if (strcmp(key, "radio") == 0) {
Serial.printf(" > freq=%.3f bw=%.1f sf=%d cr=%d tx=%d\n",
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
} else if (strcmp(key, "pubkey") == 0) {
char hex[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(hex, self_id.pub_key, PUB_KEY_SIZE);
Serial.printf(" > %s\n", hex);
} else if (strcmp(key, "firmware") == 0) {
Serial.printf(" > %s\n", FIRMWARE_VERSION);
} else if (strcmp(key, "channels") == 0) {
bool found = false;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (getChannel(i, ch) && ch.name[0] != '\0') {
Serial.printf(" [%d] %s\n", i, ch.name);
found = true;
} else {
break;
}
}
if (!found) Serial.println(" (no channels)");
} else if (strcmp(key, "presets") == 0) {
Serial.println(" Available radio presets:");
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
Serial.printf(" %2d %-30s %.3f MHz BW%.1f SF%d CR%d TX%d\n",
i, RADIO_PRESETS[i].name, RADIO_PRESETS[i].freq,
RADIO_PRESETS[i].bw, RADIO_PRESETS[i].sf,
RADIO_PRESETS[i].cr, RADIO_PRESETS[i].tx_power);
}
#ifdef HAS_4G_MODEM
} else if (strcmp(key, "modem") == 0) {
Serial.printf(" > %s\n", ModemManager::loadEnabledConfig() ? "on" : "off");
} else if (strcmp(key, "apn") == 0) {
Serial.printf(" > %s\n", modemManager.getAPN());
} else if (strcmp(key, "imei") == 0) {
Serial.printf(" > %s\n", modemManager.getIMEI());
#endif
} else if (strcmp(key, "all") == 0) {
Serial.println(" === Meck Device Settings ===");
Serial.printf(" name: %s\n", _prefs.node_name);
Serial.printf(" freq: %.3f\n", _prefs.freq);
Serial.printf(" bw: %.1f\n", _prefs.bw);
Serial.printf(" sf: %d\n", _prefs.sf);
Serial.printf(" cr: %d\n", _prefs.cr);
Serial.printf(" tx: %d\n", _prefs.tx_power_dbm);
Serial.printf(" utc: %d\n", _prefs.utc_offset_hours);
Serial.printf(" notify: %s\n", _prefs.kb_flash_notify ? "on" : "off");
Serial.printf(" gps: %s (interval: %ds)\n",
_prefs.gps_enabled ? "on" : "off", _prefs.gps_interval);
Serial.printf(" pin: %06d\n", _prefs.ble_pin);
#ifdef HAS_4G_MODEM
Serial.printf(" modem: %s\n", ModemManager::loadEnabledConfig() ? "on" : "off");
Serial.printf(" apn: %s\n", modemManager.getAPN());
Serial.printf(" imei: %s\n", modemManager.getIMEI());
#endif
// Detect current preset
bool presetFound = false;
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
if (_prefs.freq == RADIO_PRESETS[i].freq && _prefs.bw == RADIO_PRESETS[i].bw &&
_prefs.sf == RADIO_PRESETS[i].sf && _prefs.cr == RADIO_PRESETS[i].cr) {
Serial.printf(" preset: %s\n", RADIO_PRESETS[i].name);
presetFound = true;
break;
}
}
if (!presetFound) Serial.println(" preset: (custom)");
Serial.printf(" firmware: %s\n", FIRMWARE_VERSION);
char hex[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(hex, self_id.pub_key, PUB_KEY_SIZE);
Serial.printf(" pubkey: %s\n", hex);
// List channels
Serial.println(" channels:");
bool chFound = false;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (getChannel(i, ch) && ch.name[0] != '\0') {
Serial.printf(" [%d] %s\n", i, ch.name);
chFound = true;
} else {
break;
}
}
if (!chFound) Serial.println(" (none)");
} else {
Serial.printf(" Error: unknown key '%s' (try 'help')\n", key);
}
// =====================================================================
// SET commands — write settings
// =====================================================================
} else if (memcmp(cli_command, "set ", 4) == 0) {
const char* config = &cli_command[4];
if (memcmp(config, "pin ", 4) == 0) {
if (memcmp(config, "name ", 5) == 0) {
const char* val = &config[5];
// Validate name (same rules as CommonCLI)
bool valid = true;
const char* p = val;
while (*p) {
if (*p == '[' || *p == ']' || *p == '/' || *p == '\\' ||
*p == ':' || *p == ',' || *p == '?' || *p == '*') {
valid = false;
break;
}
p++;
}
if (valid && strlen(val) > 0) {
strncpy(_prefs.node_name, val, sizeof(_prefs.node_name) - 1);
_prefs.node_name[sizeof(_prefs.node_name) - 1] = '\0';
savePrefs();
Serial.printf(" > name = %s\n", _prefs.node_name);
} else {
Serial.println(" Error: invalid name (no []/:,?* chars)");
}
} else if (memcmp(config, "freq ", 5) == 0) {
float f = atof(&config[5]);
if (f >= 400.0f && f <= 928.0f) {
_prefs.freq = f;
savePrefs();
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
Serial.printf(" > freq = %.3f (applied)\n", _prefs.freq);
} else {
Serial.println(" Error: freq out of range (400-928)");
}
} else if (memcmp(config, "bw ", 3) == 0) {
float bw = atof(&config[3]);
if (bw >= 7.8f && bw <= 500.0f) {
_prefs.bw = bw;
savePrefs();
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
Serial.printf(" > bw = %.1f (applied)\n", _prefs.bw);
} else {
Serial.println(" Error: bw out of range (7.8-500)");
}
} else if (memcmp(config, "sf ", 3) == 0) {
int sf = atoi(&config[3]);
if (sf >= 5 && sf <= 12) {
_prefs.sf = (uint8_t)sf;
savePrefs();
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
Serial.printf(" > sf = %d (applied)\n", _prefs.sf);
} else {
Serial.println(" Error: sf out of range (5-12)");
}
} else if (memcmp(config, "cr ", 3) == 0) {
int cr = atoi(&config[3]);
if (cr >= 5 && cr <= 8) {
_prefs.cr = (uint8_t)cr;
savePrefs();
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
Serial.printf(" > cr = %d (applied)\n", _prefs.cr);
} else {
Serial.println(" Error: cr out of range (5-8)");
}
} else if (memcmp(config, "tx ", 3) == 0) {
int tx = atoi(&config[3]);
if (tx >= 1 && tx <= MAX_LORA_TX_POWER) {
_prefs.tx_power_dbm = (uint8_t)tx;
savePrefs();
radio_set_tx_power(_prefs.tx_power_dbm);
Serial.printf(" > tx = %d (applied)\n", _prefs.tx_power_dbm);
} else {
Serial.printf(" Error: tx out of range (1-%d)\n", MAX_LORA_TX_POWER);
}
} else if (memcmp(config, "utc ", 4) == 0) {
int utc = atoi(&config[4]);
if (utc >= -12 && utc <= 14) {
_prefs.utc_offset_hours = (int8_t)utc;
savePrefs();
Serial.printf(" > utc = %d\n", _prefs.utc_offset_hours);
} else {
Serial.println(" Error: utc out of range (-12 to 14)");
}
} else if (memcmp(config, "notify ", 7) == 0) {
if (strcmp(&config[7], "on") == 0) {
_prefs.kb_flash_notify = 1;
} else if (strcmp(&config[7], "off") == 0) {
_prefs.kb_flash_notify = 0;
} else {
Serial.println(" Error: use 'on' or 'off'");
cli_command[0] = 0;
return;
}
savePrefs();
Serial.printf(" > notify = %s\n", _prefs.kb_flash_notify ? "on" : "off");
} else if (memcmp(config, "pin ", 4) == 0) {
_prefs.ble_pin = atoi(&config[4]);
savePrefs();
Serial.printf(" > pin is now %06d\n", _prefs.ble_pin);
} else if (memcmp(config, "radio ", 6) == 0) {
// Composite: "set radio <freq> <bw> <sf> <cr>"
char tmp[64];
strncpy(tmp, &config[6], sizeof(tmp) - 1);
tmp[sizeof(tmp) - 1] = '\0';
const char* parts[4];
int num = mesh::Utils::parseTextParts(tmp, parts, 4);
if (num == 4) {
float freq = strtof(parts[0], nullptr);
float bw = strtof(parts[1], nullptr);
int sf = atoi(parts[2]);
int cr = atoi(parts[3]);
if (freq >= 400.0f && freq <= 928.0f && bw >= 7.8f && bw <= 500.0f
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8) {
_prefs.freq = freq;
_prefs.bw = bw;
_prefs.sf = (uint8_t)sf;
_prefs.cr = (uint8_t)cr;
savePrefs();
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_set_tx_power(_prefs.tx_power_dbm);
Serial.printf(" > radio = %.3f/%.1f/SF%d/CR%d TX:%d (applied)\n",
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
} else {
Serial.println(" Error: invalid radio params");
}
} else {
Serial.println(" Usage: set radio <freq> <bw> <sf> <cr>");
}
} else if (memcmp(config, "preset ", 7) == 0) {
const char* name = &config[7];
// Try exact match first (case-insensitive)
bool found = false;
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
if (strcasecmp(RADIO_PRESETS[i].name, name) == 0) {
_prefs.freq = RADIO_PRESETS[i].freq;
_prefs.bw = RADIO_PRESETS[i].bw;
_prefs.sf = RADIO_PRESETS[i].sf;
_prefs.cr = RADIO_PRESETS[i].cr;
_prefs.tx_power_dbm = RADIO_PRESETS[i].tx_power;
savePrefs();
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_set_tx_power(_prefs.tx_power_dbm);
Serial.printf(" > Applied preset '%s' (%.3f/%.1f/SF%d/CR%d TX:%d)\n",
RADIO_PRESETS[i].name, _prefs.freq, _prefs.bw,
_prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
found = true;
break;
}
}
// Try by index number if name didn't match
if (!found) {
char* endp;
long idx = strtol(name, &endp, 10);
if (endp != name && *endp == '\0' && idx >= 0 && idx < (int)NUM_RADIO_PRESETS) {
_prefs.freq = RADIO_PRESETS[idx].freq;
_prefs.bw = RADIO_PRESETS[idx].bw;
_prefs.sf = RADIO_PRESETS[idx].sf;
_prefs.cr = RADIO_PRESETS[idx].cr;
_prefs.tx_power_dbm = RADIO_PRESETS[idx].tx_power;
savePrefs();
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_set_tx_power(_prefs.tx_power_dbm);
Serial.printf(" > Applied preset '%s' (%.3f/%.1f/SF%d/CR%d TX:%d)\n",
RADIO_PRESETS[idx].name, _prefs.freq, _prefs.bw,
_prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
found = true;
}
}
if (!found) {
Serial.printf(" Error: unknown preset '%s' (try 'get presets')\n", name);
}
} else if (memcmp(config, "channel.add ", 12) == 0) {
const char* name = &config[12];
if (strlen(name) == 0) {
Serial.println(" Error: channel name required");
cli_command[0] = 0;
return;
}
// Build channel name with # prefix if not present
char chanName[32];
if (name[0] == '#') {
strncpy(chanName, name, sizeof(chanName));
} else {
chanName[0] = '#';
strncpy(&chanName[1], name, sizeof(chanName) - 1);
}
chanName[31] = '\0';
// Generate 128-bit PSK from SHA-256 of channel name
ChannelDetails newCh;
memset(&newCh, 0, sizeof(newCh));
strncpy(newCh.name, chanName, sizeof(newCh.name));
newCh.name[31] = '\0';
uint8_t hash[32];
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
memcpy(newCh.channel.secret, hash, 16);
// Find next empty slot
bool added = false;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails existing;
if (!getChannel(i, existing) || existing.name[0] == '\0') {
if (setChannel(i, newCh)) {
saveChannels();
Serial.printf(" > Added channel '%s' at slot %d\n", chanName, i);
added = true;
}
break;
}
}
if (!added) Serial.println(" Error: no empty channel slots");
} else if (memcmp(config, "channel.del ", 12) == 0) {
int idx = atoi(&config[12]);
if (idx <= 0) {
Serial.println(" Error: cannot delete channel 0 (public)");
} else if (idx >= MAX_GROUP_CHANNELS) {
Serial.printf(" Error: index out of range (1-%d)\n", MAX_GROUP_CHANNELS - 1);
} else {
// Verify channel exists
ChannelDetails ch;
if (!getChannel(idx, ch) || ch.name[0] == '\0') {
Serial.printf(" Error: no channel at index %d\n", idx);
} else {
// Compact: shift channels down
int total = 0;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails tmp;
if (getChannel(i, tmp) && tmp.name[0] != '\0') {
total = i + 1;
} else {
break;
}
}
for (int i = idx; i < total - 1; i++) {
ChannelDetails next;
if (getChannel(i + 1, next)) {
setChannel(i, next);
}
}
ChannelDetails empty;
memset(&empty, 0, sizeof(empty));
setChannel(total - 1, empty);
saveChannels();
Serial.printf(" > Deleted channel %d ('%s'), compacted %d channels\n",
idx, ch.name, total);
}
}
#ifdef HAS_4G_MODEM
} else if (memcmp(config, "apn ", 4) == 0) {
const char* apn = &config[4];
if (strlen(apn) > 0) {
modemManager.setAPN(apn);
Serial.printf(" > apn = %s\n", apn);
} else {
ModemManager::saveAPNConfig("");
Serial.println(" > apn cleared (will auto-detect on next boot)");
}
} else if (strcmp(config, "modem on") == 0) {
ModemManager::saveEnabledConfig(true);
modemManager.begin();
Serial.println(" > modem enabled");
} else if (strcmp(config, "modem off") == 0) {
ModemManager::saveEnabledConfig(false);
modemManager.shutdown();
Serial.println(" > modem disabled");
#endif
} else {
Serial.printf(" Error: unknown config: %s\n", config);
Serial.printf(" Error: unknown setting '%s' (try 'help')\n", config);
}
// =====================================================================
// HELP command
// =====================================================================
} else if (strcmp(cli_command, "help") == 0) {
Serial.println("=== Meck Serial CLI ===");
Serial.println(" get <key> Read a setting");
Serial.println(" set <key> <value> Write a setting");
Serial.println("");
Serial.println(" Settings keys:");
Serial.println(" name, freq, bw, sf, cr, tx, utc, notify, pin");
Serial.println("");
Serial.println(" Compound commands:");
Serial.println(" get all Dump all settings");
Serial.println(" get radio Show all radio params");
Serial.println(" get channels List channels");
Serial.println(" get presets List radio presets");
Serial.println(" get pubkey Show public key");
Serial.println(" get firmware Show firmware version");
Serial.println(" set radio <f> <bw> <sf> <cr> Set all radio params");
Serial.println(" set preset <name|num> Apply radio preset");
Serial.println(" set channel.add <name> Add hashtag channel");
Serial.println(" set channel.del <idx> Delete channel by index");
#ifdef HAS_4G_MODEM
Serial.println("");
Serial.println(" 4G modem:");
Serial.println(" get/set apn, get imei, set modem on/off");
#endif
Serial.println("");
Serial.println(" System:");
Serial.println(" rebuild Erase & rebuild filesystem");
Serial.println(" erase Format filesystem");
Serial.println(" reboot Restart device");
Serial.println(" ls / cat / rm File operations");
// =====================================================================
// Existing system commands (unchanged)
// =====================================================================
} else if (strcmp(cli_command, "rebuild") == 0) {
bool success = _store->formatFileSystem();
if (success) {
@@ -2151,7 +2679,7 @@ void MyMesh::checkCLIRescueCmd() {
} else if (strcmp(cli_command, "reboot") == 0) {
board.reboot(); // doesn't return
} else {
Serial.println(" Error: unknown command");
Serial.println(" Error: unknown command (try 'help')");
}
cli_command[0] = 0; // reset command buffer
@@ -2188,9 +2716,11 @@ void MyMesh::checkSerialInterface() {
void MyMesh::loop() {
BaseChatMesh::loop();
if (_cli_rescue) {
checkCLIRescueCmd();
} else {
// Always check USB serial for text CLI commands (independent of BLE)
checkCLIRescueCmd();
// Process BLE/WiFi companion app binary frames
if (!_cli_rescue) {
checkSerialInterface();
}
@@ -2200,6 +2730,12 @@ void MyMesh::loop() {
dirty_contacts_expiry = 0;
}
// Discovery scan timeout
if (_discoveryActive && millisHasNowPassed(_discoveryTimeout)) {
_discoveryActive = false;
Serial.printf("[Discovery] Scan complete: %d nodes found\n", _discoveredCount);
}
#ifdef DISPLAY_CLASS
if (_ui) _ui->setHasConnection(_serial->isConnected());
#endif
@@ -2218,4 +2754,71 @@ bool MyMesh::advert() {
} else {
return false;
}
}
void MyMesh::startDiscovery(uint32_t duration_ms) {
_discoveredCount = 0;
_discoveryActive = true;
_discoveryTimeout = futureMillis(duration_ms);
_discoveryTag = getRNG()->nextInt(1, 0xFFFFFFFF);
Serial.printf("[Discovery] Active scan started (%lu ms, tag=%08X)\n",
duration_ms, _discoveryTag);
// --- Pre-seed from advert_paths cache (shows known nodes immediately) ---
for (int i = 0; i < ADVERT_PATH_TABLE_SIZE && _discoveredCount < MAX_DISCOVERED_NODES; i++) {
if (advert_paths[i].recv_timestamp == 0) continue; // empty slot
ContactInfo* c = lookupContactByPubKey(advert_paths[i].pubkey_prefix, sizeof(advert_paths[i].pubkey_prefix));
if (c) {
_discovered[_discoveredCount].contact = *c;
_discovered[_discoveredCount].path_len = advert_paths[i].path_len;
_discovered[_discoveredCount].snr = 0; // no SNR from cache
_discovered[_discoveredCount].already_in_contacts = true;
_discoveredCount++;
}
}
Serial.printf("[Discovery] Pre-seeded %d nodes from cache\n", _discoveredCount);
// --- Send active discovery request (CTL_TYPE_NODE_DISCOVER_REQ) ---
// Repeaters with firmware v1.11+ will respond with their pubkey + SNR
uint8_t ctl_payload[10];
ctl_payload[0] = CTL_TYPE_NODE_DISCOVER_REQ; // 0x80, prefix_only=0 (full 32-byte pubkeys)
ctl_payload[1] = (1 << ADV_TYPE_REPEATER) // repeaters
| (1 << ADV_TYPE_ROOM); // rooms (repeaters with chat)
memcpy(&ctl_payload[2], &_discoveryTag, 4); // random correlation tag
uint32_t since = 0; // accept all firmware versions
memcpy(&ctl_payload[6], &since, 4);
auto pkt = createControlData(ctl_payload, sizeof(ctl_payload));
if (pkt) {
sendZeroHop(pkt);
Serial.println("[Discovery] Sent CTL_TYPE_NODE_DISCOVER_REQ (zero-hop)");
} else {
Serial.println("[Discovery] ERROR: createControlData returned NULL (packet pool full?)");
}
}
void MyMesh::stopDiscovery() {
_discoveryActive = false;
}
bool MyMesh::addDiscoveredToContacts(int idx) {
if (idx < 0 || idx >= _discoveredCount) return false;
if (_discovered[idx].already_in_contacts) return true; // already there
// Retrieve cached raw advert packet and import it
uint8_t buf[256];
int plen = getBlobByKey(_discovered[idx].contact.id.pub_key, PUB_KEY_SIZE, buf);
if (plen > 0) {
bool ok = importContact(buf, (uint8_t)plen);
if (ok) {
_discovered[idx].already_in_contacts = true;
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
MESH_DEBUG_PRINTLN("Discovery: added contact '%s'", _discovered[idx].contact.name);
}
return ok;
}
MESH_DEBUG_PRINTLN("Discovery: no cached advert blob for contact '%s'", _discovered[idx].contact.name);
return false;
}

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 8
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "27 Feb 2026"
#define FIRMWARE_BUILD_DATE "4 March 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.9.5"
#define FIRMWARE_VERSION "Meck v0.9.8"
#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);
@@ -257,6 +275,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,5 @@ 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
};

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());
@@ -781,18 +838,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 +872,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 +1758,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 +1782,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 +1801,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 +1849,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 +1867,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 +1882,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 +1892,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 +1975,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 +2017,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 +2045,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 +2081,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 +2119,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

@@ -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);
} else {
snprintf(rightStr, sizeof(rightStr), "%dh", node.path_len);
}
}
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

@@ -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,21 @@ 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
#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 +91,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 +133,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 +149,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 +234,29 @@ private:
addRow(ROW_TX_POWER);
addRow(ROW_UTC_OFFSET);
addRow(ROW_MSG_NOTIFY);
#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 +297,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 +431,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 +454,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 +681,84 @@ public:
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 +893,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 +983,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 +1042,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 +1219,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;
}
@@ -944,6 +1419,49 @@ public:
Serial.printf("Settings: Msg flash notify = %s\n",
_prefs->kb_flash_notify ? "ON" : "OFF");
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 +1474,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 +1488,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);
}
@@ -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

View File

@@ -1072,12 +1072,9 @@ private:
// Bookmarks & History
std::vector<String> _bookmarks;
std::vector<String> _history;
int _homeSelected; // Selected item in home view (0=IRC, 1=URL, 2=Search, then bookmarks, then history)
int _homeSelected; // Selected item in home view (0=URL bar, then bookmarks, then history)
int _homeScrollY; // Pixel scroll offset for home view
bool _urlEditing; // True when URL bar is active for text entry
bool _searchEditing; // True when search bar is active for text entry
char _searchBuffer[128]; // Search query text
int _searchLen;
// Link selection
int _linkInput; // Accumulated link number digits
@@ -2781,7 +2778,7 @@ private:
const int sectionH = listLineH; // Section header height
int maxChars = _charsPerLine - 2; // Account for "> " prefix
if (maxChars < 10) maxChars = 10;
int totalItems = 3 + (int)_bookmarks.size() + (int)_history.size();
int totalItems = 2 + (int)_bookmarks.size() + (int)_history.size();
// ---- Layout pass: compute virtual Y extent of each item ----
// We track: for each selectable item, its (virtualY, height).
@@ -2807,12 +2804,6 @@ private:
virtualY += urlBarH;
itemIdx++;
// Item 2: Search bar
int searchBarH = listLineH + 2;
if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + searchBarH; }
virtualY += searchBarH;
itemIdx++;
// Bookmarks
if (_bookmarks.size() > 0) {
virtualY += sectionH; // "-- Bookmarks --" header
@@ -2933,39 +2924,6 @@ private:
itemIdx++;
}
// Item 2: Search bar
{
bool selected = (_homeSelected == itemIdx);
if (HOME_VISIBLE(y, searchBarH)) {
if (selected) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
if (_searchEditing) {
char searchDisp[140];
int maxShow = maxChars - 8; // "Search: " prefix + cursor
int start = 0;
if (_searchLen > maxShow) start = _searchLen - maxShow;
snprintf(searchDisp, sizeof(searchDisp), "Search: %s_", _searchBuffer + start);
display.print(searchDisp);
} else if (_searchLen > 0) {
char searchDisp[140];
int maxShow = maxChars - 7;
snprintf(searchDisp, sizeof(searchDisp), "Search: %s",
_searchLen > maxShow ? (_searchBuffer + _searchLen - maxShow) : _searchBuffer);
display.print(searchDisp);
} else {
display.print("Search: [DuckDuckGo Lite]");
}
}
y += searchBarH;
itemIdx++;
}
// Bookmarks section
if (_bookmarks.size() > 0) {
// Section header
@@ -3098,43 +3056,15 @@ private:
display.setColor(DisplayDriver::YELLOW);
if (_urlEditing) {
display.print("Type URL Ent:Go");
} else if (_searchEditing) {
display.print("Type query Ent:Search");
} else {
char footerBuf[48];
bool hasData = (_cookieCount > 0 || !_history.empty());
bool onBookmark = (_homeSelected >= 3 && _homeSelected < 3 + (int)_bookmarks.size());
if (onBookmark && hasData)
snprintf(footerBuf, sizeof(footerBuf), "Ent:Go Del:Del Bkmk X:Clr Ckies");
else if (onBookmark)
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk Ent:Go Del:Del Bkmk");
else if (hasData)
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S Ent:Go X:Clr Ckies");
if (hasData)
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S Ent:Go X:Clr");
else
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S:Nav Ent:Go");
display.print(footerBuf);
}
// Toast notification overlay (for bookmark deleted, etc.)
if (_toastMsg[0] && (millis() - _toastTime < 1500)) {
display.setTextSize(1);
int tw = display.getTextWidth(_toastMsg);
int bw = tw + 16;
int bh = 20;
int bx = (display.width() - bw) / 2;
int by = (display.height() - bh) / 2;
display.setColor(DisplayDriver::DARK);
display.fillRect(bx, by, bw, bh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, 1);
display.drawRect(bx, by + bh - 1, bw, 1);
display.drawRect(bx, by, 1, bh);
display.drawRect(bx + bw - 1, by, 1, bh);
display.setCursor(bx + 8, by + 5);
display.print(_toastMsg);
} else if (_toastMsg[0]) {
_toastMsg[0] = '\0';
}
}
void renderFetching(DisplayDriver& display) {
@@ -3479,7 +3409,7 @@ private:
}
bool handleHomeInput(char c) {
int totalItems = 3 + _bookmarks.size() + _history.size(); // IRC + URL + Search + bookmarks + history
int totalItems = 2 + _bookmarks.size() + _history.size(); // IRC + URL + bookmarks + history
if (_urlEditing) {
// URL text entry mode
@@ -3534,67 +3464,6 @@ private:
return true; // Consume all keys in editing mode
}
// Search text entry mode
if (_searchEditing) {
if (c == '\r' || c == 13) {
if (_searchLen > 0) {
_searchEditing = false;
// Build DuckDuckGo Lite search URL
// URL-encode the query: spaces become +, special chars become %XX
char encoded[256];
int ei = 0;
for (int i = 0; i < _searchLen && ei < (int)sizeof(encoded) - 4; i++) {
char ch = _searchBuffer[i];
if (ch == ' ') {
encoded[ei++] = '+';
} else if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~') {
encoded[ei++] = ch;
} else {
if (ei < (int)sizeof(encoded) - 4) {
snprintf(encoded + ei, 4, "%%%02X", (unsigned char)ch);
ei += 3;
}
}
}
encoded[ei] = '\0';
snprintf(_urlBuffer, WEB_MAX_URL_LEN, "https://html.duckduckgo.com/lite/?q=%s", encoded);
_urlLen = strlen(_urlBuffer);
if (!isNetworkAvailable()) {
_mode = WIFI_SETUP;
if (!loadAndAutoConnect()) {
startWifiScan();
} else {
fetchWithSelfRef(_urlBuffer);
}
} else {
fetchWithSelfRef(_urlBuffer);
}
}
return true;
}
if (c == '\b' || c == 127) {
if (_searchLen > 0) {
_searchBuffer[--_searchLen] = '\0';
}
return true;
}
if (c == 'q' && _searchLen == 0) {
_searchEditing = false;
return true;
}
if (c == 0x1B) { // ESC
_searchEditing = false;
return true;
}
if (c >= 32 && c < 127 && _searchLen < (int)sizeof(_searchBuffer) - 1) {
_searchBuffer[_searchLen++] = c;
_searchBuffer[_searchLen] = '\0';
return true;
}
return true; // Consume all keys in search editing mode
}
// Normal navigation
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_homeSelected > 0) _homeSelected--;
@@ -3625,14 +3494,9 @@ private:
_urlEditing = true;
return true;
}
if (_homeSelected == 2) {
// Activate search editing
_searchEditing = true;
return true;
}
// Bookmark or history item selected (offset by 3 for IRC + URL + Search)
// Bookmark or history item selected (offset by 2 for IRC + URL)
const char* selectedUrl = nullptr;
int bmIdx = _homeSelected - 3;
int bmIdx = _homeSelected - 2;
if (bmIdx < (int)_bookmarks.size()) {
selectedUrl = _bookmarks[bmIdx].c_str();
} else {
@@ -3658,25 +3522,6 @@ private:
return true;
}
// Delete/Backspace - remove selected bookmark
if (c == '\b' || c == 127) {
int bmIdx = _homeSelected - 3;
if (bmIdx >= 0 && bmIdx < (int)_bookmarks.size()) {
_bookmarks.erase(_bookmarks.begin() + bmIdx);
saveBookmarks();
// Adjust selection if we deleted the last item
int newTotal = 3 + _bookmarks.size() + _history.size();
if (_homeSelected >= newTotal && _homeSelected > 0) {
_homeSelected--;
}
strncpy(_toastMsg, "Bookmark deleted", sizeof(_toastMsg));
_toastTime = millis();
Serial.printf("WebReader: Deleted bookmark %d\n", bmIdx);
return true;
}
return false;
}
// X - clear all cookies
if (c == 'x' || c == 'X') {
bool hadData = (_cookieCount > 0 || !_history.empty());
@@ -4964,7 +4809,6 @@ public:
_textBuffer(nullptr), _textLen(0), _links(nullptr), _linkCount(0),
_currentPage(0), _totalPages(0),
_homeSelected(0), _homeScrollY(0), _urlEditing(false),
_searchEditing(false), _searchLen(0),
_linkInput(0), _linkInputActive(false),
_formCount(0), _forms(nullptr), _activeForm(-1), _activeField(0),
_formFieldEditing(false), _formEditLen(0), _formLastCharAt(0),
@@ -4981,7 +4825,6 @@ public:
_ircLastDataTime(0), _ircReconnectAt(0),
_ircDirty(false), _ircLastRender(0) {
_urlBuffer[0] = '\0';
_searchBuffer[0] = '\0';
_wifiPass[0] = '\0';
_pageTitle[0] = '\0';
_currentUrl[0] = '\0';
@@ -5110,17 +4953,22 @@ 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;
_homeSelected = 0;
_homeScrollY = 0;
_urlEditing = false;
_searchEditing = false;
Serial.printf("WebReader: exitReader - heap after: %d, largest: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
@@ -5131,7 +4979,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 +4986,9 @@ public:
bool isFormFilling() const {
return _mode == FORM_FILL && _formFieldEditing;
}
bool isSearchEditing() const {
return false; // TODO: page text search not yet implemented
}
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

@@ -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 v0.9.8.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 v0.9.8.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 v0.9.8.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 v0.9.8.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>