Compare commits
38 Commits
maps-1
...
crowpanel-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb778780f3 | ||
|
|
c0dd59834c | ||
|
|
90a4f5f881 | ||
|
|
b27acb3252 | ||
|
|
580484e0ad | ||
|
|
9d7fbc3134 | ||
|
|
b859f8f168 | ||
|
|
190b40c2ce | ||
|
|
859919348d | ||
|
|
e91ad4bac4 | ||
|
|
db58f8cf87 | ||
|
|
a74b1c3f7a | ||
|
|
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)
|
||||
@@ -11,6 +13,7 @@ This fork was created specifically to focus on enabling BLE companion firmware f
|
||||
- [Sending a Direct Message](#sending-a-direct-message)
|
||||
- [Repeater Admin Screen](#repeater-admin-screen)
|
||||
- [Settings Screen](#settings-screen)
|
||||
- [Serial Settings (USB)](Serial_Settings_Guide.md)
|
||||
- [Compose Mode](#compose-mode)
|
||||
- [Symbol Entry (Sym Key)](#symbol-entry-sym-key)
|
||||
- [Emoji Picker](#emoji-picker)
|
||||
@@ -49,6 +52,8 @@ The T-Deck Pro BLE companion firmware includes full keyboard support for standal
|
||||
| B | Open web browser (BLE and 4G variants only) |
|
||||
| T | Open SMS & Phone app (4G variant only) |
|
||||
| P | Open audiobook player (audio variant only) |
|
||||
| F | Open node discovery (search for nearby repeaters/nodes) |
|
||||
| G | Open map screen (shows contacts with GPS positions) |
|
||||
| Q | Back to home screen |
|
||||
|
||||
### Bluetooth (BLE)
|
||||
@@ -163,6 +168,8 @@ When adding a hashtag channel, type the channel name and press Enter. The channe
|
||||
|
||||
If you've changed radio parameters, pressing Q will prompt you to apply changes before exiting.
|
||||
|
||||
> **Tip:** All device settings (plus mesh tuning parameters not available on-screen) can also be configured via USB serial. See the [Serial Settings Guide](Serial_Settings_Guide.md) for complete documentation.
|
||||
|
||||
### Compose Mode
|
||||
|
||||
| Key | Action |
|
||||
|
||||
393
Serial Settings Guide.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Meck Serial Settings Guide
|
||||
|
||||
Configure your T-Deck Pro's Meck firmware over USB serial — no companion app needed. Plug in a USB-C cable, open a serial terminal, and you have full access to every setting on the device.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### What You Need
|
||||
|
||||
- T-Deck Pro running Meck firmware
|
||||
- USB-C cable
|
||||
- A serial terminal application:
|
||||
- **Windows:** PuTTY, TeraTerm, or the Arduino IDE Serial Monitor
|
||||
- **macOS:** `screen`, CoolTerm, or the Arduino IDE Serial Monitor
|
||||
- **Linux:** `screen`, `minicom`, `picocom`, or the Arduino IDE Serial Monitor
|
||||
|
||||
### Connection Settings
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Baud rate | 115200 |
|
||||
| Data bits | 8 |
|
||||
| Parity | None |
|
||||
| Stop bits | 1 |
|
||||
| Line ending | CR (carriage return) or CR+LF |
|
||||
|
||||
### Quick Start (macOS / Linux)
|
||||
|
||||
```
|
||||
screen /dev/ttyACM0 115200
|
||||
```
|
||||
|
||||
On macOS the port is typically `/dev/cu.usbmodem*`. On Linux it is usually `/dev/ttyACM0` or `/dev/ttyUSB0`.
|
||||
|
||||
### Quick Start (Arduino IDE)
|
||||
|
||||
Open **Tools → Serial Monitor**, set baud to **115200** and line ending to **Carriage Return** or **Both NL & CR**.
|
||||
|
||||
Once connected, type `help` and press Enter to confirm everything is working.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
All commands follow a simple pattern: `get` to read, `set` to write.
|
||||
|
||||
### Viewing Settings
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get all` | Dump every setting at once |
|
||||
| `get name` | Device name |
|
||||
| `get freq` | Radio frequency (MHz) |
|
||||
| `get bw` | Bandwidth (kHz) |
|
||||
| `get sf` | Spreading factor |
|
||||
| `get cr` | Coding rate |
|
||||
| `get tx` | TX power (dBm) |
|
||||
| `get radio` | All radio params in one line |
|
||||
| `get utc` | UTC offset (hours) |
|
||||
| `get notify` | Keyboard flash notification (on/off) |
|
||||
| `get gps` | GPS status and interval |
|
||||
| `get pin` | BLE pairing PIN |
|
||||
| `get path.hash.mode` | Path hash size (0=1-byte, 1=2-byte, 2=3-byte) |
|
||||
| `get rxdelay` | Rx delay base (0=disabled) |
|
||||
| `get af` | Airtime factor |
|
||||
| `get multi.acks` | Redundant ACKs (0 or 1) |
|
||||
| `get int.thresh` | Interference threshold (0=disabled) |
|
||||
| `get gps.baud` | GPS baud rate (0=compile-time default) |
|
||||
| `get channels` | List all channels with index numbers |
|
||||
| `get presets` | List all radio presets with parameters |
|
||||
| `get pubkey` | Device public key (hex) |
|
||||
| `get firmware` | Firmware version string |
|
||||
|
||||
**4G variant only:**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get modem` | Modem enabled/disabled |
|
||||
| `get apn` | Current APN |
|
||||
| `get imei` | Device IMEI |
|
||||
|
||||
### Changing Settings
|
||||
|
||||
#### Device Name
|
||||
|
||||
```
|
||||
set name MyNode
|
||||
```
|
||||
|
||||
Names cannot contain these characters: `[ ] / \ : , ? *`
|
||||
|
||||
#### Radio Parameters (Individual)
|
||||
|
||||
Each of these applies immediately — no reboot required.
|
||||
|
||||
```
|
||||
set freq 910.525
|
||||
set bw 62.5
|
||||
set sf 7
|
||||
set cr 5
|
||||
set tx 22
|
||||
```
|
||||
|
||||
Valid ranges:
|
||||
|
||||
| Parameter | Min | Max |
|
||||
|-----------|-----|-----|
|
||||
| freq | 400.0 | 928.0 |
|
||||
| bw | 7.8 | 500.0 |
|
||||
| sf | 5 | 12 |
|
||||
| cr | 5 | 8 |
|
||||
| tx | 1 | Board max (typically 22) |
|
||||
|
||||
#### Radio Parameters (All at Once)
|
||||
|
||||
Set frequency, bandwidth, spreading factor, and coding rate in a single command:
|
||||
|
||||
```
|
||||
set radio 910.525 62.5 7 5
|
||||
```
|
||||
|
||||
#### Radio Presets
|
||||
|
||||
The easiest way to configure your radio. First, list the available presets:
|
||||
|
||||
```
|
||||
get presets
|
||||
```
|
||||
|
||||
This prints a numbered list like:
|
||||
|
||||
```
|
||||
Available radio presets:
|
||||
0 Australia 915.800 MHz BW250.0 SF10 CR5 TX22
|
||||
1 Australia (Narrow) 916.575 MHz BW62.5 SF7 CR8 TX22
|
||||
...
|
||||
14 USA/Canada (Recommended) 910.525 MHz BW62.5 SF7 CR5 TX22
|
||||
15 Vietnam 920.250 MHz BW250.0 SF11 CR5 TX22
|
||||
```
|
||||
|
||||
Apply a preset by name or number:
|
||||
|
||||
```
|
||||
set preset USA/Canada (Recommended)
|
||||
set preset 14
|
||||
```
|
||||
|
||||
Preset names are case-insensitive, so `set preset australia` works too. The preset applies all five radio parameters (freq, bw, sf, cr, tx) and takes effect immediately.
|
||||
|
||||
#### UTC Offset
|
||||
|
||||
```
|
||||
set utc 10
|
||||
```
|
||||
|
||||
Range: -12 to +14.
|
||||
|
||||
#### Keyboard Notification Flash
|
||||
|
||||
Toggle whether the keyboard backlight flashes when a new message arrives:
|
||||
|
||||
```
|
||||
set notify on
|
||||
set notify off
|
||||
```
|
||||
|
||||
#### BLE PIN
|
||||
|
||||
```
|
||||
set pin 123456
|
||||
```
|
||||
|
||||
#### Path Hash Mode
|
||||
|
||||
Controls the byte size of each repeater's identity stamp in forwarded flood packets. Larger hashes reduce collisions at the cost of fewer maximum hops.
|
||||
|
||||
```
|
||||
set path.hash.mode 1
|
||||
```
|
||||
|
||||
| Mode | Bytes/hop | Max hops | Notes |
|
||||
|------|-----------|----------|-------|
|
||||
| 0 | 1 | 64 | Legacy — prone to hash collisions in larger networks |
|
||||
| 1 | 2 | 32 | Recommended — effectively eliminates collisions |
|
||||
| 2 | 3 | 21 | Maximum precision, rarely needed |
|
||||
|
||||
Nodes with different modes can coexist — the mode only affects packets your node originates. The hash size is encoded in each packet's header, so receiving nodes adapt automatically.
|
||||
|
||||
### Mesh Tuning
|
||||
|
||||
These settings control how the device participates in the mesh network. They take effect immediately — no reboot required (except `gps.baud`).
|
||||
|
||||
#### Rx Delay (rxdelay)
|
||||
|
||||
Delays processing of flood packets based on signal quality. Stronger signals are processed first; weaker copies wait longer and are typically discarded as duplicates. Direct messages are always processed immediately.
|
||||
|
||||
```
|
||||
set rxdelay 3
|
||||
```
|
||||
|
||||
Range: 0–20 (0 = disabled, default). Higher values create larger timing differences between strong and weak signals. Values below 1.0 have no practical effect. See the [MeshSydney wiki](https://meshsydney.com/wiki) for detailed tuning profiles.
|
||||
|
||||
#### Airtime Factor (af)
|
||||
|
||||
Adjusts how long certain internal timing windows remain open. Does not change the LoRa radio parameters (SF, BW, CR) — those remain as configured.
|
||||
|
||||
```
|
||||
set af 1.0
|
||||
```
|
||||
|
||||
Range: 0–9 (default: 1.0). Keep this value consistent across nodes in your mesh for best coherence.
|
||||
|
||||
#### Multiple Acknowledgments (multi.acks)
|
||||
|
||||
Sends redundant ACK packets for direct messages. When enabled, two ACKs are sent (a multi-ack first, then the standard ACK), improving delivery confirmation reliability.
|
||||
|
||||
```
|
||||
set multi.acks 1
|
||||
```
|
||||
|
||||
Values: 0 (single ACK) or 1 (redundant ACKs, default).
|
||||
|
||||
#### Interference Threshold (int.thresh)
|
||||
|
||||
Enables channel activity scanning before transmitting. Not recommended unless your device is in a high RF interference environment — specifically where the noise floor is low but shows significant fluctuations indicating interference. Enabling this adds approximately 4 seconds of receive delay per packet.
|
||||
|
||||
```
|
||||
set int.thresh 14
|
||||
set int.thresh 0
|
||||
```
|
||||
|
||||
Values: 0 (disabled, default) or 14+ (14 is the typical setting). Values between 1–13 are not functional and will be rejected.
|
||||
|
||||
#### GPS Baud Rate (gps.baud)
|
||||
|
||||
Override the GPS serial baud rate. The default (0) uses the compile-time value of 38400. **Requires a reboot to take effect** — the GPS serial port is only configured at startup.
|
||||
|
||||
```
|
||||
set gps.baud 9600
|
||||
set gps.baud 0
|
||||
```
|
||||
|
||||
Valid rates: 0 (default), 4800, 9600, 19200, 38400, 57600, 115200.
|
||||
|
||||
### Channel Management
|
||||
|
||||
#### List Channels
|
||||
|
||||
```
|
||||
get channels
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
[0] #public
|
||||
[1] #meck-test
|
||||
[2] #local-group
|
||||
```
|
||||
|
||||
#### Add a Hashtag Channel
|
||||
|
||||
```
|
||||
set channel.add meck-test
|
||||
```
|
||||
|
||||
The `#` prefix is added automatically if you omit it. The channel's encryption key is derived from the name (SHA-256), matching the same method used by the on-device Settings screen and companion apps.
|
||||
|
||||
#### Delete a Channel
|
||||
|
||||
```
|
||||
set channel.del 2
|
||||
```
|
||||
|
||||
Channels are referenced by their index number (shown in `get channels`). Channel 0 (public) cannot be deleted. Remaining channels are automatically compacted after deletion.
|
||||
|
||||
### 4G Modem (4G Variant Only)
|
||||
|
||||
#### Enable / Disable Modem
|
||||
|
||||
```
|
||||
set modem on
|
||||
set modem off
|
||||
```
|
||||
|
||||
#### Set APN
|
||||
|
||||
```
|
||||
set apn telstra.internet
|
||||
```
|
||||
|
||||
To clear a custom APN and revert to auto-detection on next boot:
|
||||
|
||||
```
|
||||
set apn
|
||||
```
|
||||
|
||||
### System Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `reboot` | Restart the device |
|
||||
| `rebuild` | Erase filesystem, re-save identity + prefs + contacts + channels |
|
||||
| `erase` | Format the filesystem (caution: loses everything) |
|
||||
| `ls UserData/` | List files on internal filesystem |
|
||||
| `ls ExtraFS/` | List files on secondary filesystem |
|
||||
| `cat UserData/<path>` | Dump file contents as hex |
|
||||
| `rm UserData/<path>` | Delete a file |
|
||||
| `help` | Show command summary |
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
Plug in your new T-Deck Pro and run through these commands to get on the air:
|
||||
|
||||
```
|
||||
set name YourCallsign
|
||||
set preset Australia
|
||||
set utc 10
|
||||
set channel.add local-group
|
||||
get all
|
||||
```
|
||||
|
||||
### Switching to a New Region
|
||||
|
||||
Moving from Australia to the US? One command:
|
||||
|
||||
```
|
||||
set preset USA/Canada (Recommended)
|
||||
```
|
||||
|
||||
Verify with:
|
||||
|
||||
```
|
||||
get radio
|
||||
```
|
||||
|
||||
### Custom Radio Configuration
|
||||
|
||||
If none of the presets match your local group or you need specific parameters, set them directly. You can do it all in one command:
|
||||
|
||||
```
|
||||
set radio 916.575 62.5 8 8
|
||||
set tx 20
|
||||
```
|
||||
|
||||
Or one parameter at a time if you're only adjusting part of your config:
|
||||
|
||||
```
|
||||
set freq 916.575
|
||||
set bw 62.5
|
||||
set sf 8
|
||||
set cr 8
|
||||
set tx 20
|
||||
```
|
||||
|
||||
Both approaches apply immediately. Confirm with `get radio` to double-check everything took:
|
||||
|
||||
```
|
||||
get radio
|
||||
> freq=916.575 bw=62.5 sf=8 cr=8 tx=20
|
||||
```
|
||||
|
||||
### Troubleshooting Radio Settings
|
||||
|
||||
If you're not sure what went wrong, dump everything:
|
||||
|
||||
```
|
||||
get all
|
||||
```
|
||||
|
||||
Compare the radio section against what others in your area are using. If you need to match exact parameters from another node:
|
||||
|
||||
```
|
||||
set radio 916.575 62.5 7 8
|
||||
set tx 22
|
||||
```
|
||||
|
||||
### Backing Up Your Settings
|
||||
|
||||
Use `get all` to capture a snapshot of your configuration. Copy the serial output and save it — you can manually re-enter the settings after a firmware update or device reset if your SD card backup isn't available.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **All radio changes apply live.** There is no need to reboot after changing frequency, bandwidth, spreading factor, coding rate, or TX power. The radio reconfigures on the fly.
|
||||
- **Preset selection by number is faster.** Once you've seen `get presets`, use the index number instead of typing the full name.
|
||||
- **Settings are persisted immediately.** Every `set` command writes to flash. If power is lost, your settings are safe.
|
||||
- **SD card backup is automatic.** If your T-Deck Pro has an SD card inserted, settings are backed up after every change. On a fresh flash, settings restore automatically from the SD card.
|
||||
- **The `get all` command is your friend.** When in doubt, dump everything and check.
|
||||
43
boards/crowpanel.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"memory_type": "qio_opi",
|
||||
"partitions": "default_16MB.csv"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=0"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "esp32s3"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "Elecrow CrowPanel (ESP32-S3 16MB Flash, 8MB PSRAM)",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 524288,
|
||||
"maximum_size": 16777216,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"monitor": {
|
||||
"speed": 115200
|
||||
},
|
||||
"url": "https://www.elecrow.com/crowpanel-advance-hmi-intelligent-screen-esp32-ai-display.html",
|
||||
"vendor": "Elecrow"
|
||||
}
|
||||
101
docs/Launcher_Flash_Guide.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# How to Flash Meck Firmware Using Launcher over WiFi
|
||||
|
||||
## How to Install Launcher on Your T-Deck Pro
|
||||
|
||||
First, ensure your SD card is inserted into your T-Deck Pro. Your SD card should already have been formatted as FAT32.
|
||||
|
||||
1. Plug your T-Deck Pro into your computer via USB-C.
|
||||
2. Go to [https://bmorcelli.github.io/Launcher/webflasher.html](https://bmorcelli.github.io/Launcher/webflasher.html) in Chrome browser.
|
||||
3. Click on **LilyGo** under Choose a Vendor.
|
||||
4. Click on **T-Deck Pro**.
|
||||
5. Click on **Connect**.
|
||||
6. In the serial connect popup, click on your device in the list (likely starts with "USB JTAG/serial debug unit"), and click **Connect**. Wait a few seconds for it to connect.
|
||||
7. Click the **Install T-Deck Pro** popup.
|
||||
8. Click **Next**. (Don't worry about ticking the Erase Device checkbox.)
|
||||
9. Click **Install**.
|
||||
|
||||
### If You Don't Already Have a Meck Firmware File
|
||||
|
||||
Download one from [https://github.com/pelgraine/Meck/releases](https://github.com/pelgraine/Meck/releases).
|
||||
|
||||
## How to Install a New Meck Firmware .bin File via Launcher
|
||||
|
||||
After flashing using [https://bmorcelli.github.io/Launcher/webflasher.html](https://bmorcelli.github.io/Launcher/webflasher.html), your Pro will reboot itself automatically and display the main Launcher home screen, with the SD card option highlighted.
|
||||
|
||||
<img src="images/01_launcher_home.jpg" alt="Launcher home screen" width="200">
|
||||
|
||||
Either tap **NEXT** on the device screen twice or tap on the WUI button, and tap **SEL**.
|
||||
|
||||
<img src="images/02_wui_selected.jpg" alt="WUI selected" width="200">
|
||||
|
||||
Tap on **My Network** on the pop-up menu. Press **NEXT/SEL** as needed to highlight and select your WiFi SSID.
|
||||
|
||||
Enter your WiFi SSID details.
|
||||
|
||||
Once connected, your device will display the WebUI connection screen with the T-Deck Pro IP address.
|
||||
|
||||
Open a browser on your computer — Chrome, Firefox, or Safari will do, but Firefox tends to be easiest — and type in the IP address displayed on your T-Deck Pro into your computer browser address bar, and press enter.
|
||||
|
||||
<img src="images/03_webui_ip.jpg" alt="WebUI IP address screen" width="200">
|
||||
|
||||
In this instance, for example, I would type `192.168.1.118`, and once I've pressed enter, the address bar now displays `http://192.168.1.118/` (as per the photo). If you're having trouble loading the IP address page, double check your browser hasn't automatically changed it to `https`. If it has, delete the `s` out of the URL and hit enter to load the page.
|
||||
|
||||
Login to the browser page with the username **admin** and password **launcher**, and click **Login**. The browser will refresh and display your SD card file list.
|
||||
|
||||
<img src="images/04_browser_login.jpg" alt="Browser login" width="450">
|
||||
|
||||
<img src="images/05_send_files.png" alt="SD card file list with Send Files button" width="450">
|
||||
|
||||
Scroll down to the bottom of the browser page, and click the **Send Files** button.
|
||||
|
||||
Your computer/device will load the file browser. Navigate to wherever you've previously saved your new Meck firmware `.bin` file, select the bin file, and click **Open**.
|
||||
|
||||
Wait for the blue loading bar on the bottom of the browser page to finish, and then check you can see the file name in the list in green. Also worth checking the file is at least 1.2MB — if it is under 1MB, the file hasn't uploaded properly and you will need to go through the **Send Files** button to try uploading it again.
|
||||
|
||||
<img src="images/06_check_file_uploaded.png" alt="Check file uploaded" width="450">
|
||||
|
||||
You can then either close the browser window or just leave it. Go back to your T-Deck Pro and press **SEL** to disconnect the WUI mode.
|
||||
|
||||
<img src="images/07_disconnect_wui.png" alt="Disconnect WUI" width="200">
|
||||
|
||||
Either press **PREV** twice to navigate to it and then press **SEL** again to open, or tap right on the **SD** button to open the SD card menu.
|
||||
|
||||
<img src="images/08_sd_button.jpg" alt="SD button on Launcher home" width="200">
|
||||
|
||||
The Launcher SD file browser will open. You will most likely have to tap **Page Down** at least twice to scroll to where the name of your new file is.
|
||||
|
||||
<img src="images/09_sd_file_list.png" alt="SD file list page 1" width="200">
|
||||
|
||||
<img src="images/10_page_down.png" alt="Page Down to find file" width="200">
|
||||
|
||||
Either press **NEXT** to navigate until the new file is highlighted with the `>`, or just tap right on the file name, and press **SEL** to bring up the file menu.
|
||||
|
||||
<img src="images/11_select_file.png" alt="Select the firmware file" width="200">
|
||||
|
||||
The first option on the file menu list will be **>Install**. You can either tap right on **Install** or tap **SEL**.
|
||||
|
||||
<img src="images/12_install_option.png" alt="Install option" width="200">
|
||||
|
||||
**Wait for the firmware to finish installing.** It will reboot itself automatically.
|
||||
|
||||
<img src="images/13_installing_fw.jpg" alt="Installing firmware" width="200">
|
||||
|
||||
> **Note:** On first flash of a new firmware version, the "Loading…" screen will most likely display for about 70 seconds. This is a known bug. **Please be patient** if this is the first time loading your new Meck firmware.
|
||||
|
||||
<img src="images/14_loading_screen.png" alt="Loading screen" width="200">
|
||||
|
||||
On every boot, the firmware will scan your SD card and `/books` folder for any new `.txt` or `.epub` files that haven't yet been cached. It's usually very quick even if you have a lot of ebook files, and even faster after the first boot.
|
||||
|
||||
<img src="images/15_indexing_pages.jpg" alt="Indexing pages" width="200">
|
||||
|
||||
You'll then see the firmware version splash screen for a split second.
|
||||
|
||||
<img src="images/16_version_splash.jpg" alt="Version splash screen" width="200">
|
||||
|
||||
Then the Meck home screen will display, and you're good to go. Here's an example of what the Meck 4G WiFi companion firmware home screen looks like:
|
||||
|
||||
<img src="images/17_meck_home.jpg" alt="Meck home screen" width="200">
|
||||
|
||||
> **Tip:** Every time you reset the device, the Launcher splash screen will display. Wait about six seconds if you just want the Meck firmware to boot by default. Otherwise, tap the **LAUNCHER** text at the bottom to boot back into the Launcher home screen, to get access to the SD menu and WUI menu again.
|
||||
|
||||
<img src="images/18_launcher_boot.jpg" alt="Launcher boot screen" width="200">
|
||||
BIN
docs/images/01_launcher_home.jpg
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
docs/images/02_wui_selected.jpg
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
docs/images/03_webui_ip.jpg
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
docs/images/04_browser_login.jpg
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
docs/images/05_send_files.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
docs/images/06_check_file_uploaded.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
docs/images/07_disconnect_wui.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
docs/images/08_sd_button.jpg
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
docs/images/09_sd_file_list.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/images/10_page_down.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
docs/images/11_select_file.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
docs/images/12_install_option.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
docs/images/13_installing_fw.jpg
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
docs/images/14_loading_screen.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
docs/images/15_indexing_pages.jpg
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
docs/images/16_version_splash.jpg
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
docs/images/17_meck_home.jpg
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
docs/images/18_launcher_boot.jpg
Normal file
|
After Width: | Height: | Size: 394 KiB |
@@ -41,7 +41,7 @@ public:
|
||||
void disableSerial() { _serial->disable(); }
|
||||
virtual void msgRead(int msgcount) = 0;
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path = nullptr) = 0;
|
||||
const uint8_t* path = nullptr, int8_t snr = 0) = 0;
|
||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||
virtual void loop() = 0;
|
||||
virtual void showAlert(const char* text, int duration_millis) {}
|
||||
|
||||
@@ -230,6 +230,28 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.read((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
|
||||
|
||||
// Fields added later — may not exist in older prefs files
|
||||
if (file.read((uint8_t *)&_prefs.kb_flash_notify, sizeof(_prefs.kb_flash_notify)) != sizeof(_prefs.kb_flash_notify)) {
|
||||
_prefs.kb_flash_notify = 0; // default OFF for old files
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)) != sizeof(_prefs.ringtone_enabled)) {
|
||||
_prefs.ringtone_enabled = 0; // default OFF for old files
|
||||
}
|
||||
|
||||
// Clamp booleans to 0/1 in case of garbage
|
||||
if (_prefs.kb_flash_notify > 1) _prefs.kb_flash_notify = 0;
|
||||
if (_prefs.ringtone_enabled > 1) _prefs.ringtone_enabled = 0;
|
||||
|
||||
// v1.14+ fields — may not exist in older prefs files
|
||||
if (file.read((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)) != sizeof(_prefs.path_hash_mode)) {
|
||||
_prefs.path_hash_mode = 0; // default: legacy 1-byte
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)) != sizeof(_prefs.autoadd_max_hops)) {
|
||||
_prefs.autoadd_max_hops = 0; // default: no limit
|
||||
}
|
||||
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
|
||||
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
|
||||
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
@@ -265,14 +287,66 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
|
||||
file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.write((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
|
||||
file.write((uint8_t *)&_prefs.kb_flash_notify, sizeof(_prefs.kb_flash_notify)); // 89
|
||||
file.write((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)); // 90
|
||||
file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 91
|
||||
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 92
|
||||
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::loadContacts(DataStoreHost* host) {
|
||||
File file = openRead(_getContactsChannelsFS(), "/contacts3");
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
|
||||
// --- Crash recovery ---
|
||||
// If /contacts3 is missing but /contacts3.tmp exists, a crash occurred
|
||||
// after removing the original but before the rename completed.
|
||||
// The .tmp file has the valid data — promote it.
|
||||
if (!fs->exists("/contacts3") && fs->exists("/contacts3.tmp")) {
|
||||
Serial.println("DataStore: recovering contacts from .tmp file");
|
||||
fs->rename("/contacts3.tmp", "/contacts3");
|
||||
}
|
||||
// If both exist, a crash occurred before the old file was removed.
|
||||
// The original /contacts3 is still valid — just clean up the orphan.
|
||||
if (fs->exists("/contacts3.tmp")) {
|
||||
fs->remove("/contacts3.tmp");
|
||||
}
|
||||
|
||||
File file = openRead(fs, "/contacts3");
|
||||
if (file) {
|
||||
// --- Truncation guard ---
|
||||
// If the file is smaller than one full contact record (152 bytes),
|
||||
// it was truncated by a crash/brown-out. Discard it and try the
|
||||
// .tmp backup if available.
|
||||
size_t fsize = file.size();
|
||||
if (fsize > 0 && fsize < 152) {
|
||||
Serial.printf("DataStore: contacts3 truncated (%d bytes < 152), discarding\n", (int)fsize);
|
||||
file.close();
|
||||
fs->remove("/contacts3");
|
||||
if (fs->exists("/contacts3.tmp")) {
|
||||
File tmp = openRead(fs, "/contacts3.tmp");
|
||||
if (tmp && tmp.size() >= 152) {
|
||||
Serial.println("DataStore: recovering from .tmp after truncation");
|
||||
tmp.close();
|
||||
fs->rename("/contacts3.tmp", "/contacts3");
|
||||
file = openRead(fs, "/contacts3");
|
||||
if (!file) return; // give up
|
||||
} else {
|
||||
if (tmp) tmp.close();
|
||||
Serial.println("DataStore: no valid contacts backup — starting fresh");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Serial.println("DataStore: no .tmp backup — starting fresh");
|
||||
return;
|
||||
}
|
||||
} else if (fsize == 0) {
|
||||
// Empty file — nothing to load
|
||||
file.close();
|
||||
return;
|
||||
}
|
||||
|
||||
bool full = false;
|
||||
while (!full) {
|
||||
ContactInfo c;
|
||||
@@ -302,36 +376,86 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
|
||||
}
|
||||
|
||||
void DataStore::saveContacts(DataStoreHost* host) {
|
||||
File file = openWrite(_getContactsChannelsFS(), "/contacts3");
|
||||
if (file) {
|
||||
uint32_t idx = 0;
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
const char* finalPath = "/contacts3";
|
||||
const char* tmpPath = "/contacts3.tmp";
|
||||
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
bool success = (file.write(c.id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.write(&c.type, 1) == 1);
|
||||
success = success && (file.write(&c.flags, 1) == 1);
|
||||
success = success && (file.write(&unused, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (file.write(c.out_path, 64) == 64);
|
||||
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
// --- Step 1: Write all contacts to a temporary file ---
|
||||
File file = openWrite(fs, tmpPath);
|
||||
if (!file) {
|
||||
Serial.println("DataStore: saveContacts FAILED — cannot open tmp file");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!success) break; // write failed
|
||||
uint32_t idx = 0;
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
uint32_t recordsWritten = 0;
|
||||
bool writeOk = true;
|
||||
|
||||
idx++; // advance to next contact
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
bool success = (file.write(c.id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.write(&c.type, 1) == 1);
|
||||
success = success && (file.write(&c.flags, 1) == 1);
|
||||
success = success && (file.write(&unused, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (file.write(c.out_path, 64) == 64);
|
||||
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
|
||||
if (!success) {
|
||||
writeOk = false;
|
||||
Serial.printf("DataStore: saveContacts write error at record %d\n", idx);
|
||||
break;
|
||||
}
|
||||
file.close();
|
||||
|
||||
recordsWritten++;
|
||||
idx++;
|
||||
}
|
||||
|
||||
file.close();
|
||||
|
||||
// --- Step 2: Verify the write completed ---
|
||||
// Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close)
|
||||
size_t expectedBytes = recordsWritten * 152; // 152 bytes per contact record
|
||||
File verify = openRead(fs, tmpPath);
|
||||
size_t bytesWritten = verify ? verify.size() : 0;
|
||||
if (verify) verify.close();
|
||||
|
||||
if (!writeOk || bytesWritten != expectedBytes) {
|
||||
Serial.printf("DataStore: saveContacts ABORTED — wrote %d bytes, expected %d (%d records)\n",
|
||||
(int)bytesWritten, (int)expectedBytes, recordsWritten);
|
||||
fs->remove(tmpPath); // Clean up failed tmp file
|
||||
return; // Original /contacts3 is untouched
|
||||
}
|
||||
|
||||
// --- Step 3: Replace original with verified temp file ---
|
||||
fs->remove(finalPath);
|
||||
if (fs->rename(tmpPath, finalPath)) {
|
||||
Serial.printf("DataStore: saved %d contacts (%d bytes)\n", recordsWritten, (int)bytesWritten);
|
||||
} else {
|
||||
// Rename failed — tmp file still has the good data
|
||||
Serial.println("DataStore: rename failed, tmp file preserved");
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::loadChannels(DataStoreHost* host) {
|
||||
File file = openRead(_getContactsChannelsFS(), "/channels2");
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
|
||||
// Crash recovery (same pattern as contacts)
|
||||
if (!fs->exists("/channels2") && fs->exists("/channels2.tmp")) {
|
||||
Serial.println("DataStore: recovering channels from .tmp file");
|
||||
fs->rename("/channels2.tmp", "/channels2");
|
||||
}
|
||||
if (fs->exists("/channels2.tmp")) {
|
||||
fs->remove("/channels2.tmp");
|
||||
}
|
||||
|
||||
File file = openRead(fs, "/channels2");
|
||||
if (file) {
|
||||
bool full = false;
|
||||
uint8_t channel_idx = 0;
|
||||
@@ -356,22 +480,54 @@ void DataStore::loadChannels(DataStoreHost* host) {
|
||||
}
|
||||
|
||||
void DataStore::saveChannels(DataStoreHost* host) {
|
||||
File file = openWrite(_getContactsChannelsFS(), "/channels2");
|
||||
if (file) {
|
||||
uint8_t channel_idx = 0;
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
memset(unused, 0, 4);
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
const char* finalPath = "/channels2";
|
||||
const char* tmpPath = "/channels2.tmp";
|
||||
|
||||
while (host->getChannelForSave(channel_idx, ch)) {
|
||||
bool success = (file.write(unused, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
File file = openWrite(fs, tmpPath);
|
||||
if (!file) {
|
||||
Serial.println("DataStore: saveChannels FAILED — cannot open tmp file");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!success) break; // write failed
|
||||
channel_idx++;
|
||||
uint8_t channel_idx = 0;
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
memset(unused, 0, 4);
|
||||
bool writeOk = true;
|
||||
|
||||
while (host->getChannelForSave(channel_idx, ch)) {
|
||||
bool success = (file.write(unused, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
|
||||
if (!success) {
|
||||
writeOk = false;
|
||||
Serial.printf("DataStore: saveChannels write error at channel %d\n", channel_idx);
|
||||
break;
|
||||
}
|
||||
file.close();
|
||||
channel_idx++;
|
||||
}
|
||||
|
||||
file.close();
|
||||
|
||||
// Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close)
|
||||
size_t expectedBytes = channel_idx * 68; // 4 + 32 + 32 = 68 bytes per channel
|
||||
File verify = openRead(fs, tmpPath);
|
||||
size_t bytesWritten = verify ? verify.size() : 0;
|
||||
if (verify) verify.close();
|
||||
if (!writeOk || bytesWritten != expectedBytes) {
|
||||
Serial.printf("DataStore: saveChannels ABORTED — wrote %d bytes, expected %d\n",
|
||||
(int)bytesWritten, (int)expectedBytes);
|
||||
fs->remove(tmpPath);
|
||||
return;
|
||||
}
|
||||
|
||||
fs->remove(finalPath);
|
||||
if (fs->rename(tmpPath, finalPath)) {
|
||||
Serial.printf("DataStore: saved %d channels (%d bytes)\n", channel_idx, (int)bytesWritten);
|
||||
} else {
|
||||
Serial.println("DataStore: channels rename failed, tmp file preserved");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
#include "AbstractUITask.h"
|
||||
|
||||
/*------------ Frame Protocol --------------*/
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
#define FIRMWARE_VER_CODE 10
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "27 Feb 2026"
|
||||
#define FIRMWARE_BUILD_DATE "8 March 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.9.5"
|
||||
#define FIRMWARE_VERSION "Meck v1.0"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
@@ -84,6 +84,16 @@ struct AdvertPath {
|
||||
uint8_t path[MAX_PATH_SIZE];
|
||||
};
|
||||
|
||||
// Discovery scan — transient buffer for on-device node discovery
|
||||
#define MAX_DISCOVERED_NODES 20
|
||||
|
||||
struct DiscoveredNode {
|
||||
ContactInfo contact;
|
||||
uint8_t path_len;
|
||||
int8_t snr; // SNR × 4 from active discovery response (0 if pre-seeded)
|
||||
bool already_in_contacts; // true if contact was auto-added or already known
|
||||
};
|
||||
|
||||
class MyMesh : public BaseChatMesh, public DataStoreHost {
|
||||
public:
|
||||
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL);
|
||||
@@ -101,6 +111,14 @@ public:
|
||||
void enterCLIRescue();
|
||||
|
||||
int getRecentlyHeard(AdvertPath dest[], int max_num);
|
||||
|
||||
// Discovery scan — on-device node discovery
|
||||
void startDiscovery(uint32_t duration_ms = 30000);
|
||||
void stopDiscovery();
|
||||
bool isDiscoveryActive() const { return _discoveryActive; }
|
||||
int getDiscoveredCount() const { return _discoveredCount; }
|
||||
const DiscoveredNode& getDiscovered(int idx) const { return _discovered[idx]; }
|
||||
bool addDiscoveredToContacts(int idx); // promote a discovered node into contacts
|
||||
|
||||
// Queue a sent channel message for BLE app sync
|
||||
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
|
||||
@@ -109,7 +127,7 @@ public:
|
||||
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
|
||||
|
||||
// Repeater admin - UI-initiated operations
|
||||
bool uiLoginToRepeater(uint32_t contact_idx, const char* password);
|
||||
bool uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms);
|
||||
bool uiSendCliCommand(uint32_t contact_idx, const char* command);
|
||||
bool uiSendTelemetryRequest(uint32_t contact_idx);
|
||||
int getAdminContactIdx() const { return _admin_contact_idx; }
|
||||
@@ -119,7 +137,10 @@ protected:
|
||||
float getAirtimeBudgetFactor() const override;
|
||||
int getInterferenceThreshold() const override;
|
||||
int calcRxDelay(float score, uint32_t air_time) const override;
|
||||
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
|
||||
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
|
||||
uint8_t getExtraAckTransmitCount() const override;
|
||||
uint8_t getAutoAddMaxHops() const override;
|
||||
bool filterRecvFloodPacket(mesh::Packet* packet) override;
|
||||
|
||||
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
@@ -257,6 +278,13 @@ private:
|
||||
SentMsgTrack _sent_track[SENT_TRACK_SIZE];
|
||||
int _sent_track_idx; // next slot in circular buffer
|
||||
int _admin_contact_idx; // contact index for active admin session (-1 if none)
|
||||
|
||||
// Discovery scan state
|
||||
DiscoveredNode _discovered[MAX_DISCOVERED_NODES];
|
||||
int _discoveredCount;
|
||||
bool _discoveryActive;
|
||||
unsigned long _discoveryTimeout;
|
||||
uint32_t _discoveryTag; // random correlation tag for active discovery
|
||||
};
|
||||
|
||||
extern MyMesh the_mesh;
|
||||
@@ -30,4 +30,9 @@ struct NodePrefs { // persisted to file
|
||||
uint8_t autoadd_config; // bitmask for auto-add contacts config
|
||||
int8_t utc_offset_hours; // UTC offset in hours (-12 to +14), default 0
|
||||
uint8_t kb_flash_notify; // Keyboard backlight flash on new message (0=off, 1=on)
|
||||
uint8_t ringtone_enabled; // Ringtone on incoming call (0=off, 1=on) — 4G only
|
||||
uint8_t path_hash_mode; // 0=1-byte (legacy), 1=2-byte, 2=3-byte path hashes
|
||||
uint8_t autoadd_max_hops; // 0=no limit, N=up to N-1 hops (max 64)
|
||||
uint32_t gps_baudrate; // GPS baud rate (0 = use compile-time GPS_BAUDRATE default)
|
||||
uint8_t interference_threshold; // Interference threshold in dB (0=disabled, 14+=enabled)
|
||||
};
|
||||
@@ -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;
|
||||
@@ -329,6 +343,18 @@
|
||||
}
|
||||
#endif
|
||||
|
||||
// --- Non-T-Deck ESP32 targets (CrowPanel, etc.) ---
|
||||
// Variables declared inside the LilyGo_TDeck_Pro block above that are
|
||||
// referenced unconditionally in setup()/loop() need parallel declarations.
|
||||
#if !defined(LilyGo_TDeck_Pro) && defined(ESP32)
|
||||
CPUPowerManager cpuPower;
|
||||
#define AGC_RESET_INTERVAL_MS 500
|
||||
static unsigned long lastAGCReset = 0;
|
||||
static bool readerMode = false;
|
||||
static bool notesMode = false;
|
||||
static bool audiobookMode = false;
|
||||
#endif
|
||||
|
||||
// Believe it or not, this std C function is busted on some platforms!
|
||||
static uint32_t _atoi(const char* sp) {
|
||||
uint32_t n = 0;
|
||||
@@ -368,6 +394,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 +448,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 +677,44 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - the_mesh.begin() done");
|
||||
|
||||
#ifdef WIFI_SSID
|
||||
MESH_DEBUG_PRINTLN("setup() - WiFi mode");
|
||||
MESH_DEBUG_PRINTLN("setup() - WiFi mode (compile-time credentials)");
|
||||
WiFi.begin(WIFI_SSID, WIFI_PWD);
|
||||
serial_interface.begin(TCP_PORT);
|
||||
#elif defined(MECK_WIFI_COMPANION)
|
||||
{
|
||||
// WiFi companion: load credentials from SD at runtime.
|
||||
// TCP server starts regardless — companion connects when WiFi comes up.
|
||||
MESH_DEBUG_PRINTLN("setup() - WiFi companion mode (runtime credentials)");
|
||||
WiFi.mode(WIFI_STA);
|
||||
if (sdCardReady) {
|
||||
File f = SD.open("/web/wifi.cfg", FILE_READ);
|
||||
if (f) {
|
||||
String ssid = f.readStringUntil('\n'); ssid.trim();
|
||||
String pass = f.readStringUntil('\n'); pass.trim();
|
||||
f.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
if (ssid.length() > 0) {
|
||||
MESH_DEBUG_PRINTLN("setup() - WiFi: connecting to '%s'", ssid.c_str());
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
unsigned long timeout = millis() + 8000;
|
||||
while (WiFi.status() != WL_CONNECTED && millis() < timeout) {
|
||||
delay(100);
|
||||
}
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("WiFi companion: connected to %s, IP: %s\n",
|
||||
ssid.c_str(), WiFi.localIP().toString().c_str());
|
||||
} else {
|
||||
Serial.println("WiFi companion: auto-connect failed (configure in Settings)");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.println("WiFi companion: no /web/wifi.cfg found (configure in Settings)");
|
||||
}
|
||||
}
|
||||
serial_interface.begin(TCP_PORT);
|
||||
MESH_DEBUG_PRINTLN("setup() - WiFi TCP server started on port %d", TCP_PORT);
|
||||
}
|
||||
#elif defined(BLE_PIN_CODE)
|
||||
MESH_DEBUG_PRINTLN("setup() - about to call serial_interface.begin() with BLE");
|
||||
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
||||
@@ -673,8 +742,12 @@ void setup() {
|
||||
// We need to reinitialize Serial2 to reclaim them
|
||||
#if HAS_GPS
|
||||
Serial2.end(); // Close any existing Serial2
|
||||
Serial2.begin(GPS_BAUDRATE, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
|
||||
MESH_DEBUG_PRINTLN("setup() - Reinitialized Serial2 for GPS after sensors.begin()");
|
||||
{
|
||||
uint32_t gps_baud = the_mesh.getNodePrefs()->gps_baudrate;
|
||||
if (gps_baud == 0) gps_baud = GPS_BAUDRATE;
|
||||
Serial2.begin(gps_baud, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
|
||||
MESH_DEBUG_PRINTLN("setup() - Reinitialized Serial2 for GPS at %lu baud", (unsigned long)gps_baud);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
@@ -688,8 +761,8 @@ void setup() {
|
||||
initKeyboard();
|
||||
#endif
|
||||
|
||||
// Initialize touch input (CST328)
|
||||
#ifdef HAS_TOUCHSCREEN
|
||||
// Initialize touch input (CST328 — T-Deck Pro only; CrowPanel uses GT911 via LovyanGFX)
|
||||
#if defined(HAS_TOUCHSCREEN) && defined(CST328_PIN_INT)
|
||||
if (touchInput.begin(CST328_PIN_INT)) {
|
||||
MESH_DEBUG_PRINTLN("setup() - Touch input initialized");
|
||||
} else {
|
||||
@@ -781,18 +854,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,26 +888,41 @@ 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();
|
||||
|
||||
// Audiobook: service audio decode regardless of which screen is active
|
||||
#ifndef HAS_4G_MODEM
|
||||
#if defined(LilyGo_TDeck_Pro) && !defined(HAS_4G_MODEM)
|
||||
{
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
@@ -1030,10 +1122,12 @@ void loop() {
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
// Periodic AGC reset - re-assert boosted RX gain to prevent sensitivity drift
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
if ((millis() - lastAGCReset) >= AGC_RESET_INTERVAL_MS) {
|
||||
radio_reset_agc();
|
||||
lastAGCReset = millis();
|
||||
}
|
||||
#endif
|
||||
// Handle T-Deck Pro keyboard input
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
handleKeyboardInput();
|
||||
@@ -1682,7 +1776,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 +1800,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 +1819,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 +1867,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 +1885,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 +1900,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 +1910,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 +1993,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 +2035,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 +2063,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 +2099,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 +2137,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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ChannelDetails.h>
|
||||
#include <MeshCore.h>
|
||||
#include <Packet.h>
|
||||
#include "EmojiSprites.h"
|
||||
|
||||
// SD card message persistence
|
||||
@@ -24,7 +25,7 @@
|
||||
// On-disk format for message persistence (SD card)
|
||||
// ---------------------------------------------------------------------------
|
||||
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
|
||||
#define MSG_FILE_VERSION 3 // v3: MSG_PATH_MAX increased to 20
|
||||
#define MSG_FILE_VERSION 3 // v3: MSG_PATH_MAX=20, reserved→snr field
|
||||
#define MSG_FILE_PATH "/meshcore/messages.bin"
|
||||
|
||||
struct __attribute__((packed)) MsgFileHeader {
|
||||
@@ -41,7 +42,7 @@ struct __attribute__((packed)) MsgFileRecord {
|
||||
uint8_t path_len;
|
||||
uint8_t channel_idx;
|
||||
uint8_t valid;
|
||||
uint8_t reserved;
|
||||
int8_t snr; // Receive SNR × 4 (was reserved; 0 = unknown)
|
||||
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes (first byte of pub key)
|
||||
char text[CHANNEL_MSG_TEXT_LEN];
|
||||
// 188 bytes total
|
||||
@@ -57,6 +58,7 @@ public:
|
||||
uint32_t timestamp;
|
||||
uint8_t path_len;
|
||||
uint8_t channel_idx; // Which channel this message belongs to
|
||||
int8_t snr; // Receive SNR × 4 (0 if locally sent or unknown)
|
||||
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes
|
||||
char text[CHANNEL_MSG_TEXT_LEN];
|
||||
bool valid;
|
||||
@@ -105,7 +107,7 @@ public:
|
||||
|
||||
// Add a new message to the history
|
||||
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
|
||||
const uint8_t* path_bytes = nullptr) {
|
||||
const uint8_t* path_bytes = nullptr, int8_t snr = 0) {
|
||||
// Move to next slot in circular buffer
|
||||
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
|
||||
|
||||
@@ -113,12 +115,14 @@ public:
|
||||
msg->timestamp = _rtc->getCurrentTime();
|
||||
msg->path_len = path_len;
|
||||
msg->channel_idx = channel_idx;
|
||||
msg->snr = snr;
|
||||
msg->valid = true;
|
||||
|
||||
// Store path hop hashes
|
||||
memset(msg->path, 0, MSG_PATH_MAX);
|
||||
if (path_bytes && path_len > 0 && path_len != 0xFF) {
|
||||
int n = path_len < MSG_PATH_MAX ? path_len : MSG_PATH_MAX;
|
||||
int n = mesh::Packet::getPathByteLenFor(path_len);
|
||||
if (n > MSG_PATH_MAX) n = MSG_PATH_MAX;
|
||||
memcpy(msg->path, path_bytes, n);
|
||||
}
|
||||
|
||||
@@ -289,11 +293,15 @@ public:
|
||||
if (!msg || msg->path_len == 0 || msg->path_len == 0xFF) return 0;
|
||||
|
||||
int pos = 0;
|
||||
int plen = msg->path_len < MSG_PATH_MAX ? msg->path_len : MSG_PATH_MAX;
|
||||
uint8_t hopCount = msg->path_len & 63;
|
||||
uint8_t bytesPerHop = (msg->path_len >> 6) + 1;
|
||||
|
||||
for (int h = 0; h < plen && pos < bufLen - 1; h++) {
|
||||
for (int h = 0; h < hopCount && pos < bufLen - 1; h++) {
|
||||
if (h > 0) pos += snprintf(buf + pos, bufLen - pos, ", ");
|
||||
pos += snprintf(buf + pos, bufLen - pos, "%02x", msg->path[h]);
|
||||
int offset = h * bytesPerHop;
|
||||
for (int b = 0; b < bytesPerHop && pos < bufLen - 1; b++) {
|
||||
pos += snprintf(buf + pos, bufLen - pos, "%02x", msg->path[offset + b]);
|
||||
}
|
||||
}
|
||||
|
||||
return pos;
|
||||
@@ -336,7 +344,7 @@ public:
|
||||
rec.path_len = _messages[i].path_len;
|
||||
rec.channel_idx = _messages[i].channel_idx;
|
||||
rec.valid = _messages[i].valid ? 1 : 0;
|
||||
rec.reserved = 0;
|
||||
rec.snr = _messages[i].snr;
|
||||
memcpy(rec.path, _messages[i].path, MSG_PATH_MAX);
|
||||
memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN);
|
||||
f.write((uint8_t*)&rec, sizeof(rec));
|
||||
@@ -403,6 +411,7 @@ public:
|
||||
_messages[i].path_len = rec.path_len;
|
||||
_messages[i].channel_idx = rec.channel_idx;
|
||||
_messages[i].valid = (rec.valid != 0);
|
||||
_messages[i].snr = rec.snr;
|
||||
memcpy(_messages[i].path, rec.path, MSG_PATH_MAX);
|
||||
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
|
||||
if (_messages[i].valid) loaded++;
|
||||
@@ -491,6 +500,8 @@ public:
|
||||
// Route type
|
||||
display.setCursor(0, y);
|
||||
uint8_t plen = msg->path_len;
|
||||
uint8_t hopCount = plen & 63; // extract hop count from encoded path_len
|
||||
uint8_t bytesPerHop = (plen >> 6) + 1; // 1, 2, or 3 bytes per hop
|
||||
if (plen == 0xFF) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("Route: Direct");
|
||||
@@ -499,14 +510,26 @@ public:
|
||||
display.print("Route: Local/Sent");
|
||||
} else {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
sprintf(tmp, "Route: %d hop%s", plen, plen == 1 ? "" : "s");
|
||||
sprintf(tmp, "Route: %d hop%s (%dB)", hopCount, hopCount == 1 ? "" : "s", bytesPerHop);
|
||||
display.print(tmp);
|
||||
}
|
||||
y += lineH + 2;
|
||||
y += lineH;
|
||||
|
||||
// SNR (if available — value is SNR×4)
|
||||
if (msg->snr != 0) {
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
int snr_whole = msg->snr / 4;
|
||||
int snr_frac = ((abs(msg->snr) % 4) * 10) / 4;
|
||||
sprintf(tmp, "SNR: %d.%ddB", snr_whole, snr_frac);
|
||||
display.print(tmp);
|
||||
y += lineH;
|
||||
}
|
||||
y += 2;
|
||||
|
||||
// Show each hop resolved against contacts (scrollable)
|
||||
if (plen > 0 && plen != 0xFF) {
|
||||
int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX;
|
||||
if (hopCount > 0 && plen != 0xFF) {
|
||||
int displayHops = hopCount;
|
||||
int footerReserve = 26; // footer + divider
|
||||
int scrollBarW = 4;
|
||||
int maxY = display.height() - footerReserve;
|
||||
@@ -532,28 +555,37 @@ public:
|
||||
if (endHop > displayHops) endHop = displayHops;
|
||||
|
||||
for (int h = startHop; h < endHop && y + lineH <= maxY; h++) {
|
||||
uint8_t hopHash = msg->path[h];
|
||||
int hopOffset = h * bytesPerHop; // byte offset into path[]
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
sprintf(tmp, " %d: ", h + 1);
|
||||
display.print(tmp);
|
||||
|
||||
// Always show hex prefix first
|
||||
// Show hex prefix (1, 2, or 3 bytes)
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
sprintf(tmp, "%02X ", hopHash);
|
||||
if (bytesPerHop == 1) {
|
||||
sprintf(tmp, "%02X ", msg->path[hopOffset]);
|
||||
} else if (bytesPerHop == 2) {
|
||||
sprintf(tmp, "%02X%02X ", msg->path[hopOffset], msg->path[hopOffset + 1]);
|
||||
} else {
|
||||
sprintf(tmp, "%02X%02X%02X ", msg->path[hopOffset], msg->path[hopOffset + 1], msg->path[hopOffset + 2]);
|
||||
}
|
||||
display.print(tmp);
|
||||
|
||||
// Try to resolve name: prefer repeaters, then any contact
|
||||
bool resolved = false;
|
||||
int numContacts = the_mesh.getNumContacts();
|
||||
ContactInfo contact;
|
||||
char filteredName[32];
|
||||
|
||||
// First pass: repeaters only
|
||||
for (uint32_t ci = 0; ci < numContacts && !resolved; ci++) {
|
||||
if (the_mesh.getContactByIdx(ci, contact)) {
|
||||
if (contact.id.pub_key[0] == hopHash && contact.type == ADV_TYPE_REPEATER) {
|
||||
if (memcmp(contact.id.pub_key, &msg->path[hopOffset], bytesPerHop) == 0
|
||||
&& contact.type == ADV_TYPE_REPEATER) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.print(contact.name);
|
||||
display.translateUTF8ToBlocks(filteredName, contact.name, sizeof(filteredName));
|
||||
display.print(filteredName);
|
||||
resolved = true;
|
||||
}
|
||||
}
|
||||
@@ -562,9 +594,10 @@ public:
|
||||
if (!resolved) {
|
||||
for (uint32_t ci = 0; ci < numContacts; ci++) {
|
||||
if (the_mesh.getContactByIdx(ci, contact)) {
|
||||
if (contact.id.pub_key[0] == hopHash) {
|
||||
if (memcmp(contact.id.pub_key, &msg->path[hopOffset], bytesPerHop) == 0) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print(contact.name);
|
||||
display.translateUTF8ToBlocks(filteredName, contact.name, sizeof(filteredName));
|
||||
display.print(filteredName);
|
||||
resolved = true;
|
||||
break;
|
||||
}
|
||||
@@ -608,7 +641,7 @@ public:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("Q:Back");
|
||||
// Show scroll hint if path is scrollable
|
||||
if (msg && msg->path_len > _pathHopsVisible && msg->path_len != 0xFF) {
|
||||
if (msg && (msg->path_len & 63) > _pathHopsVisible && msg->path_len != 0xFF) {
|
||||
const char* scrollHint = "W/S:Scrl";
|
||||
int scrollW = display.getTextWidth(scrollHint);
|
||||
display.setCursor((display.width() - scrollW) / 2, footerY);
|
||||
@@ -723,13 +756,13 @@ public:
|
||||
}
|
||||
} else {
|
||||
if (age < 60) {
|
||||
sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : msg->path_len, age);
|
||||
sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age);
|
||||
} else if (age < 3600) {
|
||||
sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 60);
|
||||
sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 60);
|
||||
} else if (age < 86400) {
|
||||
sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 3600);
|
||||
sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 3600);
|
||||
} else {
|
||||
sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400);
|
||||
sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 86400);
|
||||
}
|
||||
}
|
||||
display.print(tmp);
|
||||
@@ -952,7 +985,7 @@ public:
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
ChannelMessage* msg = getNewestReceivedMsg();
|
||||
if (msg && msg->path_len > 0 && msg->path_len != 0xFF) {
|
||||
int totalHops = msg->path_len < MSG_PATH_MAX ? msg->path_len : MSG_PATH_MAX;
|
||||
int totalHops = msg->path_len & 63;
|
||||
if (_pathScrollPos < totalHops - _pathHopsVisible) {
|
||||
_pathScrollPos++;
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1,205 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/AdvertDataHelpers.h>
|
||||
#include <MeshCore.h>
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
class MyMesh;
|
||||
extern MyMesh the_mesh;
|
||||
|
||||
class DiscoveryScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
int _scrollPos;
|
||||
int _rowsPerPage;
|
||||
|
||||
static char typeChar(uint8_t adv_type) {
|
||||
switch (adv_type) {
|
||||
case ADV_TYPE_CHAT: return 'C';
|
||||
case ADV_TYPE_REPEATER: return 'R';
|
||||
case ADV_TYPE_ROOM: return 'S';
|
||||
case ADV_TYPE_SENSOR: return 'N';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
|
||||
static const char* typeLabel(uint8_t adv_type) {
|
||||
switch (adv_type) {
|
||||
case ADV_TYPE_CHAT: return "Chat";
|
||||
case ADV_TYPE_REPEATER: return "Rptr";
|
||||
case ADV_TYPE_ROOM: return "Room";
|
||||
case ADV_TYPE_SENSOR: return "Sens";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
DiscoveryScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _scrollPos(0), _rowsPerPage(5) {}
|
||||
|
||||
void resetScroll() { _scrollPos = 0; }
|
||||
|
||||
int getSelectedIdx() const { return _scrollPos; }
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
int count = the_mesh.getDiscoveredCount();
|
||||
bool active = the_mesh.isDiscoveryActive();
|
||||
|
||||
// === Header ===
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
|
||||
char hdr[32];
|
||||
if (active) {
|
||||
snprintf(hdr, sizeof(hdr), "Scanning... %d found", count);
|
||||
} else {
|
||||
snprintf(hdr, sizeof(hdr), "Scan done: %d found", count);
|
||||
}
|
||||
display.print(hdr);
|
||||
|
||||
// Divider
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body — discovered node rows ===
|
||||
display.setTextSize(0); // tiny font for compact rows
|
||||
int lineHeight = 9;
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
int y = headerHeight;
|
||||
int rowsDrawn = 0;
|
||||
|
||||
if (count == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 28);
|
||||
display.print(active ? "Listening for adverts..." : "No nodes found");
|
||||
if (!active) {
|
||||
display.setCursor(4, 38);
|
||||
display.print("F: Scan again Q: Back");
|
||||
}
|
||||
} else {
|
||||
// Center visible window around selected item
|
||||
int maxVisible = (maxY - headerHeight) / lineHeight;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
|
||||
count - maxVisible));
|
||||
int endIdx = min(count, startIdx + maxVisible);
|
||||
|
||||
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
|
||||
const DiscoveredNode& node = the_mesh.getDiscovered(i);
|
||||
bool selected = (i == _scrollPos);
|
||||
|
||||
// Highlight selected row
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Prefix: cursor + type
|
||||
char prefix[4];
|
||||
if (selected) {
|
||||
snprintf(prefix, sizeof(prefix), ">%c", typeChar(node.contact.type));
|
||||
} else {
|
||||
snprintf(prefix, sizeof(prefix), " %c", typeChar(node.contact.type));
|
||||
}
|
||||
display.print(prefix);
|
||||
|
||||
// Build right-side info: SNR or hop count + status
|
||||
char rightStr[16];
|
||||
if (node.snr != 0) {
|
||||
// Active discovery result — show SNR in dB (value is ×4 scaled)
|
||||
int snr_db = node.snr / 4;
|
||||
if (node.already_in_contacts) {
|
||||
snprintf(rightStr, sizeof(rightStr), "%ddB [+]", snr_db);
|
||||
} else {
|
||||
snprintf(rightStr, sizeof(rightStr), "%ddB", snr_db);
|
||||
}
|
||||
} else {
|
||||
// Pre-seeded from cache — show hop count
|
||||
if (node.already_in_contacts) {
|
||||
snprintf(rightStr, sizeof(rightStr), "%dh [+]", node.path_len & 63);
|
||||
} else {
|
||||
snprintf(rightStr, sizeof(rightStr), "%dh", node.path_len & 63);
|
||||
}
|
||||
}
|
||||
int rightWidth = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
// Name (truncated with ellipsis)
|
||||
char filteredName[32];
|
||||
display.translateUTF8ToBlocks(filteredName, node.contact.name, sizeof(filteredName));
|
||||
int nameX = display.getTextWidth(prefix) + 2;
|
||||
int nameMaxW = display.width() - nameX - rightWidth - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
// Right-aligned info
|
||||
display.setCursor(display.width() - rightWidth, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineHeight;
|
||||
rowsDrawn++;
|
||||
}
|
||||
_rowsPerPage = (rowsDrawn > 0) ? rowsDrawn : 1;
|
||||
}
|
||||
|
||||
display.setTextSize(1); // restore for footer
|
||||
|
||||
// === Footer ===
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Back");
|
||||
|
||||
const char* mid = "Ent:Add";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
const char* right = "F:Rescan";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
|
||||
// Faster refresh while actively scanning
|
||||
return active ? 1000 : 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
int count = the_mesh.getDiscoveredCount();
|
||||
|
||||
// W - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_scrollPos > 0) {
|
||||
_scrollPos--;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// S - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_scrollPos < count - 1) {
|
||||
_scrollPos++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// F - rescan (handled here as well as in main.cpp for consistency)
|
||||
if (c == 'f') {
|
||||
the_mesh.startDiscovery();
|
||||
_scrollPos = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Enter - handled by main.cpp for alert feedback
|
||||
|
||||
return false; // Q/back and Enter handled by main.cpp
|
||||
}
|
||||
};
|
||||
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
@@ -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]))
|
||||
@@ -197,6 +197,7 @@ private:
|
||||
|
||||
// Timing
|
||||
unsigned long _cmdSentAt;
|
||||
unsigned long _loginTimeoutMs; // computed timeout for login (ms), falls back to ADMIN_TIMEOUT_MS
|
||||
bool _waitingForLogin;
|
||||
|
||||
// Password cache
|
||||
@@ -428,7 +429,7 @@ public:
|
||||
_catSel(0), _cmdSel(0), _scrollOffset(0),
|
||||
_paramLen(0), _pendingCmd(nullptr),
|
||||
_responseLen(0), _responseScroll(0), _responseTotalLines(0),
|
||||
_cmdSentAt(0), _waitingForLogin(false), _pwdCacheCount(0),
|
||||
_cmdSentAt(0), _loginTimeoutMs(ADMIN_TIMEOUT_MS), _waitingForLogin(false), _pwdCacheCount(0),
|
||||
_telemVoltage(0), _telemTempC(0),
|
||||
_telemHasVoltage(false), _telemHasTemp(false), _telemRequested(false) {
|
||||
_password[0] = '\0';
|
||||
@@ -529,19 +530,23 @@ public:
|
||||
}
|
||||
|
||||
void poll() override {
|
||||
if ((_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) &&
|
||||
_cmdSentAt > 0 && (millis() - _cmdSentAt) > ADMIN_TIMEOUT_MS) {
|
||||
if (_pendingCmd && (_pendingCmd->flags & CMDF_EXPECT_TIMEOUT)) {
|
||||
snprintf(_response, sizeof(_response), "Command sent.\nTimeout is expected\n(device is rebooting/updating).");
|
||||
_responseLen = strlen(_response);
|
||||
_responseTotalLines = countLines(_response);
|
||||
_state = STATE_RESPONSE_VIEW;
|
||||
} else {
|
||||
snprintf(_response, sizeof(_response), "Timeout - no response.");
|
||||
_responseLen = strlen(_response);
|
||||
_state = STATE_ERROR;
|
||||
if (_cmdSentAt > 0) {
|
||||
unsigned long elapsed = millis() - _cmdSentAt;
|
||||
unsigned long timeout = (_state == STATE_LOGGING_IN) ? _loginTimeoutMs : ADMIN_TIMEOUT_MS;
|
||||
|
||||
if ((_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) && elapsed > timeout) {
|
||||
if (_pendingCmd && (_pendingCmd->flags & CMDF_EXPECT_TIMEOUT)) {
|
||||
snprintf(_response, sizeof(_response), "Command sent.\nTimeout is expected\n(device is rebooting/updating).");
|
||||
_responseLen = strlen(_response);
|
||||
_responseTotalLines = countLines(_response);
|
||||
_state = STATE_RESPONSE_VIEW;
|
||||
} else {
|
||||
snprintf(_response, sizeof(_response), "Timeout - no response.");
|
||||
_responseLen = strlen(_response);
|
||||
_state = STATE_ERROR;
|
||||
}
|
||||
_task->forceRefresh(); // Immediate redraw on state change
|
||||
}
|
||||
_task->forceRefresh(); // Immediate redraw on state change
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1160,9 +1165,13 @@ private:
|
||||
inline bool RepeaterAdminScreen::doLogin() {
|
||||
if (_contactIdx < 0 || _pwdLen == 0) return false;
|
||||
|
||||
if (the_mesh.uiLoginToRepeater(_contactIdx, _password)) {
|
||||
uint32_t timeout_ms = 0;
|
||||
if (the_mesh.uiLoginToRepeater(_contactIdx, _password, timeout_ms)) {
|
||||
_state = STATE_LOGGING_IN;
|
||||
_cmdSentAt = millis();
|
||||
// Add a 1.5s buffer over the mesh estimate; fall back to ADMIN_TIMEOUT_MS
|
||||
// if the estimate came back zero for any reason.
|
||||
_loginTimeoutMs = (timeout_ms > 0) ? timeout_ms + 1500 : ADMIN_TIMEOUT_MS;
|
||||
_waitingForLogin = true;
|
||||
return true;
|
||||
} else {
|
||||
|
||||
@@ -10,42 +10,39 @@
|
||||
#include "ModemManager.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
#include <WiFi.h>
|
||||
#include <SD.h>
|
||||
#endif
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
class MyMesh;
|
||||
extern MyMesh the_mesh;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Radio presets
|
||||
// Auto-add config bitmask (mirrored from MyMesh.cpp for UI access)
|
||||
// ---------------------------------------------------------------------------
|
||||
struct RadioPreset {
|
||||
const char* name;
|
||||
float freq;
|
||||
float bw;
|
||||
uint8_t sf;
|
||||
uint8_t cr;
|
||||
uint8_t tx_power;
|
||||
};
|
||||
#define AUTO_ADD_OVERWRITE_OLDEST (1 << 0) // 0x01 - overwrite oldest non-favourite when full
|
||||
#define AUTO_ADD_CHAT (1 << 1) // 0x02 - auto-add Chat (Companion) (ADV_TYPE_CHAT)
|
||||
#define AUTO_ADD_REPEATER (1 << 2) // 0x04 - auto-add Repeater (ADV_TYPE_REPEATER)
|
||||
#define AUTO_ADD_ROOM_SERVER (1 << 3) // 0x08 - auto-add Room Server (ADV_TYPE_ROOM)
|
||||
#define AUTO_ADD_SENSOR (1 << 4) // 0x10 - auto-add Sensor (ADV_TYPE_SENSOR)
|
||||
|
||||
static const RadioPreset RADIO_PRESETS[] = {
|
||||
{ "Australia", 915.800f, 250.0f, 10, 5, 22 },
|
||||
{ "Australia (Narrow)", 916.575f, 62.5f, 7, 8, 22 },
|
||||
{ "Australia: SA, WA", 923.125f, 62.5f, 8, 8, 22 },
|
||||
{ "Australia: QLD", 923.125f, 62.5f, 8, 5, 22 },
|
||||
{ "EU/UK (Narrow)", 869.618f, 62.5f, 8, 8, 14 },
|
||||
{ "EU/UK (Long Range)", 869.525f, 250.0f, 11, 5, 14 },
|
||||
{ "EU/UK (Medium Range)", 869.525f, 250.0f, 10, 5, 14 },
|
||||
{ "Czech Republic (Narrow)",869.432f, 62.5f, 7, 5, 14 },
|
||||
{ "EU 433 (Long Range)", 433.650f, 250.0f, 11, 5, 14 },
|
||||
{ "New Zealand", 917.375f, 250.0f, 11, 5, 22 },
|
||||
{ "New Zealand (Narrow)", 917.375f, 62.5f, 7, 5, 22 },
|
||||
{ "Portugal 433", 433.375f, 62.5f, 9, 6, 14 },
|
||||
{ "Portugal 868", 869.618f, 62.5f, 7, 6, 14 },
|
||||
{ "Switzerland", 869.618f, 62.5f, 8, 8, 14 },
|
||||
{ "USA/Canada (Recommended)",910.525f, 62.5f, 7, 5, 22 },
|
||||
{ "Vietnam", 920.250f, 250.0f, 11, 5, 22 },
|
||||
};
|
||||
#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]))
|
||||
// All type bits combined (excludes overwrite flag)
|
||||
#define AUTO_ADD_ALL_TYPES (AUTO_ADD_CHAT | AUTO_ADD_REPEATER | \
|
||||
AUTO_ADD_ROOM_SERVER | AUTO_ADD_SENSOR)
|
||||
|
||||
// Contact mode indices for picker
|
||||
#define CONTACT_MODE_AUTO_ALL 0 // Add all contacts automatically
|
||||
#define CONTACT_MODE_CUSTOM 1 // Per-type toggles
|
||||
#define CONTACT_MODE_MANUAL 2 // No auto-add, companion app only
|
||||
#define CONTACT_MODE_COUNT 3
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Radio presets (shared with Serial CLI in MyMesh.cpp)
|
||||
// ---------------------------------------------------------------------------
|
||||
#include "RadioPresets.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings row types
|
||||
@@ -60,9 +57,22 @@ enum SettingsRowType : uint8_t {
|
||||
ROW_TX_POWER, // TX power (1-20 dBm)
|
||||
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
|
||||
ROW_MSG_NOTIFY, // Keyboard flash on new msg toggle
|
||||
ROW_PATH_HASH_SIZE, // Path hash size (1, 2, or 3 bytes per hop)
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
ROW_WIFI_SETUP, // WiFi SSID/password configuration
|
||||
ROW_WIFI_TOGGLE, // WiFi radio on/off toggle
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
ROW_MODEM_TOGGLE, // 4G modem enable/disable toggle (4G builds only)
|
||||
// ROW_RINGTONE, // Incoming call ringtone toggle (4G builds only)
|
||||
#endif
|
||||
ROW_CONTACT_HEADER, // "--- Contacts ---" separator
|
||||
ROW_CONTACT_MODE, // Contact auto-add mode picker (Auto All / Custom / Manual)
|
||||
ROW_AUTOADD_CHAT, // Toggle: auto-add Chat clients
|
||||
ROW_AUTOADD_REPEATER,// Toggle: auto-add Repeaters
|
||||
ROW_AUTOADD_ROOM, // Toggle: auto-add Room Servers
|
||||
ROW_AUTOADD_SENSOR, // Toggle: auto-add Sensors
|
||||
ROW_AUTOADD_OVERWRITE, // Toggle: overwrite oldest non-favourite when full
|
||||
ROW_CH_HEADER, // "--- Channels ---" separator
|
||||
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
|
||||
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
|
||||
@@ -82,16 +92,23 @@ enum SettingsRowType : uint8_t {
|
||||
enum EditMode : uint8_t {
|
||||
EDIT_NONE, // Just browsing
|
||||
EDIT_TEXT, // Typing into a text buffer (name, channel name)
|
||||
EDIT_PICKER, // A/D cycles options (radio preset)
|
||||
EDIT_PICKER, // A/D cycles options (radio preset, contact mode)
|
||||
EDIT_NUMBER, // W/S adjusts value (freq, BW, SF, CR, TX, UTC)
|
||||
EDIT_CONFIRM, // Confirmation dialog (delete channel, apply radio)
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
EDIT_WIFI, // WiFi scan/select/password flow
|
||||
#endif
|
||||
};
|
||||
|
||||
// Max rows in the settings list
|
||||
#ifdef HAS_4G_MODEM
|
||||
#define SETTINGS_MAX_ROWS 46 // Extra rows for IMEI, Carrier, APN
|
||||
// Max rows in the settings list (increased for contact sub-toggles + WiFi)
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WIFI_COMPANION)
|
||||
#define SETTINGS_MAX_ROWS 56 // Extra rows for IMEI, Carrier, APN, contacts, WiFi
|
||||
#elif defined(HAS_4G_MODEM)
|
||||
#define SETTINGS_MAX_ROWS 54 // Extra rows for IMEI, Carrier, APN + contacts
|
||||
#elif defined(MECK_WIFI_COMPANION)
|
||||
#define SETTINGS_MAX_ROWS 50 // Extra rows for contacts + WiFi
|
||||
#else
|
||||
#define SETTINGS_MAX_ROWS 40
|
||||
#define SETTINGS_MAX_ROWS 48 // Contacts section
|
||||
#endif
|
||||
#define SETTINGS_TEXT_BUF 33 // 32 chars + null
|
||||
|
||||
@@ -117,7 +134,7 @@ private:
|
||||
EditMode _editMode;
|
||||
char _editBuf[SETTINGS_TEXT_BUF];
|
||||
int _editPos;
|
||||
int _editPickerIdx; // for preset picker
|
||||
int _editPickerIdx; // for preset picker / contact mode picker
|
||||
float _editFloat; // for freq/BW editing
|
||||
int _editInt; // for SF/CR/TX/UTC editing
|
||||
int _confirmAction; // 0=none, 1=delete channel, 2=apply radio
|
||||
@@ -133,6 +150,75 @@ private:
|
||||
bool _modemEnabled;
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
// WiFi setup sub-screen state
|
||||
enum WifiSetupPhase : uint8_t {
|
||||
WIFI_PHASE_IDLE,
|
||||
WIFI_PHASE_SCANNING,
|
||||
WIFI_PHASE_SELECT, // W/S to pick SSID, Enter to select
|
||||
WIFI_PHASE_PASSWORD, // Type password, Enter to connect
|
||||
WIFI_PHASE_CONNECTING,
|
||||
};
|
||||
WifiSetupPhase _wifiPhase;
|
||||
String _wifiSSIDs[10];
|
||||
int _wifiSSIDCount;
|
||||
int _wifiSSIDSelected;
|
||||
char _wifiPassBuf[64];
|
||||
int _wifiPassLen;
|
||||
unsigned long _wifiFormLastChar; // For brief password reveal
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contact mode helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Determine current contact mode from prefs
|
||||
int getContactMode() const {
|
||||
if ((_prefs->manual_add_contacts & 1) == 0) {
|
||||
return CONTACT_MODE_AUTO_ALL;
|
||||
}
|
||||
// manual_add_contacts bit 0 is set — check if any type bits are enabled
|
||||
if ((_prefs->autoadd_config & AUTO_ADD_ALL_TYPES) != 0) {
|
||||
return CONTACT_MODE_CUSTOM;
|
||||
}
|
||||
return CONTACT_MODE_MANUAL;
|
||||
}
|
||||
|
||||
// Get display label for a contact mode
|
||||
static const char* contactModeLabel(int mode) {
|
||||
switch (mode) {
|
||||
case CONTACT_MODE_AUTO_ALL: return "Auto All";
|
||||
case CONTACT_MODE_CUSTOM: return "Custom";
|
||||
case CONTACT_MODE_MANUAL: return "Manual Only";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a contact mode selection from picker
|
||||
void applyContactMode(int mode) {
|
||||
switch (mode) {
|
||||
case CONTACT_MODE_AUTO_ALL:
|
||||
_prefs->manual_add_contacts &= ~1; // clear bit 0 → auto all
|
||||
break;
|
||||
case CONTACT_MODE_CUSTOM:
|
||||
_prefs->manual_add_contacts |= 1; // set bit 0 → selective
|
||||
// If no type bits are set, default to all types enabled
|
||||
if ((_prefs->autoadd_config & AUTO_ADD_ALL_TYPES) == 0) {
|
||||
_prefs->autoadd_config |= AUTO_ADD_ALL_TYPES;
|
||||
}
|
||||
break;
|
||||
case CONTACT_MODE_MANUAL:
|
||||
_prefs->manual_add_contacts |= 1; // set bit 0 → selective
|
||||
_prefs->autoadd_config &= ~AUTO_ADD_ALL_TYPES; // clear all type bits
|
||||
// Note: keeps AUTO_ADD_OVERWRITE_OLDEST bit unchanged
|
||||
break;
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
rebuildRows(); // show/hide sub-toggles
|
||||
Serial.printf("Settings: Contact mode = %s (manual=%d, autoadd=0x%02X)\n",
|
||||
contactModeLabel(mode), _prefs->manual_add_contacts, _prefs->autoadd_config);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row table management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -149,9 +235,30 @@ private:
|
||||
addRow(ROW_TX_POWER);
|
||||
addRow(ROW_UTC_OFFSET);
|
||||
addRow(ROW_MSG_NOTIFY);
|
||||
addRow(ROW_PATH_HASH_SIZE);
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
addRow(ROW_WIFI_SETUP);
|
||||
addRow(ROW_WIFI_TOGGLE);
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
addRow(ROW_MODEM_TOGGLE);
|
||||
// addRow(ROW_RINGTONE);
|
||||
#endif
|
||||
|
||||
// --- Contacts section ---
|
||||
addRow(ROW_CONTACT_HEADER);
|
||||
addRow(ROW_CONTACT_MODE);
|
||||
|
||||
// Show per-type sub-toggles only in Custom mode
|
||||
if (getContactMode() == CONTACT_MODE_CUSTOM) {
|
||||
addRow(ROW_AUTOADD_CHAT);
|
||||
addRow(ROW_AUTOADD_REPEATER);
|
||||
addRow(ROW_AUTOADD_ROOM);
|
||||
addRow(ROW_AUTOADD_SENSOR);
|
||||
addRow(ROW_AUTOADD_OVERWRITE);
|
||||
}
|
||||
|
||||
// --- Channels section ---
|
||||
addRow(ROW_CH_HEADER);
|
||||
|
||||
// Enumerate current channels
|
||||
@@ -192,7 +299,7 @@ private:
|
||||
bool isSelectable(int idx) const {
|
||||
if (idx < 0 || idx >= _numRows) return false;
|
||||
SettingsRowType t = _rows[idx].type;
|
||||
return t != ROW_CH_HEADER && t != ROW_INFO_HEADER
|
||||
return t != ROW_CH_HEADER && t != ROW_INFO_HEADER && t != ROW_CONTACT_HEADER
|
||||
#ifdef HAS_4G_MODEM
|
||||
&& t != ROW_IMEI && t != ROW_OPERATOR_INFO
|
||||
#endif
|
||||
@@ -326,6 +433,14 @@ public:
|
||||
#ifdef HAS_4G_MODEM
|
||||
_modemEnabled = ModemManager::loadEnabledConfig();
|
||||
#endif
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
_wifiPhase = WIFI_PHASE_IDLE;
|
||||
_wifiSSIDCount = 0;
|
||||
_wifiSSIDSelected = 0;
|
||||
_wifiPassLen = 0;
|
||||
memset(_wifiPassBuf, 0, sizeof(_wifiPassBuf));
|
||||
_wifiFormLastChar = 0;
|
||||
#endif
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
@@ -341,6 +456,64 @@ public:
|
||||
bool isEditing() const { return _editMode != EDIT_NONE; }
|
||||
bool hasRadioChanges() const { return _radioChanged; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WiFi scan helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
// Perform a blocking WiFi scan. Populates _wifiSSIDs/_wifiSSIDCount and
|
||||
// advances _wifiPhase to SELECT (even on zero results, so the overlay
|
||||
// stays visible and the user can rescan with 'r').
|
||||
void performWifiScan() {
|
||||
_wifiPhase = WIFI_PHASE_SCANNING;
|
||||
_wifiSSIDCount = 0;
|
||||
_wifiSSIDSelected = 0;
|
||||
|
||||
// Disconnect any active WiFi connection first — the ESP32 driver
|
||||
// returns -2 (WIFI_SCAN_FAILED) if the radio is busy with an
|
||||
// existing connection or the TCP companion server socket.
|
||||
WiFi.disconnect(false); // false = don't turn off WiFi radio
|
||||
delay(100); // let the driver settle
|
||||
WiFi.mode(WIFI_STA);
|
||||
|
||||
// 500ms per-channel dwell helps detect phone hotspots that are slow
|
||||
// to respond to probe requests (default 300ms often misses them).
|
||||
int n = WiFi.scanNetworks(false, false, false, 500);
|
||||
Serial.printf("Settings: WiFi scan found %d networks\n", n);
|
||||
|
||||
if (n > 0) {
|
||||
_wifiSSIDCount = min(n, 10);
|
||||
for (int si = 0; si < _wifiSSIDCount; si++) {
|
||||
_wifiSSIDs[si] = WiFi.SSID(si);
|
||||
Serial.printf(" [%d] %s (RSSI %d)\n", si,
|
||||
_wifiSSIDs[si].c_str(), WiFi.RSSI(si));
|
||||
}
|
||||
} else if (n < 0) {
|
||||
Serial.printf("Settings: WiFi scan error %d\n", n);
|
||||
}
|
||||
WiFi.scanDelete();
|
||||
_wifiPhase = WIFI_PHASE_SELECT; // always show overlay (even if 0)
|
||||
}
|
||||
|
||||
// After WiFi setup exits (connect success or user quit), try to
|
||||
// reconnect to saved credentials so the companion TCP server works.
|
||||
void wifiReconnectSaved() {
|
||||
File f = SD.open("/web/wifi.cfg", FILE_READ);
|
||||
if (f) {
|
||||
String ssid = f.readStringUntil('\n'); ssid.trim();
|
||||
String pass = f.readStringUntil('\n'); pass.trim();
|
||||
f.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
if (ssid.length() > 0) {
|
||||
Serial.printf("Settings: Reconnecting to saved WiFi '%s'\n", ssid.c_str());
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
}
|
||||
} else {
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit mode starters
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -510,14 +683,93 @@ public:
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_PATH_HASH_SIZE:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "Path Hash Size: %d-byte <W/S>", _editInt);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Path Hash Size: %d-byte", _prefs->path_hash_mode + 1);
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
case ROW_WIFI_SETUP:
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
snprintf(tmp, sizeof(tmp), "WiFi: %s", WiFi.SSID().c_str());
|
||||
} else {
|
||||
strcpy(tmp, "WiFi: (not connected)");
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
case ROW_WIFI_TOGGLE:
|
||||
snprintf(tmp, sizeof(tmp), "WiFi Radio: %s",
|
||||
(WiFi.getMode() != WIFI_OFF) ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
case ROW_MODEM_TOGGLE:
|
||||
snprintf(tmp, sizeof(tmp), "4G Modem: %s",
|
||||
_modemEnabled ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
//case ROW_RINGTONE:
|
||||
// snprintf(tmp, sizeof(tmp), "Incoming Call Ring: %s",
|
||||
// _prefs->ringtone_enabled ? "ON" : "OFF");
|
||||
// display.print(tmp);
|
||||
// break;
|
||||
#endif
|
||||
|
||||
// --- Contacts section ---
|
||||
case ROW_CONTACT_HEADER:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("--- Contacts ---");
|
||||
break;
|
||||
|
||||
case ROW_CONTACT_MODE:
|
||||
if (editing && _editMode == EDIT_PICKER) {
|
||||
snprintf(tmp, sizeof(tmp), "< Add Mode: %s >",
|
||||
contactModeLabel(_editPickerIdx));
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Add Mode: %s",
|
||||
contactModeLabel(getContactMode()));
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_AUTOADD_CHAT:
|
||||
snprintf(tmp, sizeof(tmp), " Chat: %s",
|
||||
(_prefs->autoadd_config & AUTO_ADD_CHAT) ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_AUTOADD_REPEATER:
|
||||
snprintf(tmp, sizeof(tmp), " Repeater: %s",
|
||||
(_prefs->autoadd_config & AUTO_ADD_REPEATER) ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_AUTOADD_ROOM:
|
||||
snprintf(tmp, sizeof(tmp), " Room Server: %s",
|
||||
(_prefs->autoadd_config & AUTO_ADD_ROOM_SERVER) ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_AUTOADD_SENSOR:
|
||||
snprintf(tmp, sizeof(tmp), " Sensor: %s",
|
||||
(_prefs->autoadd_config & AUTO_ADD_SENSOR) ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_AUTOADD_OVERWRITE:
|
||||
snprintf(tmp, sizeof(tmp), " Overwrite Oldest: %s",
|
||||
(_prefs->autoadd_config & AUTO_ADD_OVERWRITE_OLDEST) ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
// --- Channels section ---
|
||||
case ROW_CH_HEADER:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("--- Channels ---");
|
||||
@@ -652,6 +904,88 @@ public:
|
||||
display.setTextSize(1);
|
||||
}
|
||||
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
// === WiFi setup overlay ===
|
||||
if (_editMode == EDIT_WIFI) {
|
||||
int bx = 2, by = 14, bw = display.width() - 4;
|
||||
int bh = display.height() - 28;
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(bx, by, bw, bh);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
display.setTextSize(0);
|
||||
int wy = by + 4;
|
||||
|
||||
if (_wifiPhase == WIFI_PHASE_SCANNING) {
|
||||
display.drawTextCentered(display.width() / 2, wy, "Scanning for networks...");
|
||||
|
||||
} else if (_wifiPhase == WIFI_PHASE_SELECT) {
|
||||
if (_wifiSSIDCount == 0) {
|
||||
// No networks found — show message with rescan prompt
|
||||
display.setCursor(bx + 4, wy);
|
||||
display.print("No networks found.");
|
||||
wy += 12;
|
||||
display.setCursor(bx + 4, wy);
|
||||
display.print("Check your hotspot is on");
|
||||
wy += 8;
|
||||
display.setCursor(bx + 4, wy);
|
||||
display.print("and set to 2.4GHz.");
|
||||
wy += 12;
|
||||
display.setCursor(bx + 4, wy);
|
||||
display.print("Press R or Enter to rescan.");
|
||||
} else {
|
||||
display.setCursor(bx + 4, wy);
|
||||
display.print("Select network:");
|
||||
wy += 10;
|
||||
for (int wi = 0; wi < _wifiSSIDCount && wy < by + bh - 16; wi++) {
|
||||
bool sel = (wi == _wifiSSIDSelected);
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(bx + 2, wy + 5, bw - 4, 8);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(bx + 4, wy);
|
||||
char ssidLine[40];
|
||||
if (sel) {
|
||||
snprintf(ssidLine, sizeof(ssidLine), "> %.33s", _wifiSSIDs[wi].c_str());
|
||||
} else {
|
||||
snprintf(ssidLine, sizeof(ssidLine), " %.33s", _wifiSSIDs[wi].c_str());
|
||||
}
|
||||
display.print(ssidLine);
|
||||
wy += 8;
|
||||
}
|
||||
}
|
||||
|
||||
} else if (_wifiPhase == WIFI_PHASE_PASSWORD) {
|
||||
display.setCursor(bx + 4, wy);
|
||||
snprintf(tmp, sizeof(tmp), "SSID: %s", _wifiSSIDs[_wifiSSIDSelected].c_str());
|
||||
display.print(tmp);
|
||||
wy += 12;
|
||||
display.setCursor(bx + 4, wy);
|
||||
display.print("Password:");
|
||||
wy += 10;
|
||||
display.setCursor(bx + 4, wy);
|
||||
// Masked password with brief reveal of last char
|
||||
char passBuf[66];
|
||||
for (int pi = 0; pi < _wifiPassLen; pi++) passBuf[pi] = '*';
|
||||
if (_wifiPassLen > 0 && _wifiFormLastChar > 0 &&
|
||||
(millis() - _wifiFormLastChar) < 800) {
|
||||
passBuf[_wifiPassLen - 1] = _wifiPassBuf[_wifiPassLen - 1];
|
||||
}
|
||||
passBuf[_wifiPassLen] = '_';
|
||||
passBuf[_wifiPassLen + 1] = '\0';
|
||||
display.print(passBuf);
|
||||
|
||||
} else if (_wifiPhase == WIFI_PHASE_CONNECTING) {
|
||||
display.drawTextCentered(display.width() / 2, wy + 10, "Connecting...");
|
||||
}
|
||||
display.setTextSize(1);
|
||||
}
|
||||
#endif
|
||||
|
||||
// === Footer ===
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
@@ -660,6 +994,20 @@ public:
|
||||
|
||||
if (_editMode == EDIT_TEXT) {
|
||||
display.print("Type, Enter:Ok Q:Cancel");
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
} else if (_editMode == EDIT_WIFI) {
|
||||
if (_wifiPhase == WIFI_PHASE_SELECT) {
|
||||
if (_wifiSSIDCount == 0) {
|
||||
display.print("R/Enter:Rescan Q:Back");
|
||||
} else {
|
||||
display.print("W/S:Pick Enter:Sel R:Rescan");
|
||||
}
|
||||
} else if (_wifiPhase == WIFI_PHASE_PASSWORD) {
|
||||
display.print("Type, Enter:Connect Q:Bck");
|
||||
} else {
|
||||
display.print("Please wait...");
|
||||
}
|
||||
#endif
|
||||
} else if (_editMode == EDIT_PICKER) {
|
||||
display.print("A/D:Choose Enter:Ok");
|
||||
} else if (_editMode == EDIT_NUMBER) {
|
||||
@@ -705,6 +1053,113 @@ public:
|
||||
return true; // consume all keys in confirm mode
|
||||
}
|
||||
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
// --- WiFi setup flow ---
|
||||
if (_editMode == EDIT_WIFI) {
|
||||
if (_wifiPhase == WIFI_PHASE_SELECT) {
|
||||
if (c == 'w' || c == 'W') {
|
||||
if (_wifiSSIDSelected > 0) _wifiSSIDSelected--;
|
||||
return true;
|
||||
}
|
||||
if (c == 's' || c == 'S') {
|
||||
if (_wifiSSIDSelected < _wifiSSIDCount - 1) _wifiSSIDSelected++;
|
||||
return true;
|
||||
}
|
||||
if (c == 'r' || c == 'R') {
|
||||
// Rescan — lets user toggle hotspot on then retry
|
||||
performWifiScan();
|
||||
return true;
|
||||
}
|
||||
if (c == '\r' || c == 13) {
|
||||
if (_wifiSSIDCount == 0) {
|
||||
// No networks — Enter rescans (same as R)
|
||||
performWifiScan();
|
||||
return true;
|
||||
}
|
||||
// Selected an SSID — move to password entry
|
||||
_wifiPhase = WIFI_PHASE_PASSWORD;
|
||||
_wifiPassLen = 0;
|
||||
memset(_wifiPassBuf, 0, sizeof(_wifiPassBuf));
|
||||
_wifiFormLastChar = 0;
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'Q') {
|
||||
_editMode = EDIT_NONE;
|
||||
_wifiPhase = WIFI_PHASE_IDLE;
|
||||
if (_onboarding) _onboarding = false; // Skip WiFi, finish onboarding
|
||||
wifiReconnectSaved(); // Restore connection after scan disconnect
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_wifiPhase == WIFI_PHASE_PASSWORD) {
|
||||
if (c == '\r' || c == 13) {
|
||||
// Attempt connection
|
||||
_wifiPassBuf[_wifiPassLen] = '\0';
|
||||
_wifiPhase = WIFI_PHASE_CONNECTING;
|
||||
|
||||
// Save credentials to SD first (so web reader can reuse them)
|
||||
if (SD.exists("/web") || SD.mkdir("/web")) {
|
||||
File f = SD.open("/web/wifi.cfg", FILE_WRITE);
|
||||
if (f) {
|
||||
f.println(_wifiSSIDs[_wifiSSIDSelected]);
|
||||
f.println(_wifiPassBuf);
|
||||
f.close();
|
||||
}
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
}
|
||||
|
||||
WiFi.disconnect(false);
|
||||
WiFi.begin(_wifiSSIDs[_wifiSSIDSelected].c_str(), _wifiPassBuf);
|
||||
|
||||
// Brief blocking wait — fine for e-ink (screen won't update during this anyway)
|
||||
unsigned long timeout = millis() + 8000;
|
||||
while (WiFi.status() != WL_CONNECTED && millis() < timeout) {
|
||||
delay(100);
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("Settings: WiFi connected to %s, IP: %s\n",
|
||||
_wifiSSIDs[_wifiSSIDSelected].c_str(),
|
||||
WiFi.localIP().toString().c_str());
|
||||
_editMode = EDIT_NONE;
|
||||
_wifiPhase = WIFI_PHASE_IDLE;
|
||||
if (_onboarding) _onboarding = false; // Finish onboarding
|
||||
} else {
|
||||
Serial.println("Settings: WiFi connection failed");
|
||||
// Go back to SSID selection so user can retry
|
||||
_wifiPhase = WIFI_PHASE_SELECT;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Back to SSID selection
|
||||
_wifiPhase = WIFI_PHASE_SELECT;
|
||||
return true;
|
||||
}
|
||||
if (c == '\b') {
|
||||
if (_wifiPassLen > 0) {
|
||||
_wifiPassLen--;
|
||||
_wifiPassBuf[_wifiPassLen] = '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Printable character
|
||||
if (c >= 32 && c < 127 && _wifiPassLen < 63) {
|
||||
_wifiPassBuf[_wifiPassLen++] = c;
|
||||
_wifiPassBuf[_wifiPassLen] = '\0';
|
||||
_wifiFormLastChar = millis();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Scanning and connecting phases consume all keys
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
// --- Text editing mode ---
|
||||
if (_editMode == EDIT_TEXT) {
|
||||
if (c == '\r' || c == 13) {
|
||||
@@ -775,34 +1230,65 @@ public:
|
||||
return true; // consume all keys in text edit
|
||||
}
|
||||
|
||||
// --- Picker mode (radio preset) ---
|
||||
// --- Picker mode (radio preset or contact mode) ---
|
||||
if (_editMode == EDIT_PICKER) {
|
||||
SettingsRowType type = _rows[_cursor].type;
|
||||
|
||||
if (c == 'a' || c == 'A') {
|
||||
_editPickerIdx--;
|
||||
if (_editPickerIdx < 0) _editPickerIdx = (int)NUM_RADIO_PRESETS - 1;
|
||||
if (type == ROW_CONTACT_MODE) {
|
||||
_editPickerIdx--;
|
||||
if (_editPickerIdx < 0) _editPickerIdx = CONTACT_MODE_COUNT - 1;
|
||||
} else {
|
||||
// Radio preset
|
||||
_editPickerIdx--;
|
||||
if (_editPickerIdx < 0) _editPickerIdx = (int)NUM_RADIO_PRESETS - 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == 'd' || c == 'D') {
|
||||
_editPickerIdx++;
|
||||
if (_editPickerIdx >= (int)NUM_RADIO_PRESETS) _editPickerIdx = 0;
|
||||
if (type == ROW_CONTACT_MODE) {
|
||||
_editPickerIdx++;
|
||||
if (_editPickerIdx >= CONTACT_MODE_COUNT) _editPickerIdx = 0;
|
||||
} else {
|
||||
// Radio preset
|
||||
_editPickerIdx++;
|
||||
if (_editPickerIdx >= (int)NUM_RADIO_PRESETS) _editPickerIdx = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == '\r' || c == 13) {
|
||||
// Apply preset
|
||||
if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) {
|
||||
const RadioPreset& p = RADIO_PRESETS[_editPickerIdx];
|
||||
_prefs->freq = p.freq;
|
||||
_prefs->bw = p.bw;
|
||||
_prefs->sf = p.sf;
|
||||
_prefs->cr = p.cr;
|
||||
_prefs->tx_power_dbm = p.tx_power;
|
||||
_radioChanged = true;
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
if (_onboarding) {
|
||||
// Apply and finish onboarding
|
||||
applyRadioParams();
|
||||
_onboarding = false;
|
||||
if (type == ROW_CONTACT_MODE) {
|
||||
applyContactMode(_editPickerIdx);
|
||||
_editMode = EDIT_NONE;
|
||||
} else {
|
||||
// Apply radio preset
|
||||
if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) {
|
||||
const RadioPreset& p = RADIO_PRESETS[_editPickerIdx];
|
||||
_prefs->freq = p.freq;
|
||||
_prefs->bw = p.bw;
|
||||
_prefs->sf = p.sf;
|
||||
_prefs->cr = p.cr;
|
||||
_prefs->tx_power_dbm = p.tx_power;
|
||||
_radioChanged = true;
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
if (_onboarding) {
|
||||
applyRadioParams();
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
// Move to WiFi setup before finishing onboarding
|
||||
for (int r = 0; r < _numRows; r++) {
|
||||
if (_rows[r].type == ROW_WIFI_SETUP) {
|
||||
_cursor = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Auto-launch the WiFi scan
|
||||
_editMode = EDIT_WIFI;
|
||||
performWifiScan();
|
||||
#else
|
||||
_onboarding = false;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -831,6 +1317,7 @@ public:
|
||||
case ROW_CR: if (_editInt < 8) _editInt++; break;
|
||||
case ROW_TX_POWER: if (_editInt < MAX_LORA_TX_POWER) _editInt++; break;
|
||||
case ROW_UTC_OFFSET: if (_editInt < 14) _editInt++; break;
|
||||
case ROW_PATH_HASH_SIZE: if (_editInt < 3) _editInt++; break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
@@ -847,6 +1334,7 @@ public:
|
||||
case ROW_CR: if (_editInt > 5) _editInt--; break;
|
||||
case ROW_TX_POWER: if (_editInt > 1) _editInt--; break;
|
||||
case ROW_UTC_OFFSET: if (_editInt > -12) _editInt--; break;
|
||||
case ROW_PATH_HASH_SIZE: if (_editInt > 1) _editInt--; break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
@@ -874,6 +1362,10 @@ public:
|
||||
_prefs->utc_offset_hours = (int8_t)constrain(_editInt, -12, 14);
|
||||
the_mesh.savePrefs();
|
||||
break;
|
||||
case ROW_PATH_HASH_SIZE:
|
||||
_prefs->path_hash_mode = (uint8_t)constrain(_editInt - 1, 0, 2); // display 1-3, store 0-2
|
||||
the_mesh.savePrefs();
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
@@ -944,6 +1436,52 @@ public:
|
||||
Serial.printf("Settings: Msg flash notify = %s\n",
|
||||
_prefs->kb_flash_notify ? "ON" : "OFF");
|
||||
break;
|
||||
case ROW_PATH_HASH_SIZE:
|
||||
startEditInt(_prefs->path_hash_mode + 1); // display as 1-3
|
||||
break;
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
case ROW_WIFI_SETUP: {
|
||||
// Launch WiFi scan → select → password → connect flow
|
||||
_editMode = EDIT_WIFI;
|
||||
performWifiScan();
|
||||
break;
|
||||
}
|
||||
case ROW_WIFI_TOGGLE:
|
||||
if (WiFi.getMode() != WIFI_OFF) {
|
||||
// Turn WiFi OFF
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
Serial.println("Settings: WiFi radio OFF");
|
||||
} else {
|
||||
// Turn WiFi ON — reconnect using saved credentials
|
||||
WiFi.mode(WIFI_STA);
|
||||
if (SD.exists("/web/wifi.cfg")) {
|
||||
File f = SD.open("/web/wifi.cfg", FILE_READ);
|
||||
if (f) {
|
||||
String ssid = f.readStringUntil('\n'); ssid.trim();
|
||||
String pass = f.readStringUntil('\n'); pass.trim();
|
||||
f.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
if (ssid.length() > 0) {
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
unsigned long timeout = millis() + 8000;
|
||||
while (WiFi.status() != WL_CONNECTED && millis() < timeout) {
|
||||
delay(100);
|
||||
}
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("Settings: WiFi ON, connected to %s\n", ssid.c_str());
|
||||
} else {
|
||||
Serial.println("Settings: WiFi ON, but connection failed");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
}
|
||||
}
|
||||
Serial.println("Settings: WiFi radio ON");
|
||||
}
|
||||
break;
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
case ROW_MODEM_TOGGLE:
|
||||
_modemEnabled = !_modemEnabled;
|
||||
@@ -956,6 +1494,13 @@ public:
|
||||
Serial.println("Settings: 4G modem DISABLED (shutdown)");
|
||||
}
|
||||
break;
|
||||
// case ROW_RINGTONE:
|
||||
// _prefs->ringtone_enabled = _prefs->ringtone_enabled ? 0 : 1;
|
||||
// modemManager.setRingtoneEnabled(_prefs->ringtone_enabled);
|
||||
// the_mesh.savePrefs();
|
||||
// Serial.printf("Settings: Ringtone = %s\n",
|
||||
// _prefs->ringtone_enabled ? "ON" : "OFF");
|
||||
// break;
|
||||
case ROW_APN: {
|
||||
// Start text editing with current APN as initial value
|
||||
const char* currentApn = modemManager.getAPN();
|
||||
@@ -963,6 +1508,44 @@ public:
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
|
||||
// --- Contact mode picker ---
|
||||
case ROW_CONTACT_MODE:
|
||||
startEditPicker(getContactMode());
|
||||
break;
|
||||
|
||||
// --- Contact sub-toggles (flip bit and save) ---
|
||||
case ROW_AUTOADD_CHAT:
|
||||
_prefs->autoadd_config ^= AUTO_ADD_CHAT;
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Auto-add Chat = %s\n",
|
||||
(_prefs->autoadd_config & AUTO_ADD_CHAT) ? "ON" : "OFF");
|
||||
break;
|
||||
case ROW_AUTOADD_REPEATER:
|
||||
_prefs->autoadd_config ^= AUTO_ADD_REPEATER;
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Auto-add Repeater = %s\n",
|
||||
(_prefs->autoadd_config & AUTO_ADD_REPEATER) ? "ON" : "OFF");
|
||||
break;
|
||||
case ROW_AUTOADD_ROOM:
|
||||
_prefs->autoadd_config ^= AUTO_ADD_ROOM_SERVER;
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Auto-add Room = %s\n",
|
||||
(_prefs->autoadd_config & AUTO_ADD_ROOM_SERVER) ? "ON" : "OFF");
|
||||
break;
|
||||
case ROW_AUTOADD_SENSOR:
|
||||
_prefs->autoadd_config ^= AUTO_ADD_SENSOR;
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Auto-add Sensor = %s\n",
|
||||
(_prefs->autoadd_config & AUTO_ADD_SENSOR) ? "ON" : "OFF");
|
||||
break;
|
||||
case ROW_AUTOADD_OVERWRITE:
|
||||
_prefs->autoadd_config ^= AUTO_ADD_OVERWRITE_OLDEST;
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Overwrite oldest = %s\n",
|
||||
(_prefs->autoadd_config & AUTO_ADD_OVERWRITE_OLDEST) ? "ON" : "OFF");
|
||||
break;
|
||||
|
||||
case ROW_ADD_CHANNEL:
|
||||
startEditText("");
|
||||
break;
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "DiscoveryScreen.h"
|
||||
#include "MapScreen.h"
|
||||
#include "target.h"
|
||||
#include "GPSDutyCycle.h"
|
||||
#ifdef WIFI_SSID
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
|
||||
@@ -97,6 +98,8 @@ class HomeScreen : public UIScreen {
|
||||
RADIO,
|
||||
#ifdef BLE_PIN_CODE
|
||||
BLUETOOTH,
|
||||
#elif defined(MECK_WIFI_COMPANION)
|
||||
WIFI_STATUS,
|
||||
#endif
|
||||
ADVERT,
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
@@ -301,14 +304,16 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 18;
|
||||
|
||||
#ifdef WIFI_SSID
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 12;
|
||||
if (ip != IPAddress(0,0,0,0)) {
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 12;
|
||||
}
|
||||
#endif
|
||||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID)
|
||||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
@@ -335,16 +340,20 @@ public:
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
y += 10;
|
||||
#ifdef HAS_4G_MODEM
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] Phone ");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [P] Audiobooks");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
#endif
|
||||
#ifdef MECK_WEB_READER
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
|
||||
y += 10;
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
|
||||
#elif defined(HAS_4G_MODEM)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone ");
|
||||
#elif defined(MECK_AUDIO_VARIANT) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser ");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks ");
|
||||
#elif defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||
#else
|
||||
y -= 10; // reclaim the row for standalone
|
||||
#endif
|
||||
y += 14;
|
||||
|
||||
@@ -415,6 +424,44 @@ public:
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 72, "toggle: " PRESS_LABEL);
|
||||
#endif
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
} else if (_page == HomePage::WIFI_STATUS) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 18, "WiFi Companion");
|
||||
|
||||
int wy = 36;
|
||||
display.setTextSize(0);
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
snprintf(tmp, sizeof(tmp), "SSID: %s", WiFi.SSID().c_str());
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 10;
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 10;
|
||||
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 12;
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, wy, "< App Connected >");
|
||||
} else {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Waiting for app...");
|
||||
}
|
||||
} else {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Not connected");
|
||||
wy += 12;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Configure in Settings");
|
||||
}
|
||||
display.setTextSize(1);
|
||||
#endif
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -422,34 +469,16 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
} else if (_page == HomePage::GPS) {
|
||||
extern GPSDutyCycle gpsDuty;
|
||||
extern GPSStreamCounter gpsStream;
|
||||
LocationProvider* nmea = sensors.getLocationProvider();
|
||||
char buf[50];
|
||||
int y = 18;
|
||||
|
||||
// GPS state line with duty cycle info
|
||||
// GPS state line
|
||||
if (!_node_prefs->gps_enabled) {
|
||||
strcpy(buf, "gps off");
|
||||
} else {
|
||||
switch (gpsDuty.getState()) {
|
||||
case GPSDutyState::ACQUIRING: {
|
||||
uint32_t elapsed = gpsDuty.acquireElapsedSecs();
|
||||
sprintf(buf, "acquiring %us", (unsigned)elapsed);
|
||||
break;
|
||||
}
|
||||
case GPSDutyState::SLEEPING: {
|
||||
uint32_t remain = gpsDuty.sleepRemainingSecs();
|
||||
if (remain >= 60) {
|
||||
sprintf(buf, "sleep %um%02us", (unsigned)(remain / 60), (unsigned)(remain % 60));
|
||||
} else {
|
||||
sprintf(buf, "sleep %us", (unsigned)remain);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
strcpy(buf, "gps off");
|
||||
}
|
||||
strcpy(buf, "gps on");
|
||||
}
|
||||
display.drawTextLeftAlign(0, y, buf);
|
||||
|
||||
@@ -465,9 +494,9 @@ public:
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
// NMEA sentence counter — confirms baud rate and data flow
|
||||
display.drawTextLeftAlign(0, y, "sentences");
|
||||
if (gpsDuty.isHardwareOn()) {
|
||||
if (_node_prefs->gps_enabled) {
|
||||
uint16_t sps = gpsStream.getSentencesPerSec();
|
||||
uint32_t total = gpsStream.getSentenceCount();
|
||||
sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total);
|
||||
@@ -901,6 +930,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
// Sync ringtone enabled state to modem manager
|
||||
modemManager.setRingtoneEnabled(node_prefs->ringtone_enabled);
|
||||
#endif
|
||||
|
||||
ui_started_at = millis();
|
||||
_alert_expiry = 0;
|
||||
|
||||
@@ -913,10 +947,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
notes_screen = new NotesScreen(this);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||||
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
#ifdef HAS_4G_MODEM
|
||||
sms_screen = new SMSScreen(this);
|
||||
#endif
|
||||
map_screen = new MapScreen(this);
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -964,7 +1000,7 @@ void UITask::msgRead(int msgcount) {
|
||||
}
|
||||
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path) {
|
||||
const uint8_t* path, int8_t snr) {
|
||||
_msgcount = msgcount;
|
||||
|
||||
// Add to preview screen (for notifications on non-keyboard devices)
|
||||
@@ -982,8 +1018,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
}
|
||||
}
|
||||
|
||||
// Add to channel history screen with channel index and path data
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path);
|
||||
// Add to channel history screen with channel index, path data, and SNR
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr);
|
||||
|
||||
// If user is currently viewing this channel, mark it as read immediately
|
||||
// (they can see the message arrive in real-time)
|
||||
@@ -1086,10 +1122,11 @@ void UITask::shutdown(bool restart){
|
||||
// Disable GPS if active
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
{
|
||||
extern GPSDutyCycle gpsDuty;
|
||||
if (_sensors != NULL && _node_prefs != NULL && _node_prefs->gps_enabled) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
gpsDuty.disable();
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1184,11 +1221,60 @@ void UITask::loop() {
|
||||
// Turn off keyboard flash after timeout
|
||||
#ifdef KB_BL_PIN
|
||||
if (_kb_flash_off_at && millis() >= _kb_flash_off_at) {
|
||||
#ifdef HAS_4G_MODEM
|
||||
// Don't turn off LED if incoming call flash is active
|
||||
if (!_incomingCallRinging) {
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
}
|
||||
#else
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
#endif
|
||||
_kb_flash_off_at = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Incoming call LED flash — rapid repeated pulse while ringing
|
||||
#if defined(HAS_4G_MODEM) && defined(KB_BL_PIN)
|
||||
{
|
||||
bool ringing = modemManager.isRinging();
|
||||
|
||||
if (ringing && !_incomingCallRinging) {
|
||||
// Ringing just started
|
||||
_incomingCallRinging = true;
|
||||
_callFlashState = false;
|
||||
_nextCallFlash = 0; // Start immediately
|
||||
|
||||
// Wake display for incoming call
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + 60000; // Keep display on while ringing (60s)
|
||||
|
||||
} else if (!ringing && _incomingCallRinging) {
|
||||
// Ringing stopped
|
||||
_incomingCallRinging = false;
|
||||
// Only turn off LED if message flash isn't also active
|
||||
if (!_kb_flash_off_at) {
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
}
|
||||
_callFlashState = false;
|
||||
}
|
||||
|
||||
// Rapid LED flash while ringing (if kb_flash_notify is ON)
|
||||
if (_incomingCallRinging && _node_prefs->kb_flash_notify) {
|
||||
unsigned long now = millis();
|
||||
if (now >= _nextCallFlash) {
|
||||
_callFlashState = !_callFlashState;
|
||||
digitalWrite(KB_BL_PIN, _callFlashState ? HIGH : LOW);
|
||||
// 250ms on, 250ms off — fast pulse to distinguish from single msg flash
|
||||
_nextCallFlash = now + 250;
|
||||
}
|
||||
// Extend auto-off while ringing
|
||||
_auto_off = millis() + 60000;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
if (buzzer.isPlaying()) buzzer.loop();
|
||||
#endif
|
||||
@@ -1229,10 +1315,11 @@ if (curr) curr->poll();
|
||||
if (millis() > next_batt_chck) {
|
||||
uint16_t milliVolts = getBattMilliVolts();
|
||||
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
_low_batt_count++;
|
||||
if (_low_batt_count >= 3) { // 3 consecutive low readings (~24s) to avoid transient sags
|
||||
|
||||
// show low battery shutdown alert
|
||||
// we should only do this for eink displays, which will persist after power loss
|
||||
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO)
|
||||
// show low battery shutdown alert on e-ink (persists after power loss)
|
||||
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO) || defined(LilyGo_TDeck_Pro)
|
||||
if (_display != NULL) {
|
||||
_display->startFrame();
|
||||
_display->setTextSize(2);
|
||||
@@ -1244,7 +1331,9 @@ if (curr) curr->poll();
|
||||
#endif
|
||||
|
||||
shutdown();
|
||||
|
||||
}
|
||||
} else {
|
||||
_low_batt_count = 0;
|
||||
}
|
||||
next_batt_chck = millis() + 8000;
|
||||
}
|
||||
@@ -1295,20 +1384,22 @@ bool UITask::getGPSState() {
|
||||
|
||||
void UITask::toggleGPS() {
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
extern GPSDutyCycle gpsDuty;
|
||||
|
||||
if (_sensors != NULL) {
|
||||
if (_node_prefs->gps_enabled) {
|
||||
// Disable GPS  cut hardware power
|
||||
// Disable GPS — cut hardware power
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
gpsDuty.disable();
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
#endif
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
// Enable GPS  start duty cycle
|
||||
// Enable GPS — power on hardware
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
gpsDuty.enable();
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
|
||||
#endif
|
||||
notify(UIEventType::ack);
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
@@ -1517,6 +1608,16 @@ void UITask::gotoRepeaterAdmin(int contactIdx) {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoDiscoveryScreen() {
|
||||
((DiscoveryScreen*)discovery_screen)->resetScroll();
|
||||
setCurrScreen(discovery_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
void UITask::gotoWebReader() {
|
||||
// Lazy-initialize on first use (same pattern as audiobook player)
|
||||
@@ -1542,6 +1643,19 @@ void UITask::gotoWebReader() {
|
||||
}
|
||||
#endif
|
||||
|
||||
void UITask::gotoMapScreen() {
|
||||
MapScreen* map = (MapScreen*)map_screen;
|
||||
if (_display != NULL) {
|
||||
map->enter(*_display);
|
||||
}
|
||||
setCurrScreen(map_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
|
||||
// MapScreen.h included in UITask.cpp and main.cpp only (PNGdec headers
|
||||
// conflict with BLE if pulled into the global include chain)
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
DisplayDriver* _display;
|
||||
SensorManager* _sensors;
|
||||
@@ -41,11 +44,17 @@ class UITask : public AbstractUITask {
|
||||
#endif
|
||||
unsigned long _next_refresh, _auto_off;
|
||||
unsigned long _kb_flash_off_at; // Keyboard flash turn-off timer
|
||||
#ifdef HAS_4G_MODEM
|
||||
bool _incomingCallRinging; // Currently ringing (incoming call)
|
||||
unsigned long _nextCallFlash; // Next LED toggle time
|
||||
bool _callFlashState; // Current LED state during ring
|
||||
#endif
|
||||
NodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
unsigned long _alert_expiry;
|
||||
int _msgcount;
|
||||
unsigned long ui_started_at, next_batt_chck;
|
||||
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
|
||||
int next_backlight_btn_check = 0;
|
||||
#ifdef PIN_STATUS_LED
|
||||
int led_state = 0;
|
||||
@@ -70,9 +79,11 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
|
||||
#endif
|
||||
UIScreen* repeater_admin; // Repeater admin screen
|
||||
UIScreen* discovery_screen; // Node discovery scan screen
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
|
||||
#endif
|
||||
UIScreen* map_screen; // Map tile screen (GPS + SD card tiles)
|
||||
UIScreen* curr;
|
||||
|
||||
void userLedHandler();
|
||||
@@ -90,6 +101,11 @@ public:
|
||||
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
next_batt_chck = _next_refresh = 0;
|
||||
_kb_flash_off_at = 0;
|
||||
#ifdef HAS_4G_MODEM
|
||||
_incomingCallRinging = false;
|
||||
_nextCallFlash = 0;
|
||||
_callFlashState = false;
|
||||
#endif
|
||||
ui_started_at = 0;
|
||||
curr = NULL;
|
||||
}
|
||||
@@ -104,6 +120,8 @@ public:
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
void gotoDiscoveryScreen(); // Navigate to node discovery scan
|
||||
void gotoMapScreen(); // Navigate to map tile screen
|
||||
#ifdef MECK_WEB_READER
|
||||
void gotoWebReader(); // Navigate to web reader (browser)
|
||||
#endif
|
||||
@@ -131,6 +149,8 @@ public:
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
|
||||
bool isOnMapScreen() const { return curr == map_screen; }
|
||||
#ifdef MECK_WEB_READER
|
||||
bool isOnWebReader() const { return curr == web_reader; }
|
||||
#endif
|
||||
@@ -174,6 +194,8 @@ public:
|
||||
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
|
||||
UIScreen* getMapScreen() const { return map_screen; }
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* getWebReaderScreen() const { return web_reader; }
|
||||
#endif
|
||||
@@ -181,7 +203,7 @@ public:
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path = nullptr) override;
|
||||
const uint8_t* path = nullptr, int8_t snr = 0) override;
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
|
||||
@@ -2152,6 +2152,8 @@ private:
|
||||
needsFreshTls = true; // Recreate at top of next iteration (after http destructor)
|
||||
|
||||
// After 2 failures, try WiFi reconnect — the lwIP stack may be wedged
|
||||
// Skip on WiFi companion variant — disconnect kills the companion TCP server
|
||||
#ifndef MECK_WIFI_COMPANION
|
||||
if (totalRetries == 2 && isWiFiConnected()) {
|
||||
Serial.println("WebReader: WiFi reconnect after persistent failures");
|
||||
// Destroy TLS before WiFi teardown
|
||||
@@ -2170,6 +2172,7 @@ private:
|
||||
Serial.printf("WebReader: WiFi reconnected, IP: %s\n",
|
||||
WiFi.localIP().toString().c_str());
|
||||
}
|
||||
#endif
|
||||
|
||||
int retryDelay = 1000 + totalRetries * 1000; // 2s, 3s, 4s, 5s
|
||||
Serial.printf("WebReader: Connection error %d, retrying in %dms... (attempt %d/4)\n",
|
||||
@@ -2246,6 +2249,8 @@ private:
|
||||
clearCloudflareCookies(domain);
|
||||
|
||||
// WiFi reconnect after 2 total failures (same logic as conn errors)
|
||||
// Skip on WiFi companion variant — disconnect kills the companion TCP server
|
||||
#ifndef MECK_WIFI_COMPANION
|
||||
if (totalRetries == 2 && isWiFiConnected()) {
|
||||
Serial.println("WebReader: WiFi reconnect after persistent failures");
|
||||
if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
|
||||
@@ -2262,6 +2267,7 @@ private:
|
||||
Serial.printf("WebReader: WiFi reconnected, IP: %s\n",
|
||||
WiFi.localIP().toString().c_str());
|
||||
}
|
||||
#endif
|
||||
|
||||
int retryDelay = 1000 + totalRetries * 1000; // 2s, 3s, 4s, 5s
|
||||
Serial.printf("WebReader: Server error %d, retrying in %dms... (attempt %d/4)\n",
|
||||
@@ -2947,7 +2953,7 @@ private:
|
||||
display.setCursor(0, y);
|
||||
if (_searchEditing) {
|
||||
char searchDisp[140];
|
||||
int maxShow = maxChars - 8; // "Search: " prefix + cursor
|
||||
int maxShow = maxChars - 8; // "Search: " prefix + cursor
|
||||
int start = 0;
|
||||
if (_searchLen > maxShow) start = _searchLen - maxShow;
|
||||
snprintf(searchDisp, sizeof(searchDisp), "Search: %s_", _searchBuffer + start);
|
||||
@@ -3099,7 +3105,7 @@ private:
|
||||
if (_urlEditing) {
|
||||
display.print("Type URL Ent:Go");
|
||||
} else if (_searchEditing) {
|
||||
display.print("Type query Ent:Search");
|
||||
display.print("Type query Ent:Search");
|
||||
} else {
|
||||
char footerBuf[48];
|
||||
bool hasData = (_cookieCount > 0 || !_history.empty());
|
||||
@@ -5017,6 +5023,7 @@ public:
|
||||
// Called when entering the web reader screen
|
||||
void enter(DisplayDriver& display) {
|
||||
_display = &display;
|
||||
_fetchError = ""; // Clear stale errors from previous session
|
||||
initLayout(display);
|
||||
loadBookmarks();
|
||||
loadHistory();
|
||||
@@ -5110,10 +5117,16 @@ public:
|
||||
if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
|
||||
_tlsHost = String();
|
||||
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
// WiFi companion: keep WiFi alive for the companion TCP server.
|
||||
// Don't disconnect or change mode — just reset our internal state.
|
||||
_wifiState = WIFI_CONNECTED; // WiFi is still up
|
||||
#else
|
||||
// Shut down WiFi to reclaim ~50-70KB internal RAM
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
_wifiState = WIFI_IDLE;
|
||||
#endif
|
||||
|
||||
// Reset mode so re-entry starts fresh
|
||||
_mode = HOME;
|
||||
@@ -5131,7 +5144,6 @@ public:
|
||||
bool wantsTextReader() const { return _requestTextReader; }
|
||||
void clearTextReaderRequest() { _requestTextReader = false; }
|
||||
bool isUrlEditing() const { return _urlEditing && _mode == HOME; }
|
||||
bool isSearchEditing() const { return _searchEditing && _mode == HOME; }
|
||||
bool isWifiSetup() const { return _mode == WIFI_SETUP; }
|
||||
bool isPasswordEntry() const {
|
||||
return _mode == WIFI_SETUP && _wifiState == WIFI_ENTERING_PASS;
|
||||
@@ -5139,6 +5151,9 @@ public:
|
||||
bool isFormFilling() const {
|
||||
return _mode == FORM_FILL && _formFieldEditing;
|
||||
}
|
||||
bool isSearchEditing() const {
|
||||
return _searchEditing && _mode == HOME;
|
||||
}
|
||||
bool isIRCMode() const { return _mode == IRC_CHAT || _mode == IRC_SETUP; }
|
||||
bool isIRCTextEntry() const {
|
||||
return (_mode == IRC_CHAT && _ircComposing) ||
|
||||
|
||||
@@ -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
|
||||
@@ -68,7 +68,7 @@ void Dispatcher::loop() {
|
||||
next_tx_time = futureMillis(t * getAirtimeBudgetFactor());
|
||||
|
||||
_radio->onSendFinished();
|
||||
logTx(outbound, 2 + outbound->path_len + outbound->payload_len);
|
||||
logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
|
||||
if (outbound->isRouteFlood()) {
|
||||
n_sent_flood++;
|
||||
} else {
|
||||
@@ -80,7 +80,7 @@ void Dispatcher::loop() {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): WARNING: outbound packed send timed out!", getLogDateTime());
|
||||
|
||||
_radio->onSendFinished();
|
||||
logTxFail(outbound, 2 + outbound->path_len + outbound->payload_len);
|
||||
logTxFail(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
|
||||
|
||||
releasePacket(outbound); // return to pool
|
||||
outbound = NULL;
|
||||
@@ -141,12 +141,13 @@ void Dispatcher::checkRecv() {
|
||||
}
|
||||
pkt->path_len = raw[i++];
|
||||
|
||||
if (pkt->path_len > MAX_PATH_SIZE || i + pkt->path_len > len) {
|
||||
uint16_t path_byte_len = pkt->getPathByteLen();
|
||||
if (path_byte_len > MAX_PATH_SIZE || i + path_byte_len > len) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(): partial or corrupt packet received, len=%d", getLogDateTime(), len);
|
||||
_mgr->free(pkt); // put back into pool
|
||||
pkt = NULL;
|
||||
} else {
|
||||
memcpy(pkt->path, &raw[i], pkt->path_len); i += pkt->path_len;
|
||||
memcpy(pkt->path, &raw[i], path_byte_len); i += path_byte_len;
|
||||
|
||||
pkt->payload_len = len - i; // payload is remainder
|
||||
if (pkt->payload_len > sizeof(pkt->payload)) {
|
||||
@@ -258,7 +259,8 @@ void Dispatcher::checkSend() {
|
||||
memcpy(&raw[len], &outbound->transport_codes[1], 2); len += 2;
|
||||
}
|
||||
raw[len++] = outbound->path_len;
|
||||
memcpy(&raw[len], outbound->path, outbound->path_len); len += outbound->path_len;
|
||||
uint16_t out_pbl = outbound->getPathByteLen();
|
||||
memcpy(&raw[len], outbound->path, out_pbl); len += out_pbl;
|
||||
|
||||
if (len + outbound->payload_len > MAX_TRANS_UNIT) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): FATAL: Invalid packet queued... too long, len=%d", getLogDateTime(), len + outbound->payload_len);
|
||||
@@ -312,8 +314,8 @@ void Dispatcher::releasePacket(Packet* packet) {
|
||||
}
|
||||
|
||||
void Dispatcher::sendPacket(Packet* packet, uint8_t priority, uint32_t delay_millis) {
|
||||
if (packet->path_len > MAX_PATH_SIZE || packet->payload_len > MAX_PACKET_PAYLOAD) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::sendPacket(): ERROR: invalid packet... path_len=%d, payload_len=%d", getLogDateTime(), (uint32_t) packet->path_len, (uint32_t) packet->payload_len);
|
||||
if (packet->getPathByteLen() > MAX_PATH_SIZE || packet->payload_len > MAX_PACKET_PAYLOAD) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::sendPacket(): ERROR: invalid packet... path_len=%d (byte_len=%d), payload_len=%d", getLogDateTime(), (uint32_t) packet->path_len, (uint32_t) packet->getPathByteLen(), (uint32_t) packet->payload_len);
|
||||
_mgr->free(packet);
|
||||
} else {
|
||||
_mgr->queueOutbound(packet, priority, futureMillis(delay_millis));
|
||||
|
||||
@@ -20,6 +20,10 @@ public:
|
||||
memcpy(dest, pub_key, PATH_HASH_SIZE); // hash is just prefix of pub_key
|
||||
return PATH_HASH_SIZE;
|
||||
}
|
||||
int copyHashTo(uint8_t* dest, uint8_t len) const {
|
||||
memcpy(dest, pub_key, len);
|
||||
return len;
|
||||
}
|
||||
bool isHashMatch(const uint8_t* hash) const {
|
||||
return memcmp(hash, pub_key, PATH_HASH_SIZE) == 0;
|
||||
}
|
||||
@@ -90,5 +94,4 @@ public:
|
||||
void readFrom(const uint8_t* src, size_t len);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
74
src/Mesh.cpp
@@ -15,7 +15,7 @@ bool Mesh::allowPacketForward(const mesh::Packet* packet) {
|
||||
return false; // by default, Transport NOT enabled
|
||||
}
|
||||
uint32_t Mesh::getRetransmitDelay(const mesh::Packet* packet) {
|
||||
uint32_t t = (_radio->getEstAirtimeFor(packet->getRawLength()) * 52 / 50) / 2;
|
||||
uint32_t t = (uint32_t)(_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.5f);
|
||||
|
||||
return _rng->nextInt(0, 5)*t;
|
||||
}
|
||||
@@ -77,7 +77,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
return ACTION_RELEASE;
|
||||
}
|
||||
|
||||
if (pkt->isRouteDirect() && pkt->path_len >= PATH_HASH_SIZE) {
|
||||
if (pkt->isRouteDirect() && (pkt->path_len & 63) > 0) {
|
||||
uint8_t dir_bph = (pkt->path_len >> 6) + 1; // bytes per hop for this packet
|
||||
|
||||
// check for 'early received' ACK
|
||||
if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) {
|
||||
int i = 0;
|
||||
@@ -88,7 +90,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
}
|
||||
}
|
||||
|
||||
if (self_id.isHashMatch(pkt->path) && allowPacketForward(pkt)) {
|
||||
if (self_id.isHashMatch(pkt->path, dir_bph) && allowPacketForward(pkt)) {
|
||||
if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) {
|
||||
return forwardMultipartDirect(pkt);
|
||||
} else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) {
|
||||
@@ -158,7 +160,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) {
|
||||
int k = 0;
|
||||
uint8_t path_len = data[k++];
|
||||
uint8_t* path = &data[k]; k += path_len;
|
||||
uint8_t* path = &data[k]; k += Packet::getPathByteLenFor(path_len);
|
||||
uint8_t extra_type = data[k++] & 0x0F; // upper 4 bits reserved for future use
|
||||
uint8_t* extra = &data[k];
|
||||
uint8_t extra_len = len - k; // remainder of packet (may be padded with zeroes!)
|
||||
@@ -293,8 +295,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { // a multipart ACK
|
||||
Packet tmp;
|
||||
tmp.header = pkt->header;
|
||||
tmp.path_len = pkt->path_len;
|
||||
memcpy(tmp.path, pkt->path, pkt->path_len);
|
||||
tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len);
|
||||
tmp.payload_len = pkt->payload_len - 1;
|
||||
memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len);
|
||||
|
||||
@@ -320,28 +321,34 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
}
|
||||
|
||||
void Mesh::removeSelfFromPath(Packet* pkt) {
|
||||
// remove our hash from 'path'
|
||||
pkt->path_len -= PATH_HASH_SIZE;
|
||||
#if 0
|
||||
memcpy(pkt->path, &pkt->path[PATH_HASH_SIZE], pkt->path_len);
|
||||
#elif PATH_HASH_SIZE == 1
|
||||
for (int k = 0; k < pkt->path_len; k++) { // shuffle bytes by 1
|
||||
pkt->path[k] = pkt->path[k + 1];
|
||||
}
|
||||
#else
|
||||
#error "need path remove impl"
|
||||
#endif
|
||||
uint8_t bph = (pkt->path_len >> 6) + 1; // bytes per hop
|
||||
uint8_t hops = pkt->path_len & 63;
|
||||
if (hops == 0) return;
|
||||
|
||||
uint16_t new_byte_len = (hops - 1) * bph;
|
||||
// remove first bph bytes (our hash) from path, shift remainder
|
||||
memmove(pkt->path, &pkt->path[bph], new_byte_len);
|
||||
// decrement hop count, preserve mode bits
|
||||
pkt->path_len = (pkt->path_len & 0xC0) | ((hops - 1) & 63);
|
||||
}
|
||||
|
||||
DispatcherAction Mesh::routeRecvPacket(Packet* packet) {
|
||||
if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit()
|
||||
&& packet->path_len + PATH_HASH_SIZE <= MAX_PATH_SIZE && allowPacketForward(packet)) {
|
||||
// append this node's hash to 'path'
|
||||
packet->path_len += self_id.copyHashTo(&packet->path[packet->path_len]);
|
||||
&& allowPacketForward(packet)) {
|
||||
uint8_t bph = (packet->path_len >> 6) + 1; // bytes per hop
|
||||
uint8_t hops = packet->path_len & 63;
|
||||
uint16_t byte_len = hops * bph;
|
||||
|
||||
uint32_t d = getRetransmitDelay(packet);
|
||||
// as this propagates outwards, give it lower and lower priority
|
||||
return ACTION_RETRANSMIT_DELAYED(packet->path_len, d); // give priority to closer sources, than ones further away
|
||||
if (byte_len + bph <= MAX_PATH_SIZE) {
|
||||
// append this node's hash (bph bytes of pub_key) to path
|
||||
memcpy(&packet->path[byte_len], self_id.pub_key, bph);
|
||||
// increment hop count, preserve mode bits
|
||||
packet->path_len = (packet->path_len & 0xC0) | ((hops + 1) & 63);
|
||||
|
||||
uint32_t d = getRetransmitDelay(packet);
|
||||
// as this propagates outwards, give it lower and lower priority
|
||||
return ACTION_RETRANSMIT_DELAYED(hops + 1, d); // give priority to closer sources, than ones further away
|
||||
}
|
||||
}
|
||||
return ACTION_RELEASE;
|
||||
}
|
||||
@@ -353,8 +360,7 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) {
|
||||
if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { // a multipart ACK
|
||||
Packet tmp;
|
||||
tmp.header = pkt->header;
|
||||
tmp.path_len = pkt->path_len;
|
||||
memcpy(tmp.path, pkt->path, pkt->path_len);
|
||||
tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len);
|
||||
tmp.payload_len = pkt->payload_len - 1;
|
||||
memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len);
|
||||
|
||||
@@ -376,7 +382,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
|
||||
delay_millis += getDirectRetransmitDelay(packet) + 300;
|
||||
auto a1 = createMultiAck(crc, extra);
|
||||
if (a1) {
|
||||
memcpy(a1->path, packet->path, a1->path_len = packet->path_len);
|
||||
a1->path_len = Packet::copyPath(a1->path, packet->path, packet->path_len);
|
||||
a1->header &= ~PH_ROUTE_MASK;
|
||||
a1->header |= ROUTE_TYPE_DIRECT;
|
||||
sendPacket(a1, 0, delay_millis);
|
||||
@@ -386,7 +392,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
|
||||
|
||||
auto a2 = createAck(crc);
|
||||
if (a2) {
|
||||
memcpy(a2->path, packet->path, a2->path_len = packet->path_len);
|
||||
a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len);
|
||||
a2->header &= ~PH_ROUTE_MASK;
|
||||
a2->header |= ROUTE_TYPE_DIRECT;
|
||||
sendPacket(a2, 0, delay_millis);
|
||||
@@ -624,7 +630,7 @@ Packet* Mesh::createControlData(const uint8_t* data, size_t len) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
void Mesh::sendFlood(Packet* packet, uint32_t delay_millis) {
|
||||
void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_bytes_per_hop) {
|
||||
if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) {
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime());
|
||||
return;
|
||||
@@ -632,7 +638,9 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis) {
|
||||
|
||||
packet->header &= ~PH_ROUTE_MASK;
|
||||
packet->header |= ROUTE_TYPE_FLOOD;
|
||||
packet->path_len = 0;
|
||||
// encode bytes-per-hop mode in upper 2 bits of path_len, 0 hops initially
|
||||
uint8_t mode = (path_bytes_per_hop > 1) ? (path_bytes_per_hop - 1) : 0;
|
||||
packet->path_len = (mode << 6);
|
||||
|
||||
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
|
||||
@@ -647,7 +655,7 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis) {
|
||||
sendPacket(packet, pri, delay_millis);
|
||||
}
|
||||
|
||||
void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis) {
|
||||
void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis, uint8_t path_bytes_per_hop) {
|
||||
if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) {
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime());
|
||||
return;
|
||||
@@ -657,7 +665,9 @@ void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_m
|
||||
packet->header |= ROUTE_TYPE_TRANSPORT_FLOOD;
|
||||
packet->transport_codes[0] = transport_codes[0];
|
||||
packet->transport_codes[1] = transport_codes[1];
|
||||
packet->path_len = 0;
|
||||
// encode bytes-per-hop mode in upper 2 bits of path_len, 0 hops initially
|
||||
uint8_t mode = (path_bytes_per_hop > 1) ? (path_bytes_per_hop - 1) : 0;
|
||||
packet->path_len = (mode << 6);
|
||||
|
||||
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
|
||||
@@ -685,7 +695,7 @@ void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uin
|
||||
packet->path_len = 0;
|
||||
pri = 5; // maybe make this configurable
|
||||
} else {
|
||||
memcpy(packet->path, path, packet->path_len = path_len);
|
||||
packet->path_len = Packet::copyPath(packet->path, path, path_len);
|
||||
if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) {
|
||||
pri = 1; // slightly less priority
|
||||
} else {
|
||||
|
||||
@@ -195,14 +195,16 @@ public:
|
||||
|
||||
/**
|
||||
* \brief send a locally-generated Packet with flood routing
|
||||
* \param path_bytes_per_hop number of bytes per path hop (1=legacy, 2, or 3)
|
||||
*/
|
||||
void sendFlood(Packet* packet, uint32_t delay_millis=0);
|
||||
void sendFlood(Packet* packet, uint32_t delay_millis=0, uint8_t path_bytes_per_hop=1);
|
||||
|
||||
/**
|
||||
* \brief send a locally-generated Packet with flood routing
|
||||
* \param transport_codes array of 2 codes to attach to packet
|
||||
* \param path_bytes_per_hop number of bytes per path hop (1=legacy, 2, or 3)
|
||||
*/
|
||||
void sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis=0);
|
||||
void sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis=0, uint8_t path_bytes_per_hop=1);
|
||||
|
||||
/**
|
||||
* \brief send a locally-generated Packet with Direct routing
|
||||
@@ -222,4 +224,4 @@ public:
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ Packet::Packet() {
|
||||
}
|
||||
|
||||
int Packet::getRawLength() const {
|
||||
return 2 + path_len + payload_len + (hasTransportCodes() ? 4 : 0);
|
||||
return 2 + getPathByteLen() + payload_len + (hasTransportCodes() ? 4 : 0);
|
||||
}
|
||||
|
||||
void Packet::calculatePacketHash(uint8_t* hash) const {
|
||||
@@ -33,7 +33,8 @@ uint8_t Packet::writeTo(uint8_t dest[]) const {
|
||||
memcpy(&dest[i], &transport_codes[1], 2); i += 2;
|
||||
}
|
||||
dest[i++] = path_len;
|
||||
memcpy(&dest[i], path, path_len); i += path_len;
|
||||
uint16_t pbl = getPathByteLen();
|
||||
memcpy(&dest[i], path, pbl); i += pbl;
|
||||
memcpy(&dest[i], payload, payload_len); i += payload_len;
|
||||
return i;
|
||||
}
|
||||
@@ -48,8 +49,9 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) {
|
||||
transport_codes[0] = transport_codes[1] = 0;
|
||||
}
|
||||
path_len = src[i++];
|
||||
if (path_len > sizeof(path)) return false; // bad encoding
|
||||
memcpy(path, &src[i], path_len); i += path_len;
|
||||
uint16_t pbl = getPathByteLen();
|
||||
if (pbl > sizeof(path)) return false; // bad encoding
|
||||
memcpy(path, &src[i], pbl); i += pbl;
|
||||
if (i >= len) return false; // bad encoding
|
||||
payload_len = len - i;
|
||||
if (payload_len > sizeof(payload)) return false; // bad encoding
|
||||
|
||||
40
src/Packet.h
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <MeshCore.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
@@ -81,6 +82,43 @@ public:
|
||||
|
||||
float getSNR() const { return ((float)_snr) / 4.0f; }
|
||||
|
||||
/**
|
||||
* \returns the actual byte length of path data.
|
||||
* path_len encodes: lower 6 bits = hop count, upper 2 bits = bytes-per-hop mode
|
||||
* mode 0 = 1 byte/hop (legacy), mode 1 = 2 bytes/hop, mode 2 = 3 bytes/hop
|
||||
*/
|
||||
uint16_t getPathByteLen() const {
|
||||
uint8_t hops = path_len & 63;
|
||||
uint8_t bph = (path_len >> 6) + 1;
|
||||
return hops * bph;
|
||||
}
|
||||
|
||||
/** Static variant for computing byte length from any path_len value */
|
||||
static uint16_t getPathByteLenFor(uint8_t path_len) {
|
||||
return (path_len & 63) * ((path_len >> 6) + 1);
|
||||
}
|
||||
|
||||
/** Validate that encoded path_len won't exceed buffer */
|
||||
static bool isValidPathLen(uint8_t path_len) {
|
||||
return getPathByteLenFor(path_len) <= MAX_PATH_SIZE;
|
||||
}
|
||||
|
||||
/** Copy path bytes using encoded path_len; returns path_len unchanged */
|
||||
static uint8_t copyPath(uint8_t* dest, const uint8_t* src, uint8_t path_len) {
|
||||
uint16_t bl = getPathByteLenFor(path_len);
|
||||
if (bl > MAX_PATH_SIZE) bl = MAX_PATH_SIZE;
|
||||
memcpy(dest, src, bl);
|
||||
return path_len;
|
||||
}
|
||||
|
||||
/** Write path bytes to buffer; returns number of bytes written */
|
||||
static uint8_t writePath(uint8_t* dest, const uint8_t* src, uint8_t path_len) {
|
||||
uint16_t bl = getPathByteLenFor(path_len);
|
||||
if (bl > MAX_PATH_SIZE) bl = MAX_PATH_SIZE;
|
||||
memcpy(dest, src, bl);
|
||||
return (uint8_t)bl;
|
||||
}
|
||||
|
||||
/**
|
||||
* \returns the encoded/wire format length of this packet
|
||||
*/
|
||||
@@ -101,4 +139,4 @@ public:
|
||||
bool readFrom(const uint8_t src[], uint8_t len);
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name, double lat, doubl
|
||||
}
|
||||
|
||||
void BaseChatMesh::sendAckTo(const ContactInfo& dest, uint32_t ack_hash) {
|
||||
if (dest.out_path_len < 0) {
|
||||
if (dest.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
mesh::Packet* ack = createAck(ack_hash);
|
||||
if (ack) sendFloodScoped(dest, ack, TXT_ACK_DELAY);
|
||||
} else {
|
||||
@@ -92,7 +92,7 @@ ContactInfo* BaseChatMesh::allocateContactSlot() {
|
||||
void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp) {
|
||||
memset(&ci, 0, sizeof(ci));
|
||||
ci.id = id;
|
||||
ci.out_path_len = -1; // initially out_path is unknown
|
||||
ci.out_path_len = OUT_PATH_UNKNOWN; // initially out_path is unknown
|
||||
StrHelper::strncpy(ci.name, parser.getName(), sizeof(ci.name));
|
||||
ci.type = parser.getType();
|
||||
if (parser.hasLatLon()) {
|
||||
@@ -263,7 +263,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender
|
||||
} else {
|
||||
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len);
|
||||
if (reply) {
|
||||
if (from.out_path_len >= 0) { // we have an out_path, so send DIRECT
|
||||
if (from.out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
|
||||
sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY);
|
||||
} else {
|
||||
sendFloodScoped(from, reply, SERVER_RESPONSE_DELAY);
|
||||
@@ -273,7 +273,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender
|
||||
}
|
||||
} else if (type == PAYLOAD_TYPE_RESPONSE && len > 0) {
|
||||
onContactResponse(from, data, len);
|
||||
if (packet->isRouteFlood() && from.out_path_len >= 0) {
|
||||
if (packet->isRouteFlood() && from.out_path_len != OUT_PATH_UNKNOWN) {
|
||||
// we have direct path, but other node is still sending flood response, so maybe they didn't receive reciprocal path properly(?)
|
||||
handleReturnPathRetry(from, packet->path, packet->path_len);
|
||||
}
|
||||
@@ -295,7 +295,8 @@ bool BaseChatMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const ui
|
||||
bool BaseChatMesh::onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) {
|
||||
// NOTE: default impl, we just replace the current 'out_path' regardless, whenever sender sends us a new out_path.
|
||||
// FUTURE: could store multiple out_paths per contact, and try to find which is the 'best'(?)
|
||||
memcpy(from.out_path, out_path, from.out_path_len = out_path_len); // store a copy of path, for sendDirect()
|
||||
from.out_path_len = out_path_len;
|
||||
mesh::Packet::copyPath(from.out_path, out_path, out_path_len); // store a copy of path, for sendDirect()
|
||||
from.lastmod = getRTCClock()->getCurrentTime();
|
||||
|
||||
onContactPathUpdated(from);
|
||||
@@ -317,7 +318,7 @@ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) {
|
||||
txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer
|
||||
packet->markDoNotRetransmit(); // ACK was for this node, so don't retransmit
|
||||
|
||||
if (packet->isRouteFlood() && from->out_path_len >= 0) {
|
||||
if (packet->isRouteFlood() && from->out_path_len != OUT_PATH_UNKNOWN) {
|
||||
// we have direct path, but other node is still sending flood, so maybe they didn't receive reciprocal path properly(?)
|
||||
handleReturnPathRetry(*from, packet->path, packet->path_len);
|
||||
}
|
||||
@@ -386,7 +387,7 @@ int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp,
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
|
||||
int rc;
|
||||
if (recipient.out_path_len < 0) {
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(recipient, pkt);
|
||||
txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t));
|
||||
rc = MSG_SEND_SENT_FLOOD;
|
||||
@@ -412,7 +413,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest
|
||||
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
int rc;
|
||||
if (recipient.out_path_len < 0) {
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(recipient, pkt);
|
||||
txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t));
|
||||
rc = MSG_SEND_SENT_FLOOD;
|
||||
@@ -500,7 +501,7 @@ int BaseChatMesh::sendLogin(const ContactInfo& recipient, const char* password,
|
||||
}
|
||||
if (pkt) {
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
if (recipient.out_path_len < 0) {
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(recipient, pkt);
|
||||
est_timeout = calcFloodTimeoutMillisFor(t);
|
||||
return MSG_SEND_SENT_FLOOD;
|
||||
@@ -525,7 +526,7 @@ int BaseChatMesh::sendAnonReq(const ContactInfo& recipient, const uint8_t* data,
|
||||
}
|
||||
if (pkt) {
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
if (recipient.out_path_len < 0) {
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(recipient, pkt);
|
||||
est_timeout = calcFloodTimeoutMillisFor(t);
|
||||
return MSG_SEND_SENT_FLOOD;
|
||||
@@ -552,7 +553,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_
|
||||
}
|
||||
if (pkt) {
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
if (recipient.out_path_len < 0) {
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(recipient, pkt);
|
||||
est_timeout = calcFloodTimeoutMillisFor(t);
|
||||
return MSG_SEND_SENT_FLOOD;
|
||||
@@ -579,7 +580,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u
|
||||
}
|
||||
if (pkt) {
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
if (recipient.out_path_len < 0) {
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(recipient, pkt);
|
||||
est_timeout = calcFloodTimeoutMillisFor(t);
|
||||
return MSG_SEND_SENT_FLOOD;
|
||||
@@ -683,7 +684,7 @@ void BaseChatMesh::checkConnections() {
|
||||
MESH_DEBUG_PRINTLN("checkConnections(): Keep_alive contact not found!");
|
||||
continue;
|
||||
}
|
||||
if (contact->out_path_len < 0) {
|
||||
if (contact->out_path_len == OUT_PATH_UNKNOWN) {
|
||||
MESH_DEBUG_PRINTLN("checkConnections(): Keep_alive contact, no out_path!");
|
||||
continue;
|
||||
}
|
||||
@@ -710,7 +711,7 @@ void BaseChatMesh::checkConnections() {
|
||||
}
|
||||
|
||||
void BaseChatMesh::resetPathTo(ContactInfo& recipient) {
|
||||
recipient.out_path_len = -1;
|
||||
recipient.out_path_len = OUT_PATH_UNKNOWN;
|
||||
}
|
||||
|
||||
static ContactInfo* table; // pass via global :-(
|
||||
@@ -875,4 +876,4 @@ void BaseChatMesh::loop() {
|
||||
releasePacket(_pendingLoopback); // undo the obtainNewPacket()
|
||||
_pendingLoopback = NULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ protected:
|
||||
virtual bool shouldAutoAddContactType(uint8_t type) const { return true; }
|
||||
virtual void onContactsFull() {};
|
||||
virtual bool shouldOverwriteWhenFull() const { return false; }
|
||||
virtual uint8_t getAutoAddMaxHops() const { return 0; } // 0 = no limit
|
||||
virtual void onContactOverwrite(const uint8_t* pub_key) {};
|
||||
virtual void onDiscoveredContact(ContactInfo& contact, bool is_new, uint8_t path_len, const uint8_t* path) = 0;
|
||||
virtual ContactInfo* processAck(const uint8_t *data) = 0;
|
||||
|
||||
@@ -114,7 +114,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
|
||||
memset(c, 0, sizeof(*c));
|
||||
c->permissions = init_perms;
|
||||
c->id = id;
|
||||
c->out_path_len = -1; // initially out_path is unknown
|
||||
c->out_path_len = OUT_PATH_UNKNOWN; // initially out_path is unknown
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -140,4 +140,4 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8
|
||||
self_id.calcSharedSecret(c->shared_secret, pubkey);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
#include <Mesh.h>
|
||||
#include <helpers/IdentityStore.h>
|
||||
|
||||
#ifndef OUT_PATH_UNKNOWN
|
||||
#define OUT_PATH_UNKNOWN 0xFF
|
||||
#endif
|
||||
|
||||
#define PERM_ACL_ROLE_MASK 3 // lower 2 bits
|
||||
#define PERM_ACL_GUEST 0
|
||||
#define PERM_ACL_READ_ONLY 1
|
||||
@@ -13,7 +17,7 @@
|
||||
struct ClientInfo {
|
||||
mesh::Identity id;
|
||||
uint8_t permissions;
|
||||
int8_t out_path_len;
|
||||
uint8_t out_path_len; // OUT_PATH_UNKNOWN = no known path
|
||||
uint8_t out_path[MAX_PATH_SIZE];
|
||||
uint8_t shared_secret[PUB_KEY_SIZE];
|
||||
uint32_t last_timestamp; // by THEIR clock (transient)
|
||||
@@ -55,4 +59,4 @@ public:
|
||||
|
||||
int getNumClients() const { return num_clients; }
|
||||
ClientInfo* getClientByIdx(int idx) { return &clients[idx]; }
|
||||
};
|
||||
};
|
||||
@@ -3,12 +3,14 @@
|
||||
#include <Arduino.h>
|
||||
#include <Mesh.h>
|
||||
|
||||
#define OUT_PATH_UNKNOWN 0xFF // no known path — triggers flood routing
|
||||
|
||||
struct ContactInfo {
|
||||
mesh::Identity id;
|
||||
char name[32];
|
||||
uint8_t type; // on of ADV_TYPE_*
|
||||
uint8_t flags;
|
||||
int8_t out_path_len;
|
||||
uint8_t out_path_len; // encoded: bits[7:6]=mode, bits[5:0]=hops. OUT_PATH_UNKNOWN=no path
|
||||
mutable bool shared_secret_valid; // flag to indicate if shared_secret has been calculated
|
||||
uint8_t out_path[MAX_PATH_SIZE];
|
||||
uint32_t last_advert_timestamp; // by THEIR clock
|
||||
@@ -26,4 +28,4 @@ struct ContactInfo {
|
||||
|
||||
private:
|
||||
mutable uint8_t shared_secret[PUB_KEY_SIZE];
|
||||
};
|
||||
};
|
||||
@@ -1,63 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <SPI.h>
|
||||
#include <Wire.h>
|
||||
// =============================================================================
|
||||
// GxEPDDisplay STUB for CrowPanel (and other non-e-ink LGFX targets)
|
||||
//
|
||||
// This file shadows src/helpers/ui/GxEPDDisplay.h to prevent the LovyanGFX vs
|
||||
// Adafruit_GFX GFXfont type collision at link time. MapScreen.h unconditionally
|
||||
// includes GxEPDDisplay.h and uses a GxEPDDisplay* member — this stub provides
|
||||
// the minimal API so that compilation and linking succeed.
|
||||
//
|
||||
// On CrowPanel the map screen is inert (no SD card when function switch is in
|
||||
// WM mode, no keyboard to navigate to it). The _einkDisplay pointer in
|
||||
// MapScreen will be a bad cast but is null-checked before every draw call.
|
||||
// =============================================================================
|
||||
|
||||
#define ENABLE_GxEPD2_GFX 0
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
|
||||
#include <GxEPD2_BW.h>
|
||||
#include <GxEPD2_3C.h>
|
||||
#include <GxEPD2_4C.h>
|
||||
#include <GxEPD2_7C.h>
|
||||
#include <Fonts/FreeSans9pt7b.h>
|
||||
#include <Fonts/FreeSansBold12pt7b.h>
|
||||
#include <Fonts/FreeSans18pt7b.h>
|
||||
#include <CRC32.h>
|
||||
|
||||
#include "DisplayDriver.h"
|
||||
// GxEPD color constants referenced by MapScreen.h
|
||||
#ifndef GxEPD_BLACK
|
||||
#define GxEPD_BLACK 0
|
||||
#define GxEPD_WHITE 1
|
||||
#endif
|
||||
|
||||
class GxEPDDisplay : public DisplayDriver {
|
||||
|
||||
#if defined(EINK_DISPLAY_MODEL)
|
||||
GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT> display;
|
||||
const float scale_x = EINK_SCALE_X;
|
||||
const float scale_y = EINK_SCALE_Y;
|
||||
const float offset_x = EINK_X_OFFSET;
|
||||
const float offset_y = EINK_Y_OFFSET;
|
||||
#else
|
||||
GxEPD2_BW<GxEPD2_150_BN, 200> display;
|
||||
const float scale_x = 1.5625f;
|
||||
const float scale_y = 1.5625f;
|
||||
const float offset_x = 0;
|
||||
const float offset_y = 10;
|
||||
#endif
|
||||
bool _init = false;
|
||||
bool _isOn = false;
|
||||
uint16_t _curr_color;
|
||||
CRC32 display_crc;
|
||||
int last_display_crc_value = 0;
|
||||
|
||||
public:
|
||||
#if defined(EINK_DISPLAY_MODEL)
|
||||
GxEPDDisplay() : DisplayDriver(128, 128), display(EINK_DISPLAY_MODEL(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {}
|
||||
#else
|
||||
GxEPDDisplay() : DisplayDriver(128, 128), display(GxEPD2_150_BN(DISP_CS, DISP_DC, DISP_RST, DISP_BUSY)) {}
|
||||
#endif
|
||||
GxEPDDisplay() : DisplayDriver(128, 128) {}
|
||||
|
||||
bool begin();
|
||||
// --- MapScreen raw pixel API (stubs) ---
|
||||
void drawPixelRaw(int16_t x, int16_t y, uint16_t color) {}
|
||||
int16_t rawWidth() { return 0; }
|
||||
int16_t rawHeight() { return 0; }
|
||||
void drawTextRaw(int16_t x, int16_t y, const char* text, uint16_t color) {}
|
||||
void invalidateFrameCRC() {}
|
||||
|
||||
bool isOn() override {return _isOn;};
|
||||
void turnOn() override;
|
||||
void turnOff() override;
|
||||
void clear() override;
|
||||
void startFrame(Color bkg = DARK) override;
|
||||
void setTextSize(int sz) override;
|
||||
void setColor(Color c) override;
|
||||
void setCursor(int x, int y) override;
|
||||
void print(const char* str) override;
|
||||
void fillRect(int x, int y, int w, int h) override;
|
||||
void drawRect(int x, int y, int w, int h) override;
|
||||
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
|
||||
uint16_t getTextWidth(const char* str) override;
|
||||
void endFrame() override;
|
||||
};
|
||||
// --- DisplayDriver pure virtuals (no-op implementations) ---
|
||||
bool isOn() override { return false; }
|
||||
void turnOn() override {}
|
||||
void turnOff() override {}
|
||||
void clear() override {}
|
||||
void startFrame(Color bkg = DARK) override {}
|
||||
void setTextSize(int sz) override {}
|
||||
void setColor(Color c) override {}
|
||||
void setCursor(int x, int y) override {}
|
||||
void print(const char* str) override {}
|
||||
void fillRect(int x, int y, int w, int h) override {}
|
||||
void drawRect(int x, int y, int w, int h) override {}
|
||||
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override {}
|
||||
uint16_t getTextWidth(const char* str) override { return 0; }
|
||||
void endFrame() override {}
|
||||
};
|
||||
74
variants/crowpanel_70/CrowPanel70Board.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include <helpers/ESP32Board.h>
|
||||
|
||||
// CrowPanel 7.0" Advance Series
|
||||
//
|
||||
// V1.0: PCA9557/TCA9534 I/O expander at 0x18 for display power/reset/backlight
|
||||
// V1.2: STC8H1K28 MCU at 0x30 (6-step brightness)
|
||||
// V1.3: STC8H1K28 MCU at 0x30 (246-step brightness)
|
||||
//
|
||||
// I2C bus (shared between touch GT911, RTC at 0x51, and I/O controller)
|
||||
// SDA = IO15, SCL = IO16
|
||||
//
|
||||
// Function select switch (active-low DIP switches on PCB rear):
|
||||
// S1=0 S0=0 → MIC & SPK (IO4/5/6 to speaker, IO19/20 to mic)
|
||||
// S1=0 S0=1 → WM (IO4/5/6 + IO19/20 to wireless module) ← REQUIRED FOR LORA
|
||||
// S1=1 S0=1 → MIC & TF Card (IO4/5/6 to SD, IO19/20 to mic)
|
||||
|
||||
#define PIN_BOARD_SDA 15
|
||||
#define PIN_BOARD_SCL 16
|
||||
|
||||
// Touch pins (GT911 at 0x5D)
|
||||
#define PIN_TOUCH_SDA PIN_BOARD_SDA
|
||||
#define PIN_TOUCH_SCL PIN_BOARD_SCL
|
||||
#define PIN_TOUCH_INT 1 // IO1_TP_INT (active low pulse, not level-based)
|
||||
#define PIN_TOUCH_RST -1 // Controlled via STC8H1K28 P1.7 (v1.3) or TCA9534 (v1.0)
|
||||
|
||||
// STC8H1K28 I2C address (v1.2/v1.3 only)
|
||||
#define STC8H_ADDR 0x30
|
||||
|
||||
class CrowPanel70Board : public ESP32Board {
|
||||
public:
|
||||
void begin() {
|
||||
// NOTE: Wire.begin(SDA, SCL) is called in target.cpp radio_init() BEFORE
|
||||
// lcd.init(), to ensure correct init order for v1.3 STC8H1K28 backlight.
|
||||
// Do NOT call Wire.begin() here — it would conflict with LovyanGFX's
|
||||
// internal Wire usage for the GT911 touch controller.
|
||||
|
||||
ESP32Board::begin();
|
||||
}
|
||||
|
||||
const char* getManufacturerName() const override {
|
||||
#ifdef CROWPANEL_V13
|
||||
return "CrowPanel 7.0 V1.3";
|
||||
#else
|
||||
return "CrowPanel 7.0";
|
||||
#endif
|
||||
}
|
||||
|
||||
// --- STC8H1K28 control (v1.3) ---
|
||||
// These are safe to call on all versions; on v1.0 they'll talk to
|
||||
// a nonexistent I2C device and silently fail.
|
||||
|
||||
void setBacklightBrightness(uint8_t level) {
|
||||
// 0 = max, 244 = min, 245 = off
|
||||
Wire.beginTransmission(STC8H_ADDR);
|
||||
Wire.write(level);
|
||||
Wire.endTransmission();
|
||||
}
|
||||
|
||||
void buzzerOn() {
|
||||
Wire.beginTransmission(STC8H_ADDR);
|
||||
Wire.write(246);
|
||||
Wire.endTransmission();
|
||||
}
|
||||
|
||||
void buzzerOff() {
|
||||
Wire.beginTransmission(STC8H_ADDR);
|
||||
Wire.write(247);
|
||||
Wire.endTransmission();
|
||||
}
|
||||
};
|
||||
33
variants/crowpanel_70/CrowPanel70Display.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
#include "LGFX_CrowPanel70.h"
|
||||
|
||||
// Custom display class for CrowPanel 7.0" that handles native landscape touch coordinates
|
||||
class CrowPanel70Display : public LGFXDisplay {
|
||||
public:
|
||||
CrowPanel70Display(int w, int h, LGFX_Device &disp) : LGFXDisplay(w, h, disp) {}
|
||||
|
||||
// Override getTouch for native landscape display (800x480)
|
||||
// The 7" panel is natively landscape, unlike the 3.5" which is portrait rotated
|
||||
//
|
||||
// GT911 touch coords are in physical space (0-799, 0-479).
|
||||
// We map them to logical space using:
|
||||
// display->width()/height() = physical LGFX dimensions (800, 480)
|
||||
// width()/height() = logical DisplayDriver dimensions (128, 64)
|
||||
bool getTouch(int *x, int *y) override {
|
||||
lgfx::v1::touch_point_t point;
|
||||
int touch_count = display->getTouch(&point);
|
||||
|
||||
if (touch_count > 0) {
|
||||
// Physical touch → logical coords
|
||||
*x = point.x * width() / display->width();
|
||||
*y = point.y * height() / display->height();
|
||||
return true;
|
||||
}
|
||||
|
||||
*x = -1;
|
||||
*y = -1;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
204
variants/crowpanel_70/LGFX_CrowPanel70.h
Normal file
@@ -0,0 +1,204 @@
|
||||
#pragma once
|
||||
|
||||
#define LGFX_USE_V1
|
||||
#include <LovyanGFX.hpp>
|
||||
|
||||
#ifndef CROWPANEL_V13
|
||||
// V1.0 uses TCA9534 I/O expander at 0x18
|
||||
#include <TCA9534.h>
|
||||
#endif
|
||||
|
||||
#include <lgfx/v1/platforms/esp32s3/Panel_RGB.hpp>
|
||||
#include <lgfx/v1/platforms/esp32s3/Bus_RGB.hpp>
|
||||
|
||||
// CrowPanel 7.0" uses RGB parallel interface (16-bit) with:
|
||||
// V1.0: TCA9534 I/O expander at 0x18 for display power/reset/backlight
|
||||
// V1.2/V1.3: STC8H1K28 MCU at 0x30 for backlight, buzzer, speaker control
|
||||
//
|
||||
// Display: 800x480 native landscape, ST7277 driver IC
|
||||
// Touch: GT911 at 0x5D on I2C (SDA=15, SCL=16)
|
||||
//
|
||||
// STC8H1K28 (v1.3) command byte reference:
|
||||
// 0 = backlight max brightness
|
||||
// 1-244 = backlight dimmer (244 = dimmest)
|
||||
// 245 = backlight off
|
||||
// 246 = buzzer on
|
||||
// 247 = buzzer off
|
||||
// 248 = speaker amp on
|
||||
// 249 = speaker amp off
|
||||
|
||||
#define STC8H_I2C_ADDR 0x30
|
||||
|
||||
class LGFX_CrowPanel70 : public lgfx::LGFX_Device {
|
||||
lgfx::Bus_RGB _bus_instance;
|
||||
lgfx::Panel_RGB _panel_instance;
|
||||
lgfx::Touch_GT911 _touch_instance;
|
||||
|
||||
#ifndef CROWPANEL_V13
|
||||
TCA9534 _ioex;
|
||||
#endif
|
||||
|
||||
public:
|
||||
static constexpr uint16_t SCREEN_WIDTH = 800;
|
||||
static constexpr uint16_t SCREEN_HEIGHT = 480;
|
||||
|
||||
// --- STC8H1K28 helper (v1.3) ---
|
||||
static void sendSTC8Command(uint8_t cmd) {
|
||||
Wire.beginTransmission(STC8H_I2C_ADDR);
|
||||
Wire.write(cmd);
|
||||
Wire.endTransmission();
|
||||
}
|
||||
|
||||
// Set backlight brightness: 0 = max, 244 = min, 245 = off
|
||||
static void setBacklight(uint8_t brightness) {
|
||||
#ifdef CROWPANEL_V13
|
||||
sendSTC8Command(brightness);
|
||||
#endif
|
||||
}
|
||||
|
||||
static void buzzerOn() { sendSTC8Command(246); }
|
||||
static void buzzerOff() { sendSTC8Command(247); }
|
||||
static void speakerOn() { sendSTC8Command(248); }
|
||||
static void speakerOff() { sendSTC8Command(249); }
|
||||
|
||||
bool init_impl(bool use_reset, bool use_clear) override {
|
||||
|
||||
#ifdef CROWPANEL_V13
|
||||
// ---- V1.3: All I2C + GPIO init done externally in target.cpp ----
|
||||
// Wire.begin(), STC8H backlight, and GPIO1 touch reset pulse are
|
||||
// called BEFORE lcd.init() to avoid Wire double-init conflicts and
|
||||
// ensure the display is powered before Panel_RGB allocates framebuffer.
|
||||
|
||||
#else
|
||||
// ---- V1.0: TCA9534 init ----
|
||||
_ioex.attach(Wire);
|
||||
_ioex.setDeviceAddress(0x18);
|
||||
|
||||
// Configure TCA9534 pins as outputs
|
||||
_ioex.config(1, TCA9534::Config::OUT); // Display power
|
||||
_ioex.config(2, TCA9534::Config::OUT); // Display reset
|
||||
_ioex.config(3, TCA9534::Config::OUT); // Not used
|
||||
_ioex.config(4, TCA9534::Config::OUT); // Backlight
|
||||
|
||||
// Power on display
|
||||
_ioex.output(1, TCA9534::Level::H);
|
||||
|
||||
// Reset sequence
|
||||
pinMode(1, OUTPUT);
|
||||
digitalWrite(1, LOW);
|
||||
_ioex.output(2, TCA9534::Level::L);
|
||||
delay(20);
|
||||
_ioex.output(2, TCA9534::Level::H);
|
||||
delay(100);
|
||||
pinMode(1, INPUT);
|
||||
|
||||
// Turn on backlight
|
||||
_ioex.output(4, TCA9534::Level::H);
|
||||
#endif
|
||||
|
||||
return LGFX_Device::init_impl(use_reset, use_clear);
|
||||
}
|
||||
|
||||
LGFX_CrowPanel70(void) {
|
||||
// Panel configuration
|
||||
{
|
||||
auto cfg = _panel_instance.config();
|
||||
cfg.memory_width = SCREEN_WIDTH;
|
||||
cfg.memory_height = SCREEN_HEIGHT;
|
||||
cfg.panel_width = SCREEN_WIDTH;
|
||||
cfg.panel_height = SCREEN_HEIGHT;
|
||||
cfg.offset_x = 0;
|
||||
cfg.offset_y = 0;
|
||||
cfg.offset_rotation = 0; // Panel_RGB: rotation not supported via offset_rotation
|
||||
// Display renders in portrait (480x800 physical). Landscape rotation will
|
||||
// be handled by swapping the CrowPanel70Display coordinate mapping.
|
||||
_panel_instance.config(cfg);
|
||||
}
|
||||
|
||||
// Panel detail configuration
|
||||
{
|
||||
auto cfg = _panel_instance.config_detail();
|
||||
cfg.use_psram = 1; // Use PSRAM for frame buffer
|
||||
_panel_instance.config_detail(cfg);
|
||||
}
|
||||
|
||||
// RGB bus configuration
|
||||
// Pin mapping is identical across all CrowPanel 7" hardware versions
|
||||
{
|
||||
auto cfg = _bus_instance.config();
|
||||
cfg.panel = &_panel_instance;
|
||||
|
||||
// Blue (B3-B7 on panel, 5 bits)
|
||||
cfg.pin_d0 = 21; // B3
|
||||
cfg.pin_d1 = 47; // B4
|
||||
cfg.pin_d2 = 48; // B5
|
||||
cfg.pin_d3 = 45; // B6
|
||||
cfg.pin_d4 = 38; // B7
|
||||
|
||||
// Green (G2-G7 on panel, 6 bits)
|
||||
cfg.pin_d5 = 9; // G2
|
||||
cfg.pin_d6 = 10; // G3
|
||||
cfg.pin_d7 = 11; // G4
|
||||
cfg.pin_d8 = 12; // G5
|
||||
cfg.pin_d9 = 13; // G6
|
||||
cfg.pin_d10 = 14; // G7
|
||||
|
||||
// Red (R3-R7 on panel, 5 bits)
|
||||
cfg.pin_d11 = 7; // R3
|
||||
cfg.pin_d12 = 17; // R4
|
||||
cfg.pin_d13 = 18; // R5
|
||||
cfg.pin_d14 = 3; // R6
|
||||
cfg.pin_d15 = 46; // R7
|
||||
|
||||
// Control pins
|
||||
cfg.pin_henable = 42; // DE (Data Enable)
|
||||
cfg.pin_vsync = 41; // VSYNC
|
||||
cfg.pin_hsync = 40; // HSYNC
|
||||
cfg.pin_pclk = 39; // Pixel clock (DCLK)
|
||||
|
||||
// Timing configuration (14MHz pixel clock for 7" display)
|
||||
cfg.freq_write = 14000000;
|
||||
|
||||
// Horizontal timing
|
||||
cfg.hsync_polarity = 0;
|
||||
cfg.hsync_front_porch = 8;
|
||||
cfg.hsync_pulse_width = 4;
|
||||
cfg.hsync_back_porch = 8;
|
||||
|
||||
// Vertical timing
|
||||
cfg.vsync_polarity = 0;
|
||||
cfg.vsync_front_porch = 8;
|
||||
cfg.vsync_pulse_width = 4;
|
||||
cfg.vsync_back_porch = 8;
|
||||
|
||||
// Clock configuration
|
||||
cfg.pclk_idle_high = 1;
|
||||
cfg.pclk_active_neg = 0;
|
||||
|
||||
_bus_instance.config(cfg);
|
||||
}
|
||||
_panel_instance.setBus(&_bus_instance);
|
||||
|
||||
// Touch configuration (GT911 at 0x5D)
|
||||
{
|
||||
auto cfg = _touch_instance.config();
|
||||
cfg.x_min = 0;
|
||||
cfg.x_max = SCREEN_WIDTH - 1;
|
||||
cfg.y_min = 0;
|
||||
cfg.y_max = SCREEN_HEIGHT - 1;
|
||||
cfg.pin_int = -1; // IO1 is TP_INT but we poll, not interrupt-driven
|
||||
cfg.pin_rst = -1; // Reset via STC8H1K28 (v1.3) or TCA9534 (v1.0)
|
||||
cfg.bus_shared = true;
|
||||
cfg.offset_rotation = 0; // Match panel config
|
||||
cfg.i2c_port = 0;
|
||||
cfg.i2c_addr = 0x5D;
|
||||
cfg.pin_sda = 15;
|
||||
cfg.pin_scl = 16;
|
||||
cfg.freq = 400000;
|
||||
_touch_instance.config(cfg);
|
||||
_panel_instance.setTouch(&_touch_instance);
|
||||
}
|
||||
|
||||
setPanel(&_panel_instance);
|
||||
}
|
||||
};
|
||||
70
variants/crowpanel_70/cpupowermanager.h
Normal file
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// CPU Frequency Scaling for ESP32-S3
|
||||
//
|
||||
// Typical current draw (CPU only, rough):
|
||||
// 240 MHz ~70-80 mA
|
||||
// 160 MHz ~50-60 mA
|
||||
// 80 MHz ~30-40 mA
|
||||
//
|
||||
// SPI peripherals and UART use their own clock dividers from the APB clock,
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz.
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
#ifndef CPU_FREQ_IDLE
|
||||
#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_BOOST
|
||||
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
|
||||
#endif
|
||||
|
||||
#ifndef CPU_BOOST_TIMEOUT_MS
|
||||
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
|
||||
#endif
|
||||
|
||||
class CPUPowerManager {
|
||||
public:
|
||||
CPUPowerManager() : _boosted(false), _boost_started(0) {}
|
||||
|
||||
void begin() {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
|
||||
setIdle();
|
||||
}
|
||||
}
|
||||
|
||||
void setBoost() {
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_BOOST);
|
||||
_boosted = true;
|
||||
MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST);
|
||||
}
|
||||
_boost_started = millis();
|
||||
}
|
||||
|
||||
void setIdle() {
|
||||
if (_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
bool isBoosted() const { return _boosted; }
|
||||
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
|
||||
|
||||
private:
|
||||
bool _boosted;
|
||||
unsigned long _boost_started;
|
||||
};
|
||||
|
||||
#endif // ESP32
|
||||
44
variants/crowpanel_70/pins_arduino.h
Normal file
@@ -0,0 +1,44 @@
|
||||
#ifndef Pins_Arduino_h
|
||||
#define Pins_Arduino_h
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// USB Serial (native USB CDC on ESP32-S3)
|
||||
static const uint8_t TX = 43;
|
||||
static const uint8_t RX = 44;
|
||||
|
||||
// I2C (shared between touch GT911, RTC PCF85063 at 0x51, STC8H1K28 at 0x30)
|
||||
static const uint8_t SDA = 15;
|
||||
static const uint8_t SCL = 16;
|
||||
|
||||
// Default SPI — mapped to wireless module slot (active when function switch = WM)
|
||||
// V1.3 LoRa pin mapping (confirmed from board silkscreen):
|
||||
// NSS=19, DIO1=20, RESET=8, BUSY=2, SPI on 4/5/6
|
||||
// V1.0 LoRa pin mapping (original):
|
||||
// NSS=0, DIO1=20, RESET=19, BUSY=2, SPI on 4/5/6
|
||||
#ifdef CROWPANEL_V13
|
||||
static const uint8_t SS = 20; // LoRa NSS (V1.3: GPIO20, confirmed by SPI probe)
|
||||
#else
|
||||
static const uint8_t SS = 0; // LoRa NSS (V1.0: on boot strapping pin)
|
||||
#endif
|
||||
static const uint8_t MOSI = 6; // LoRa MOSI / SD MOSI / I2S LRCLK (shared)
|
||||
static const uint8_t MISO = 4; // LoRa MISO / SD MISO / I2S SDIN (shared)
|
||||
static const uint8_t SCK = 5; // LoRa SCK / SD CLK / I2S BCLK (shared)
|
||||
|
||||
// Analog pins
|
||||
static const uint8_t A0 = 1;
|
||||
static const uint8_t A1 = 2;
|
||||
static const uint8_t A2 = 3;
|
||||
static const uint8_t A3 = 4;
|
||||
static const uint8_t A4 = 5;
|
||||
static const uint8_t A5 = 6;
|
||||
|
||||
// Touch pins
|
||||
static const uint8_t T1 = 1;
|
||||
static const uint8_t T2 = 2;
|
||||
static const uint8_t T3 = 3;
|
||||
static const uint8_t T4 = 4;
|
||||
static const uint8_t T5 = 5;
|
||||
static const uint8_t T6 = 6;
|
||||
|
||||
#endif /* Pins_Arduino_h */
|
||||
143
variants/crowpanel_70/platformio.ini
Normal file
@@ -0,0 +1,143 @@
|
||||
; =============================================================================
|
||||
; CrowPanel 7.0" Advance Series — shared base configuration
|
||||
; =============================================================================
|
||||
; Display: 800x480 IPS, ST7277 driver, RGB parallel 16-bit
|
||||
; Touch: GT911 at I2C 0x5D
|
||||
; MCU: ESP32-S3-WROOM-1-N16R8 (16MB flash, 8MB OPSRAM)
|
||||
;
|
||||
; IMPORTANT: The PCB function select DIP switch (rear of board) MUST be set
|
||||
; to WM mode (S0=1, S1=0) for LoRa wireless module operation.
|
||||
; =============================================================================
|
||||
|
||||
[crowpanel_70_base]
|
||||
extends = esp32_base
|
||||
board = crowpanel
|
||||
build_flags =
|
||||
${esp32_base.build_flags}
|
||||
-I variants/crowpanel_70
|
||||
-D CROWPANEL_70
|
||||
; Route Arduino Serial to UART0 (CH340 USB bridge) instead of native USB CDC.
|
||||
; The crowpanel.json board def sets CDC_ON_BOOT=1 but the user monitors via
|
||||
; the CH340 port (/dev/cu.wchusbserial*), not the native USB-C port.
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=0
|
||||
; I2C pins (shared: touch, RTC, I/O controller)
|
||||
-D PIN_BOARD_SDA=15
|
||||
-D PIN_BOARD_SCL=16
|
||||
; Radio configuration (common across versions)
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=true
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=3.3
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D LORA_TX_POWER=22
|
||||
; SPI bus pins (same all versions — active when function switch = WM)
|
||||
-D P_LORA_MOSI=6
|
||||
-D P_LORA_MISO=4
|
||||
-D P_LORA_SCLK=5
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/crowpanel_70>
|
||||
+<helpers/sensors>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
; CRITICAL: Use LovyanGFX 1.2.0 — v1.2.7 breaks 7" RGB panel init!
|
||||
lovyan03/LovyanGFX@1.2.0
|
||||
; PNGdec for map tile rendering
|
||||
bitbank2/PNGdec@^1.1.1
|
||||
; NOTE: GxEPD2 + Adafruit GFX NOT needed — CrowPanel variant shadows
|
||||
; GxEPDDisplay.h with a stub to avoid LovyanGFX/Adafruit_GFX type collision
|
||||
|
||||
; =============================================================================
|
||||
; V1.3 hardware (current production — STC8H1K28 at 0x30)
|
||||
; =============================================================================
|
||||
; LoRa pins confirmed from V1.3 board silkscreen (wireless module connector):
|
||||
; IO19=NSS, IO20=DIO1, IO8=RESET, IO2=BUSY
|
||||
; IO8 was freed when buzzer moved from direct GPIO to STC8H1K28
|
||||
; =============================================================================
|
||||
|
||||
[crowpanel_70_v13]
|
||||
extends = crowpanel_70_base
|
||||
build_flags =
|
||||
${crowpanel_70_base.build_flags}
|
||||
-D CROWPANEL_V13
|
||||
; V1.3 LoRa pin mapping (confirmed by SPI probe: NSS=GPIO20, DIO1=GPIO19)
|
||||
-D P_LORA_NSS=20
|
||||
-D P_LORA_DIO_1=19
|
||||
-D P_LORA_RESET=8
|
||||
-D P_LORA_BUSY=2
|
||||
lib_deps =
|
||||
${crowpanel_70_base.lib_deps}
|
||||
; No TCA9534 needed — V1.3 uses STC8H1K28 controlled via raw I2C writes
|
||||
|
||||
[env:crowpanel_70_v13_companion_radio_ble]
|
||||
extends = crowpanel_70_v13
|
||||
build_flags =
|
||||
${crowpanel_70_v13.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D DISPLAY_CLASS=CrowPanel70Display
|
||||
; Scale factors: 800x480 physical -> 128x64 logical
|
||||
; 800/128 = 6.25, 480/64 = 7.5
|
||||
-D DISPLAY_SCALE_X=6.25
|
||||
-D DISPLAY_SCALE_Y=7.5
|
||||
-D AUTO_OFF_MILLIS=30000
|
||||
-D HAS_TOUCH_SCREEN=1
|
||||
-D NO_BATTERY_INDICATOR=1
|
||||
-D MESH_DEBUG=1
|
||||
build_src_filter = ${crowpanel_70_v13.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/LGFXDisplay.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${crowpanel_70_v13.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
; =============================================================================
|
||||
; V1.0 hardware (original — TCA9534/PCA9557 at 0x18)
|
||||
; =============================================================================
|
||||
; LoRa pins for V1.0:
|
||||
; IO0=NSS (boot pin!), IO20=DIO1, IO19=RESET, IO2=BUSY
|
||||
; =============================================================================
|
||||
|
||||
[crowpanel_70_v10]
|
||||
extends = crowpanel_70_base
|
||||
build_flags =
|
||||
${crowpanel_70_base.build_flags}
|
||||
; V1.0 LoRa pin mapping
|
||||
-D P_LORA_NSS=0
|
||||
-D P_LORA_DIO_1=20
|
||||
-D P_LORA_RESET=19
|
||||
-D P_LORA_BUSY=2
|
||||
lib_deps =
|
||||
${crowpanel_70_base.lib_deps}
|
||||
; TCA9534 I/O expander for display power control (V1.0 only)
|
||||
hideakitai/TCA9534@0.1.1
|
||||
|
||||
[env:crowpanel_70_v10_companion_radio_ble]
|
||||
extends = crowpanel_70_v10
|
||||
build_flags =
|
||||
${crowpanel_70_v10.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D DISPLAY_CLASS=CrowPanel70Display
|
||||
-D DISPLAY_SCALE_X=6.25
|
||||
-D DISPLAY_SCALE_Y=7.5
|
||||
-D AUTO_OFF_MILLIS=30000
|
||||
-D HAS_TOUCH_SCREEN=1
|
||||
-D NO_BATTERY_INDICATOR=1
|
||||
-D MESH_DEBUG=1
|
||||
build_src_filter = ${crowpanel_70_v10.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/LGFXDisplay.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${crowpanel_70_v10.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
227
variants/crowpanel_70/target.cpp
Normal file
@@ -0,0 +1,227 @@
|
||||
#include <Arduino.h>
|
||||
#include "target.h"
|
||||
|
||||
CrowPanel70Board board;
|
||||
|
||||
// SPI bus for LoRa — use HSPI (SPI3_HOST). Pass -1 for SS so RadioLib
|
||||
// can manually toggle NSS via digitalWrite (hardware CS conflicts with this).
|
||||
static SPIClass spi(HSPI);
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);
|
||||
|
||||
WRAPPER_CLASS radio_driver(radio, board);
|
||||
|
||||
ESP32RTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
|
||||
EnvironmentSensorManager sensors;
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
LGFX_CrowPanel70 lcd;
|
||||
CrowPanel70Display display(800, 480, lcd);
|
||||
#ifndef HAS_TOUCH_SCREEN
|
||||
TouchButton user_btn(&display);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
delay(1000);
|
||||
|
||||
#ifdef CROWPANEL_V13
|
||||
Serial.println("\n\n=== CrowPanel 7.0 V1.3 MeshCore ===");
|
||||
#else
|
||||
Serial.println("\n\n=== CrowPanel 7.0 MeshCore ===");
|
||||
#endif
|
||||
Serial.println("Initializing...");
|
||||
Serial.printf(" LoRa pins: NSS=%d DIO1=%d RST=%d BUSY=%d\n",
|
||||
P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY);
|
||||
Serial.printf(" SPI pins: MOSI=%d MISO=%d SCK=%d\n",
|
||||
P_LORA_MOSI, P_LORA_MISO, P_LORA_SCLK);
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
#ifdef CROWPANEL_V13
|
||||
Serial.println("Step 1: STC8H1K28 backlight ON...");
|
||||
Wire.beginTransmission(0x30);
|
||||
Wire.write(0);
|
||||
Wire.endTransmission();
|
||||
#endif
|
||||
|
||||
Serial.println("Step 2: Touch reset pulse...");
|
||||
pinMode(1, OUTPUT);
|
||||
digitalWrite(1, LOW);
|
||||
delay(120);
|
||||
pinMode(1, INPUT);
|
||||
|
||||
// NO ST7277 SPI init — MADCTL breaks RGB mode on this panel.
|
||||
// Rotation handled by LovyanGFX offset_rotation in constructor.
|
||||
|
||||
Serial.println("Step 3: lcd.init()...");
|
||||
lcd.init();
|
||||
Serial.printf(" LGFX: %dx%d\n", lcd.width(), lcd.height());
|
||||
|
||||
Serial.println("Step 4: display.begin()...");
|
||||
display.begin();
|
||||
#ifndef HAS_TOUCH_SCREEN
|
||||
user_btn.begin();
|
||||
#endif
|
||||
Serial.println("Display ready");
|
||||
|
||||
// Orientation test
|
||||
lcd.fillScreen(0x0000);
|
||||
int w = lcd.width();
|
||||
int h = lcd.height();
|
||||
lcd.fillRect(0, 0, 60, 40, 0xF800);
|
||||
lcd.fillRect(w-60, 0, 60, 40, 0x07E0);
|
||||
lcd.fillRect(0, h-40, 60, 40, 0x001F);
|
||||
lcd.fillRect(w-60, h-40, 60, 40, 0xFFE0);
|
||||
lcd.setTextColor(0xFFFF);
|
||||
lcd.setTextSize(2);
|
||||
lcd.setCursor(80, 10);
|
||||
lcd.printf("LGFX: %dx%d", w, h);
|
||||
lcd.setCursor(80, 40);
|
||||
lcd.print("CrowPanel 7.0 V1.3 + Meck");
|
||||
lcd.setCursor(80, 70);
|
||||
lcd.print("RED=TL GRN=TR BLU=BL YEL=BR");
|
||||
lcd.setCursor(80, 100);
|
||||
lcd.print("Testing LoRa...");
|
||||
#endif
|
||||
|
||||
Serial.println("Initializing RTC...");
|
||||
fallback_clock.begin();
|
||||
rtc_clock.begin(Wire);
|
||||
|
||||
// --- LoRa SPI diagnostic: bitbang to bypass peripheral ---
|
||||
Serial.println("Initializing LoRa radio...");
|
||||
Serial.println(" Hardware SPI returned all zeros — trying bitbang to test physical wiring\n");
|
||||
|
||||
// Reset radio
|
||||
pinMode(P_LORA_RESET, OUTPUT);
|
||||
digitalWrite(P_LORA_RESET, LOW);
|
||||
delay(20);
|
||||
digitalWrite(P_LORA_RESET, HIGH);
|
||||
delay(100);
|
||||
pinMode(P_LORA_BUSY, INPUT);
|
||||
unsigned long bs = millis();
|
||||
while (digitalRead(P_LORA_BUSY) && (millis() - bs < 1000)) delay(1);
|
||||
Serial.printf(" BUSY after reset: %s (%ldms)\n",
|
||||
digitalRead(P_LORA_BUSY) ? "HIGH" : "LOW", millis() - bs);
|
||||
|
||||
// Configure pins as GPIO (not SPI peripheral)
|
||||
pinMode(P_LORA_SCLK, OUTPUT); // GPIO5 = SCK
|
||||
pinMode(P_LORA_MOSI, OUTPUT); // GPIO6 = MOSI
|
||||
pinMode(P_LORA_MISO, INPUT); // GPIO4 = MISO
|
||||
pinMode(P_LORA_NSS, OUTPUT); // GPIO20 = NSS
|
||||
digitalWrite(P_LORA_SCLK, LOW);
|
||||
digitalWrite(P_LORA_NSS, HIGH);
|
||||
|
||||
// Bitbang SPI Mode 0: CPOL=0, CPHA=0
|
||||
// Clock idle LOW, data sampled on rising edge
|
||||
auto bbTransfer = [](uint8_t tx) -> uint8_t {
|
||||
uint8_t rx = 0;
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
// Set MOSI
|
||||
digitalWrite(P_LORA_MOSI, (tx >> i) & 1);
|
||||
delayMicroseconds(2);
|
||||
// Rising edge — slave clocks in MOSI, we read MISO
|
||||
digitalWrite(P_LORA_SCLK, HIGH);
|
||||
delayMicroseconds(2);
|
||||
if (digitalRead(P_LORA_MISO)) rx |= (1 << i);
|
||||
// Falling edge
|
||||
digitalWrite(P_LORA_SCLK, LOW);
|
||||
delayMicroseconds(2);
|
||||
}
|
||||
return rx;
|
||||
};
|
||||
|
||||
// Read register 0x0320 via bitbang
|
||||
Serial.println(" Bitbang SPI read of reg 0x0320:");
|
||||
digitalWrite(P_LORA_NSS, LOW);
|
||||
delayMicroseconds(50);
|
||||
uint8_t b0 = bbTransfer(0x1D); // ReadRegister
|
||||
uint8_t b1 = bbTransfer(0x03); // Addr high
|
||||
uint8_t b2 = bbTransfer(0x20); // Addr low
|
||||
uint8_t b3 = bbTransfer(0x00); // NOP (status)
|
||||
uint8_t b4 = bbTransfer(0x00); // Register value
|
||||
digitalWrite(P_LORA_NSS, HIGH);
|
||||
|
||||
Serial.printf(" SCK=%d MOSI=%d MISO=%d NSS=%d\n",
|
||||
P_LORA_SCLK, P_LORA_MOSI, P_LORA_MISO, P_LORA_NSS);
|
||||
Serial.printf(" bytes: %02X %02X %02X %02X %02X\n", b0, b1, b2, b3, b4);
|
||||
Serial.printf(" val=0x%02X %s\n", b4,
|
||||
b4 == 0x58 ? "<<< SX1262 FOUND via bitbang!" :
|
||||
(b4 == 0xFF ? "(no response)" :
|
||||
(b4 == 0x00 ? "(zeros — physical wiring issue)" : "")));
|
||||
|
||||
// Also try with MISO and MOSI swapped
|
||||
Serial.println("\n Bitbang with MISO=6, MOSI=4 (swapped):");
|
||||
pinMode(6, INPUT); // Now MISO
|
||||
pinMode(4, OUTPUT); // Now MOSI
|
||||
digitalWrite(4, LOW);
|
||||
|
||||
// Reset again
|
||||
digitalWrite(P_LORA_RESET, LOW);
|
||||
delay(20);
|
||||
digitalWrite(P_LORA_RESET, HIGH);
|
||||
delay(100);
|
||||
bs = millis();
|
||||
while (digitalRead(P_LORA_BUSY) && (millis() - bs < 500)) delay(1);
|
||||
|
||||
auto bbTransfer2 = [](uint8_t tx) -> uint8_t {
|
||||
uint8_t rx = 0;
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
digitalWrite(4, (tx >> i) & 1); // MOSI on GPIO4
|
||||
delayMicroseconds(2);
|
||||
digitalWrite(5, HIGH); // SCK
|
||||
delayMicroseconds(2);
|
||||
if (digitalRead(6)) rx |= (1 << i); // MISO on GPIO6
|
||||
digitalWrite(5, LOW);
|
||||
delayMicroseconds(2);
|
||||
}
|
||||
return rx;
|
||||
};
|
||||
|
||||
digitalWrite(P_LORA_NSS, LOW);
|
||||
delayMicroseconds(50);
|
||||
b0 = bbTransfer2(0x1D);
|
||||
b1 = bbTransfer2(0x03);
|
||||
b2 = bbTransfer2(0x20);
|
||||
b3 = bbTransfer2(0x00);
|
||||
b4 = bbTransfer2(0x00);
|
||||
digitalWrite(P_LORA_NSS, HIGH);
|
||||
|
||||
Serial.printf(" SCK=5 MOSI=4 MISO=6 NSS=%d\n", P_LORA_NSS);
|
||||
Serial.printf(" bytes: %02X %02X %02X %02X %02X\n", b0, b1, b2, b3, b4);
|
||||
Serial.printf(" val=0x%02X %s\n", b4,
|
||||
b4 == 0x58 ? "<<< SX1262 FOUND (MISO/MOSI swapped)!" :
|
||||
(b4 == 0xFF ? "(no response)" :
|
||||
(b4 == 0x00 ? "(zeros)" : "")));
|
||||
|
||||
// Restore pins for hardware SPI attempt
|
||||
spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, -1);
|
||||
bool result = radio.std_init(&spi);
|
||||
if (result) {
|
||||
Serial.println("LoRa: OK!");
|
||||
#ifdef DISPLAY_CLASS
|
||||
lcd.setTextColor(0x07E0);
|
||||
lcd.setCursor(80, 130);
|
||||
lcd.print("LoRa: OK!");
|
||||
#endif
|
||||
} else {
|
||||
Serial.println("LoRa: FAILED");
|
||||
#ifdef DISPLAY_CLASS
|
||||
lcd.setTextColor(0xF800);
|
||||
lcd.setCursor(80, 130);
|
||||
lcd.print("LoRa: FAILED");
|
||||
#endif
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t radio_get_rng_seed() { return radio.random(0x7FFFFFFF); }
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setFrequency(freq); radio.setSpreadingFactor(sf);
|
||||
radio.setBandwidth(bw); radio.setCodingRate(cr);
|
||||
}
|
||||
void radio_set_tx_power(uint8_t dbm) { radio.setOutputPower(dbm); }
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng);
|
||||
}
|
||||
39
variants/crowpanel_70/target.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include "variant.h" // Board-specific defines (HAS_GPS, GPS_BAUDRATE, etc.)
|
||||
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <CrowPanel70Board.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/sensors/EnvironmentSensorManager.h>
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include <LGFX_CrowPanel70.h>
|
||||
#include <CrowPanel70Display.h>
|
||||
#ifndef HAS_TOUCH_SCREEN
|
||||
#include <TouchButton.h>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
extern CrowPanel70Board board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
extern LGFX_CrowPanel70 lcd;
|
||||
extern CrowPanel70Display display;
|
||||
#ifndef HAS_TOUCH_SCREEN
|
||||
extern TouchButton user_btn;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
bool radio_init();
|
||||
uint32_t radio_get_rng_seed();
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
|
||||
void radio_set_tx_power(uint8_t dbm);
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
150
variants/crowpanel_70/variant.h
Normal file
@@ -0,0 +1,150 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// CrowPanel 7.0" Advance Series (V1.0 / V1.3)
|
||||
// ESP32-S3-WROOM-1-N16R8 (16MB Flash, 8MB OPI-PSRAM)
|
||||
// 800x480 IPS display, ST7277 driver, RGB parallel 16-bit
|
||||
// GT911 capacitive touch at I2C 0x5D
|
||||
// =============================================================================
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// I2C Bus (shared: touch GT911, RTC PCF85063 at 0x51, STC8H1K28 at 0x30)
|
||||
// -----------------------------------------------------------------------------
|
||||
#define I2C_SDA 15
|
||||
#define I2C_SCL 16
|
||||
#define PIN_BOARD_SDA I2C_SDA
|
||||
#define PIN_BOARD_SCL I2C_SCL
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Display
|
||||
// -----------------------------------------------------------------------------
|
||||
#define LCD_HOR_SIZE 800
|
||||
#define LCD_VER_SIZE 480
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// LoRa Radio (SX1262) — via wireless module connector
|
||||
// Pin mapping depends on hardware version (defined in platformio.ini):
|
||||
// V1.3: NSS=19, DIO1=20, RESET=8, BUSY=2, SPI 4/5/6
|
||||
// V1.0: NSS=0, DIO1=20, RESET=19, BUSY=2, SPI 4/5/6
|
||||
//
|
||||
// IMPORTANT: Function select DIP switch must be set to WM mode (S0=1, S1=0)
|
||||
// for the SPI bus to be routed to the wireless module connector.
|
||||
// -----------------------------------------------------------------------------
|
||||
#define USE_SX1262
|
||||
#define USE_SX1268
|
||||
|
||||
// P_LORA_* pins are defined in platformio.ini per hardware version
|
||||
// RadioLib/MeshCore compat aliases (only define if not already set by platformio)
|
||||
#ifndef P_LORA_NSS
|
||||
#ifdef CROWPANEL_V13
|
||||
#define P_LORA_NSS 20
|
||||
#define P_LORA_DIO_1 19
|
||||
#define P_LORA_RESET 8
|
||||
#define P_LORA_BUSY 2
|
||||
#else
|
||||
#define P_LORA_NSS 0
|
||||
#define P_LORA_DIO_1 20
|
||||
#define P_LORA_RESET 19
|
||||
#define P_LORA_BUSY 2
|
||||
#endif
|
||||
#endif
|
||||
#ifndef P_LORA_SCLK
|
||||
#define P_LORA_SCLK 5
|
||||
#define P_LORA_MISO 4
|
||||
#define P_LORA_MOSI 6
|
||||
#endif
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GPS — not present on CrowPanel
|
||||
// Define a fallback GPS_BAUDRATE so that serial CLI code compiles cleanly
|
||||
// (the CLI GPS commands will be inert since HAS_GPS is not defined)
|
||||
// -----------------------------------------------------------------------------
|
||||
// #define HAS_GPS — intentionally NOT defined
|
||||
#ifndef GPS_BAUDRATE
|
||||
#define GPS_BAUDRATE 9600
|
||||
#endif
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SD Card — shares SPI bus with LoRa and speaker via function switch
|
||||
// When function switch is in WM mode, SD card is NOT accessible.
|
||||
// SD support can be enabled for standalone (non-LoRa) use with S1=1,S0=1
|
||||
// -----------------------------------------------------------------------------
|
||||
// #define HAS_SDCARD — not defined by default (conflicts with LoRa SPI)
|
||||
|
||||
// SDCARD_CS dummy: NotesScreen, TextReaderScreen, EpubZipReader reference this
|
||||
// unconditionally. -1 makes digitalWrite() a no-op on ESP32.
|
||||
#ifndef SDCARD_CS
|
||||
#define SDCARD_CS -1
|
||||
#endif
|
||||
|
||||
// E-ink display: not present. GxEPDDisplay.h is shadowed by a stub in the
|
||||
// variant include path to avoid LovyanGFX/Adafruit_GFX type collision.
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Battery — CrowPanel has a battery connector but no fuel gauge IC
|
||||
// Battery charging is handled by onboard circuit with CHG LED indicator
|
||||
// -----------------------------------------------------------------------------
|
||||
// #define HAS_BQ27220 — not present
|
||||
#define NO_BATTERY_INDICATOR 1
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Audio — I2S speaker (shared pins, only when function switch = MIC&SPK)
|
||||
// Not usable simultaneously with LoRa — commented out for reference
|
||||
// -----------------------------------------------------------------------------
|
||||
// #define BOARD_I2S_SDIN 4 // IO4 (shared with SPI MISO)
|
||||
// #define BOARD_I2S_BCLK 5 // IO5 (shared with SPI SCK)
|
||||
// #define BOARD_I2S_LRCLK 6 // IO6 (shared with SPI MOSI)
|
||||
|
||||
// MIC (v1.3: LMD3526B261 PDM mic)
|
||||
// #define BOARD_MIC_DATA 20 // IO20 (shared with LoRa DIO1)
|
||||
// #define BOARD_MIC_CLOCK 19 // IO19 (shared with LoRa NSS)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Touch Screen
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_TOUCHSCREEN 1
|
||||
|
||||
// Touch controller: GT911 at 0x5D
|
||||
// INT = IO1 (active-low pulse, used for GT911 address selection at boot)
|
||||
// RST = STC8H1K28 P1.7 (v1.3) or TCA9534 pin 2 (v1.0)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Keyboard — not present (touchscreen-only device)
|
||||
// -----------------------------------------------------------------------------
|
||||
// #define HAS_PHYSICAL_KEYBOARD — not present
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Buttons — CrowPanel is touchscreen-only
|
||||
// -----------------------------------------------------------------------------
|
||||
// BOOT button (GPIO0) is for programming only, not a user button.
|
||||
// Do NOT define PIN_USER_BTN — it would enable TouchButton code in UITask.cpp
|
||||
// but the TouchButton object is not instantiated (HAS_TOUCH_SCREEN suppresses it).
|
||||
// #define BUTTON_PIN 0
|
||||
// #define PIN_USER_BTN 0
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// UART
|
||||
// -----------------------------------------------------------------------------
|
||||
// UART0: TX=IO43, RX=IO44 (also USB CDC)
|
||||
// UART1: TX=IO20, RX=IO19 (shared with LoRa DIO1/NSS when in WM mode!)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// RTC — PCF85063 at I2C 0x51 (confirmed from board silkscreen)
|
||||
// -----------------------------------------------------------------------------
|
||||
// AutoDiscoverRTCClock will find it automatically on the I2C bus
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// STC8H1K28 I/O Controller (V1.2/V1.3 only)
|
||||
// I2C slave at address 0x30
|
||||
// Command bytes:
|
||||
// 0 = backlight max brightness
|
||||
// 1-244 = backlight dimmer
|
||||
// 245 = backlight off
|
||||
// 246 = buzzer on
|
||||
// 247 = buzzer off
|
||||
// 248 = speaker amp on
|
||||
// 249 = speaker amp off
|
||||
// -----------------------------------------------------------------------------
|
||||
#ifdef CROWPANEL_V13
|
||||
#define STC8H_I2C_ADDR 0x30
|
||||
#endif
|
||||
@@ -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 v1.0.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
|
||||
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
|
||||
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
|
||||
@@ -157,7 +192,37 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.5.4G"'
|
||||
-D FIRMWARE_VERSION='"Meck v1.0.4G"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
; 4G + WiFi companion (4G modem hardware with WiFi app bridging, no audio)
|
||||
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
|
||||
; Connect via MeshCore web app, meshcore.js, or Python CLI over local network.
|
||||
; WiFi credentials loaded from SD card at runtime (/web/wifi.cfg).
|
||||
; Configure via Settings > WiFi Setup, or through the web reader.
|
||||
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
|
||||
[env:meck_4g_wifi]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D MECK_WIFI_COMPANION=1
|
||||
-D TCP_PORT=5000
|
||||
-D WIFI_DEBUG_LOGGING=1
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.0.4G.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -183,7 +248,7 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.5.4G.SA"'
|
||||
-D FIRMWARE_VERSION='"Meck v1.0.4G.SA"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
|
||||
@@ -49,19 +49,11 @@ bool radio_init() {
|
||||
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - SPI initialized, calling radio.std_init()...");
|
||||
bool result = radio.std_init(&loraSpi);
|
||||
if (result) {
|
||||
radio.setPreambleLength(32);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("radio_init() - radio.std_init() returned: %s", result ? "SUCCESS" : "FAILED");
|
||||
return result;
|
||||
#else
|
||||
MESH_DEBUG_PRINTLN("radio_init() - calling radio.std_init() without custom SPI...");
|
||||
bool result = radio.std_init();
|
||||
if (result) {
|
||||
radio.setPreambleLength(32);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
|
||||
}
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
@@ -75,6 +67,14 @@ void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setSpreadingFactor(sf);
|
||||
radio.setBandwidth(bw);
|
||||
radio.setCodingRate(cr);
|
||||
|
||||
// Longer preamble for low SF improves reliability — each symbol is shorter
|
||||
// at low SF, so more symbols are needed for reliable detection.
|
||||
// SF <= 8 gets 32 symbols (~65ms at SF7/62.5kHz); SF >= 9 keeps 16 (already ~131ms+).
|
||||
// See: https://github.com/meshcore-dev/MeshCore/pull/1954
|
||||
uint16_t preamble = (sf <= 8) ? 32 : 16;
|
||||
radio.setPreambleLength(preamble);
|
||||
MESH_DEBUG_PRINTLN("radio_set_params() - bw=%.1f sf=%u preamble=%u", bw, sf, preamble);
|
||||
}
|
||||
|
||||
void radio_set_tx_power(uint8_t dbm) {
|
||||
|
||||