mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12477af8c7 | ||
|
|
49d399c4d6 | ||
|
|
33f2e0fc6e | ||
|
|
7685de4be6 | ||
|
|
3f4da4bc2b | ||
|
|
fe949235d9 | ||
|
|
d92fdc9ffe | ||
|
|
3a6673edea | ||
|
|
e2a04892f4 | ||
|
|
31db349305 | ||
|
|
b444a664c5 | ||
|
|
4e4c6cba80 | ||
|
|
a178d43046 | ||
|
|
36c5fafec6 | ||
|
|
5260f0ccea | ||
|
|
edf3fb7fff | ||
|
|
129a75ed4e | ||
|
|
1ecda1a8f5 | ||
|
|
4bb721e060 | ||
|
|
4646fd6bd9 | ||
|
|
d1104d0b9c | ||
|
|
513715e472 | ||
|
|
1dfab7d9a6 | ||
|
|
4724cded26 | ||
|
|
74d5bfef70 | ||
|
|
e9540bcf23 |
@@ -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
315
Serial Settings Guide.md
Normal 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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
205
examples/companion_radio/ui-new/Discoveryscreen.h
Normal file
205
examples/companion_radio/ui-new/Discoveryscreen.h
Normal 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
|
||||
}
|
||||
};
|
||||
886
examples/companion_radio/ui-new/Mapscreen.h
Normal file
886
examples/companion_radio/ui-new/Mapscreen.h
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
// ================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
34
examples/companion_radio/ui-new/Radiopresets.h
Normal file
34
examples/companion_radio/ui-new/Radiopresets.h
Normal 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]))
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user