Compare commits
40 Commits
v0.9.5
...
multi-byte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2163a4c56c | ||
|
|
a536196fd7 | ||
|
|
01a7ab80eb | ||
|
|
44fe5da876 | ||
|
|
652d853b0c |
@@ -1,6 +1,8 @@
|
||||
## Meshcore + Fork = Meck
|
||||
This fork was created specifically to focus on enabling BLE companion firmware for the LilyGo T-Deck Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/b30ce6bd-79af-44d3-93c4-f5e7e21e5621" alt="IMG_1453" width="300" height="650">
|
||||
|
||||
### Contents
|
||||
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
|
||||
- [Navigation (Home Screen)](#navigation-home-screen)
|
||||
@@ -347,4 +349,4 @@ However, this firmware links against libraries with different license terms. Bec
|
||||
| [base64](https://github.com/Densaugeo/base64_arduino) | MIT | densaugeo |
|
||||
| [Arduino Crypto](https://github.com/rweather/arduinolibs) | MIT | Rhys Weatherley |
|
||||
|
||||
Full license texts for each dependency are available in their respective repositories linked above.
|
||||
Full license texts for each dependency are available in their respective repositories linked above.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
## SMS & Phone App (4G variant only) - Meck v0.9.3 (Alpha)
|
||||
## SMS & Phone App (4G variant only) - Meck v0.9.5
|
||||
|
||||
Press **T** from the home screen to open the SMS & Phone app.
|
||||
Requires a nano SIM card inserted in the T-Deck Pro V1.1 4G modem slot and an
|
||||
@@ -8,16 +8,31 @@ powered. The modem (and its red LED) can be switched off and on from the
|
||||
settings screen. After each modem startup, the system clock syncs from the
|
||||
cellular network, which takes roughly 15 seconds.
|
||||
|
||||
### App Menu
|
||||
|
||||
The SMS & Phone app opens to a landing screen with two options:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| **Phone** | Open the phone dialer to call any number |
|
||||
| **SMS Inbox** | Open the SMS inbox for messaging and calling saved contacts |
|
||||
|
||||
Use **W / S** to select an option and **Enter** to confirm. Press **Q** to
|
||||
return to the home screen.
|
||||
|
||||
### Key Mapping
|
||||
|
||||
| Context | Key | Action |
|
||||
|---------|-----|--------|
|
||||
| Home screen | T | Open SMS & Phone app |
|
||||
| App menu | W / S | Select Phone or SMS Inbox |
|
||||
| App menu | Enter | Open selected option |
|
||||
| App menu | Q | Back to home screen |
|
||||
| Inbox | W / S | Scroll conversations |
|
||||
| Inbox | Enter | Open conversation |
|
||||
| Inbox | C | Compose new SMS (enter phone number) |
|
||||
| Inbox | D | Open contacts directory |
|
||||
| Inbox | Q | Back to home screen |
|
||||
| Inbox | Q | Back to app menu |
|
||||
| Conversation | W / S | Scroll messages |
|
||||
| Conversation | C | Reply to this conversation |
|
||||
| Conversation | F | Call this number |
|
||||
@@ -31,6 +46,10 @@ cellular network, which takes roughly 15 seconds.
|
||||
| Contacts | Q | Back to inbox |
|
||||
| Edit Contact | Enter | Save contact name |
|
||||
| Edit Contact | Shift+Del | Cancel without saving |
|
||||
| Phone Dialer | 0–9, *, +, # | Enter phone number (see input methods below) |
|
||||
| Phone Dialer | Enter | Place call |
|
||||
| Phone Dialer | Backspace | Delete last digit |
|
||||
| Phone Dialer | Q | Back to app menu |
|
||||
| Dialing | Enter or Q | Cancel / hang up |
|
||||
| Incoming Call | Enter | Answer call |
|
||||
| Incoming Call | Q | Reject call |
|
||||
@@ -55,23 +74,20 @@ shown in the footer while composing.
|
||||
|
||||
### Making a Phone Call
|
||||
|
||||
Press **F** to call from either the conversation view or the contacts
|
||||
directory. The display switches to a dialing screen showing the contact name
|
||||
(or phone number) and an animated progress indicator. Once the remote party
|
||||
answers, the screen transitions to the in-call view with a live call timer.
|
||||
There are three ways to start a call:
|
||||
|
||||
There are two ways to start a call:
|
||||
|
||||
1. **From a conversation** — open a conversation and press **F**. You can call
|
||||
1. **From the phone dialer** — select **Phone** from the app menu to open the
|
||||
dialer. Enter a phone number and press **Enter** to call. This is the
|
||||
easiest way to call a number you haven't messaged before.
|
||||
2. **From a conversation** — open a conversation and press **F**. You can call
|
||||
any number you have previously exchanged messages with, whether or not it is
|
||||
saved as a named contact.
|
||||
2. **From the contacts directory** — press **D** from the inbox, scroll to a
|
||||
3. **From the contacts directory** — press **D** from the inbox, scroll to a
|
||||
contact, and press **F**.
|
||||
|
||||
> **Note:** There is currently no way to dial an arbitrary phone number without
|
||||
> first creating a conversation. To call a new number, press **C** from the
|
||||
> inbox to compose a new SMS, enter the phone number, send a short message,
|
||||
> then open the resulting conversation and press **F** to call.
|
||||
The display switches to a dialing screen showing the contact name (or phone
|
||||
number) and an animated progress indicator. Once the remote party answers, the
|
||||
screen transitions to the in-call view with a live call timer.
|
||||
|
||||
During an active call, **W** and **S** adjust the speaker volume (0–5). The
|
||||
number keys **0–9**, **\***, and **#** send DTMF tones for navigating phone
|
||||
@@ -80,6 +96,26 @@ menus and voicemail systems. Press **Enter** or **Q** to hang up.
|
||||
Audio is routed through the A7682E modem's internal codec to the board speaker
|
||||
and microphone — no headphones or external audio hardware are required.
|
||||
|
||||
### Phone Dialer Input Methods
|
||||
|
||||
The phone dialer supports three ways to enter digits:
|
||||
|
||||
1. **Direct key press** — press the keyboard letter that corresponds to each
|
||||
number using the silk-screened labels on the T-Deck Pro keys:
|
||||
|
||||
| Key | Digit | | Key | Digit | | Key | Digit |
|
||||
|-----|-------|-|-----|-------|-|-----|-------|
|
||||
| W | 1 | | S | 4 | | Z | 7 |
|
||||
| E | 2 | | D | 5 | | X | 8 |
|
||||
| R | 3 | | F | 6 | | C | 9 |
|
||||
| A | * | | O | + | | Mic | 0 |
|
||||
|
||||
2. **Touchscreen tap** — tap the on-screen number buttons directly. Note: this
|
||||
currently requires fairly precise taps on the numbers themselves.
|
||||
|
||||
3. **Sym+key** — the standard symbol entry method (e.g. Sym+W for 1, Sym+S for
|
||||
4, etc.)
|
||||
|
||||
### Receiving a Phone Call
|
||||
|
||||
When an incoming call arrives, the app automatically switches to the incoming
|
||||
@@ -134,6 +170,12 @@ call screens. Bars are derived from the modem's CSQ (signal quality) reading,
|
||||
updated every 30 seconds. The modem state (REG, READY, OFF, etc.) is shown
|
||||
when not yet connected. During a call, the signal indicator remains visible.
|
||||
|
||||
### IMEI, Carrier & APN
|
||||
|
||||
The 4G modem's IMEI, current carrier name, and APN are displayed at the bottom
|
||||
of the settings screen (press **S** from the home screen), alongside your node
|
||||
ID and firmware version.
|
||||
|
||||
### SD Card Structure
|
||||
|
||||
```
|
||||
@@ -158,7 +200,6 @@ SD Card
|
||||
| SMS sends but no delivery | Check signal strength; below 5 bars is marginal. Move to better coverage |
|
||||
| Call drops immediately after dialing | Check signal strength and ensure the SIM plan supports voice calls |
|
||||
| No audio during call | The A7682E routes audio through its own codec; ensure the board speaker is not obstructed. Try adjusting volume with W/S |
|
||||
| Cannot dial a number | You must first have a conversation or saved contact for that number. Send a short SMS to create a conversation, then press F |
|
||||
|
||||
> **Note:** The SMS & Phone app is only available on the 4G modem variant of
|
||||
> the T-Deck Pro. It is not present on the audio or standalone BLE builds due
|
||||
|
||||
315
Serial Settings Guide.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Meck Serial Settings Guide
|
||||
|
||||
Configure your T-Deck Pro's Meck firmware over USB serial — no companion app needed. Plug in a USB-C cable, open a serial terminal, and you have full access to every setting on the device.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### What You Need
|
||||
|
||||
- T-Deck Pro running Meck firmware
|
||||
- USB-C cable
|
||||
- A serial terminal application:
|
||||
- **Windows:** PuTTY, TeraTerm, or the Arduino IDE Serial Monitor
|
||||
- **macOS:** `screen`, CoolTerm, or the Arduino IDE Serial Monitor
|
||||
- **Linux:** `screen`, `minicom`, `picocom`, or the Arduino IDE Serial Monitor
|
||||
|
||||
### Connection Settings
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Baud rate | 115200 |
|
||||
| Data bits | 8 |
|
||||
| Parity | None |
|
||||
| Stop bits | 1 |
|
||||
| Line ending | CR (carriage return) or CR+LF |
|
||||
|
||||
### Quick Start (macOS / Linux)
|
||||
|
||||
```
|
||||
screen /dev/ttyACM0 115200
|
||||
```
|
||||
|
||||
On macOS the port is typically `/dev/cu.usbmodem*`. On Linux it is usually `/dev/ttyACM0` or `/dev/ttyUSB0`.
|
||||
|
||||
### Quick Start (Arduino IDE)
|
||||
|
||||
Open **Tools → Serial Monitor**, set baud to **115200** and line ending to **Carriage Return** or **Both NL & CR**.
|
||||
|
||||
Once connected, type `help` and press Enter to confirm everything is working.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
All commands follow a simple pattern: `get` to read, `set` to write.
|
||||
|
||||
### Viewing Settings
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get all` | Dump every setting at once |
|
||||
| `get name` | Device name |
|
||||
| `get freq` | Radio frequency (MHz) |
|
||||
| `get bw` | Bandwidth (kHz) |
|
||||
| `get sf` | Spreading factor |
|
||||
| `get cr` | Coding rate |
|
||||
| `get tx` | TX power (dBm) |
|
||||
| `get radio` | All radio params in one line |
|
||||
| `get utc` | UTC offset (hours) |
|
||||
| `get notify` | Keyboard flash notification (on/off) |
|
||||
| `get gps` | GPS status and interval |
|
||||
| `get pin` | BLE pairing PIN |
|
||||
| `get channels` | List all channels with index numbers |
|
||||
| `get presets` | List all radio presets with parameters |
|
||||
| `get pubkey` | Device public key (hex) |
|
||||
| `get firmware` | Firmware version string |
|
||||
|
||||
**4G variant only:**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get modem` | Modem enabled/disabled |
|
||||
| `get apn` | Current APN |
|
||||
| `get imei` | Device IMEI |
|
||||
|
||||
### Changing Settings
|
||||
|
||||
#### Device Name
|
||||
|
||||
```
|
||||
set name MyNode
|
||||
```
|
||||
|
||||
Names cannot contain these characters: `[ ] / \ : , ? *`
|
||||
|
||||
#### Radio Parameters (Individual)
|
||||
|
||||
Each of these applies immediately — no reboot required.
|
||||
|
||||
```
|
||||
set freq 910.525
|
||||
set bw 62.5
|
||||
set sf 7
|
||||
set cr 5
|
||||
set tx 22
|
||||
```
|
||||
|
||||
Valid ranges:
|
||||
|
||||
| Parameter | Min | Max |
|
||||
|-----------|-----|-----|
|
||||
| freq | 400.0 | 928.0 |
|
||||
| bw | 7.8 | 500.0 |
|
||||
| sf | 5 | 12 |
|
||||
| cr | 5 | 8 |
|
||||
| tx | 1 | Board max (typically 22) |
|
||||
|
||||
#### Radio Parameters (All at Once)
|
||||
|
||||
Set frequency, bandwidth, spreading factor, and coding rate in a single command:
|
||||
|
||||
```
|
||||
set radio 910.525 62.5 7 5
|
||||
```
|
||||
|
||||
#### Radio Presets
|
||||
|
||||
The easiest way to configure your radio. First, list the available presets:
|
||||
|
||||
```
|
||||
get presets
|
||||
```
|
||||
|
||||
This prints a numbered list like:
|
||||
|
||||
```
|
||||
Available radio presets:
|
||||
0 Australia 915.800 MHz BW250.0 SF10 CR5 TX22
|
||||
1 Australia (Narrow) 916.575 MHz BW62.5 SF7 CR8 TX22
|
||||
...
|
||||
14 USA/Canada (Recommended) 910.525 MHz BW62.5 SF7 CR5 TX22
|
||||
15 Vietnam 920.250 MHz BW250.0 SF11 CR5 TX22
|
||||
```
|
||||
|
||||
Apply a preset by name or number:
|
||||
|
||||
```
|
||||
set preset USA/Canada (Recommended)
|
||||
set preset 14
|
||||
```
|
||||
|
||||
Preset names are case-insensitive, so `set preset australia` works too. The preset applies all five radio parameters (freq, bw, sf, cr, tx) and takes effect immediately.
|
||||
|
||||
#### UTC Offset
|
||||
|
||||
```
|
||||
set utc 10
|
||||
```
|
||||
|
||||
Range: -12 to +14.
|
||||
|
||||
#### Keyboard Notification Flash
|
||||
|
||||
Toggle whether the keyboard backlight flashes when a new message arrives:
|
||||
|
||||
```
|
||||
set notify on
|
||||
set notify off
|
||||
```
|
||||
|
||||
#### BLE PIN
|
||||
|
||||
```
|
||||
set pin 123456
|
||||
```
|
||||
|
||||
### Channel Management
|
||||
|
||||
#### List Channels
|
||||
|
||||
```
|
||||
get channels
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
[0] #public
|
||||
[1] #meck-test
|
||||
[2] #local-group
|
||||
```
|
||||
|
||||
#### Add a Hashtag Channel
|
||||
|
||||
```
|
||||
set channel.add meck-test
|
||||
```
|
||||
|
||||
The `#` prefix is added automatically if you omit it. The channel's encryption key is derived from the name (SHA-256), matching the same method used by the on-device Settings screen and companion apps.
|
||||
|
||||
#### Delete a Channel
|
||||
|
||||
```
|
||||
set channel.del 2
|
||||
```
|
||||
|
||||
Channels are referenced by their index number (shown in `get channels`). Channel 0 (public) cannot be deleted. Remaining channels are automatically compacted after deletion.
|
||||
|
||||
### 4G Modem (4G Variant Only)
|
||||
|
||||
#### Enable / Disable Modem
|
||||
|
||||
```
|
||||
set modem on
|
||||
set modem off
|
||||
```
|
||||
|
||||
#### Set APN
|
||||
|
||||
```
|
||||
set apn telstra.internet
|
||||
```
|
||||
|
||||
To clear a custom APN and revert to auto-detection on next boot:
|
||||
|
||||
```
|
||||
set apn
|
||||
```
|
||||
|
||||
### System Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `reboot` | Restart the device |
|
||||
| `rebuild` | Erase filesystem, re-save identity + prefs + contacts + channels |
|
||||
| `erase` | Format the filesystem (caution: loses everything) |
|
||||
| `ls UserData/` | List files on internal filesystem |
|
||||
| `ls ExtraFS/` | List files on secondary filesystem |
|
||||
| `cat UserData/<path>` | Dump file contents as hex |
|
||||
| `rm UserData/<path>` | Delete a file |
|
||||
| `help` | Show command summary |
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
Plug in your new T-Deck Pro and run through these commands to get on the air:
|
||||
|
||||
```
|
||||
set name YourCallsign
|
||||
set preset Australia
|
||||
set utc 10
|
||||
set channel.add local-group
|
||||
get all
|
||||
```
|
||||
|
||||
### Switching to a New Region
|
||||
|
||||
Moving from Australia to the US? One command:
|
||||
|
||||
```
|
||||
set preset USA/Canada (Recommended)
|
||||
```
|
||||
|
||||
Verify with:
|
||||
|
||||
```
|
||||
get radio
|
||||
```
|
||||
|
||||
### Custom Radio Configuration
|
||||
|
||||
If none of the presets match your local group or you need specific parameters, set them directly. You can do it all in one command:
|
||||
|
||||
```
|
||||
set radio 916.575 62.5 8 8
|
||||
set tx 20
|
||||
```
|
||||
|
||||
Or one parameter at a time if you're only adjusting part of your config:
|
||||
|
||||
```
|
||||
set freq 916.575
|
||||
set bw 62.5
|
||||
set sf 8
|
||||
set cr 8
|
||||
set tx 20
|
||||
```
|
||||
|
||||
Both approaches apply immediately. Confirm with `get radio` to double-check everything took:
|
||||
|
||||
```
|
||||
get radio
|
||||
> freq=916.575 bw=62.5 sf=8 cr=8 tx=20
|
||||
```
|
||||
|
||||
### Troubleshooting Radio Settings
|
||||
|
||||
If you're not sure what went wrong, dump everything:
|
||||
|
||||
```
|
||||
get all
|
||||
```
|
||||
|
||||
Compare the radio section against what others in your area are using. If you need to match exact parameters from another node:
|
||||
|
||||
```
|
||||
set radio 916.575 62.5 7 8
|
||||
set tx 22
|
||||
```
|
||||
|
||||
### Backing Up Your Settings
|
||||
|
||||
Use `get all` to capture a snapshot of your configuration. Copy the serial output and save it — you can manually re-enter the settings after a firmware update or device reset if your SD card backup isn't available.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **All radio changes apply live.** There is no need to reboot after changing frequency, bandwidth, spreading factor, coding rate, or TX power. The radio reconfigures on the fly.
|
||||
- **Preset selection by number is faster.** Once you've seen `get presets`, use the index number instead of typing the full name.
|
||||
- **Settings are persisted immediately.** Every `set` command writes to flash. If power is lost, your settings are safe.
|
||||
- **SD card backup is automatic.** If your T-Deck Pro has an SD card inserted, settings are backed up after every change. On a fresh flash, settings restore automatically from the SD card.
|
||||
- **The `get all` command is your friend.** When in doubt, dump everything and check.
|
||||
148
Web App Guide.md
@@ -1,15 +1,97 @@
|
||||
# Web Reader - Integration Summary
|
||||
# Web Reader & IRC - Meck v0.9.5
|
||||
|
||||
### Conditional Compilation
|
||||
All web reader code is wrapped in `#ifdef MECK_WEB_READER` guards. The flag is set:
|
||||
- **meck_audio_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi available via BLE radio stack
|
||||
- **meck_4g_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi now, PPP via A7682E in future
|
||||
- **meck_audio_standalone**: No — excluded to preserve zero-radio-power design
|
||||
Press **B** from the home screen to open the web reader. The web reader is
|
||||
available on the BLE and 4G variants. It is excluded from the standalone audio
|
||||
variant to preserve zero-radio-power design.
|
||||
|
||||
### 4G Modem / PPP Support
|
||||
The web reader uses `isNetworkAvailable()` which checks both WiFi and (future) PPP connectivity. The `fetchPage()` method uses ESP32's standard `HTTPClient` which routes through whatever network interface is active — WiFi or PPP.
|
||||
The web reader home screen provides access to the **IRC client**, the **URL
|
||||
bar**, your **bookmarks**, and browsing **history**. Use **W / S** to navigate
|
||||
the list and **Enter** to select an item.
|
||||
|
||||
When PPP support is added to the 4G modem driver, the web reader will work over cellular automatically without code changes. The `isNetworkAvailable()` method has a `TODO` placeholder for the PPP status check.
|
||||
## Web Browser
|
||||
|
||||
A text-centric web browser ("reader mode") that fetches pages over WiFi,
|
||||
strips HTML to readable text, extracts links as numbered references, and
|
||||
paginates content for the e-ink display. Still very much in development, but
|
||||
already useful for text-heavy websites.
|
||||
|
||||
Includes basic web search via **DuckDuckGo Lite** — type a search query into
|
||||
the URL bar and it will be sent to DuckDuckGo.
|
||||
|
||||
### EPUB Downloads
|
||||
|
||||
If you follow a link to an `.epub` file, it will be saved directly to the
|
||||
`/books/` folder on your SD card. You can then read it in the e-book reader
|
||||
(press **E** from the home screen).
|
||||
|
||||
### Bookmarks
|
||||
|
||||
Press **K** while on a page to save a bookmark. Bookmarks appear on the web
|
||||
reader home screen below the URL bar. To delete a bookmark, open the browser
|
||||
home screen, scroll down to the bookmark, and press **Delete**.
|
||||
|
||||
### Cookies & History
|
||||
|
||||
Press **X** to clear cookies and browsing history.
|
||||
|
||||
---
|
||||
|
||||
## IRC Client
|
||||
|
||||
The IRC client lets you connect to IRC networks directly from the device. It
|
||||
is accessed from the web reader home screen — select **IRC Chat** (the first
|
||||
item) and press **Enter**.
|
||||
|
||||
If you are not currently connected, the IRC setup screen opens where you can
|
||||
configure the server, port, nickname, and channel. If you are already
|
||||
connected, you go straight to the chat view.
|
||||
|
||||
### IRC Setup
|
||||
|
||||
The setup screen has five fields. Use **W / S** to navigate between them and
|
||||
press **Enter** to edit a field (type the value, then **Enter** to confirm).
|
||||
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| Host | IRC server hostname (e.g. `irc.libera.chat`) | — |
|
||||
| Port | Server port. Use `6697` for TLS or `6667` for plain | 6697 |
|
||||
| Nick | Your IRC nickname (max 16 characters) | — |
|
||||
| Channel | Channel to join, including the `#` (e.g. `#meshcore`) | — |
|
||||
| Connect | Select and press Enter to connect | — |
|
||||
|
||||
TLS is used automatically when the port is 6697. Other ports connect without
|
||||
encryption.
|
||||
|
||||
Configuration is saved to the SD card at `/web/irc.cfg` and restored on next
|
||||
launch, so you only need to enter server details once.
|
||||
|
||||
If WiFi is not connected when you press Connect, you'll be taken to the WiFi
|
||||
setup screen first.
|
||||
|
||||
### IRC Chat View
|
||||
|
||||
Once connected and joined to the channel, you'll see messages in a scrollable
|
||||
chat view. The channel name and connection status are shown at the top.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| Enter | Start composing a message (type, then Enter to send) |
|
||||
| Backspace | Delete last character while composing; exit compose if empty |
|
||||
| W / S | Scroll up (older) / down (newer) through messages |
|
||||
| X | Disconnect from IRC and return to web reader home |
|
||||
| Q | Return to web reader home (connection stays alive in background) |
|
||||
|
||||
The IRC connection remains active when you press **Q** to go back to the web
|
||||
reader home screen. You'll see the connection status and channel name displayed
|
||||
on the IRC Chat line. Select it and press Enter to return to the chat. Press
|
||||
**X** from the chat view to disconnect.
|
||||
|
||||
The client automatically reconnects if the connection drops (10-second delay
|
||||
between attempts) and detects dead connections after 5 minutes of inactivity
|
||||
via ping timeout.
|
||||
|
||||
Messages are stored in a circular buffer of 64 messages. Older messages are
|
||||
discarded as new ones arrive.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,8 +105,8 @@ When PPP support is added to the 4G modem driver, the web reader will work over
|
||||
### Web Reader - Home View
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `w` / `s` | Navigate up/down in bookmarks/history |
|
||||
| `Enter` | Select URL bar or bookmark/history item |
|
||||
| `w` / `s` | Navigate up/down in IRC / URL bar / bookmarks / history |
|
||||
| `Enter` | Select IRC Chat, activate URL bar, or open bookmark/history item |
|
||||
| Type | Enter URL (when URL bar is active) |
|
||||
| `q` | Exit to firmware home |
|
||||
|
||||
@@ -36,6 +118,7 @@ When PPP support is added to the 4G modem driver, the web reader will work over
|
||||
| `l` or `Enter` | Enter link selection (type link number) |
|
||||
| `g` | Go to new URL (return to web reader home) |
|
||||
| `k` | Bookmark current page |
|
||||
| `x` | Clear cookies and history |
|
||||
| `q` | Back to web reader home |
|
||||
|
||||
### Web Reader - WiFi Setup
|
||||
@@ -46,6 +129,37 @@ When PPP support is added to the 4G modem driver, the web reader will work over
|
||||
| Type | Enter WiFi password |
|
||||
| `q` | Back |
|
||||
|
||||
### IRC - Setup View
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `w` / `s` | Navigate fields (Host / Port / Nick / Channel / Connect) |
|
||||
| `Enter` | Edit selected field, or connect (when on Connect button) |
|
||||
| Type | Enter field value (when editing) |
|
||||
| `Backspace` | Delete last character (when editing) |
|
||||
| `q` | Back to web reader home |
|
||||
|
||||
### IRC - Chat View
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Start composing / send message |
|
||||
| `Backspace` | Delete character / exit compose if empty |
|
||||
| `w` / `s` | Scroll older / newer messages |
|
||||
| `x` | Disconnect and return to web reader home |
|
||||
| `q` | Back to web reader home (stays connected) |
|
||||
|
||||
---
|
||||
|
||||
## WiFi
|
||||
|
||||
The web reader and IRC client both use WiFi for network access. On first use,
|
||||
you'll be taken to the WiFi setup screen to scan for networks and enter a
|
||||
password. Credentials are saved to `/web/wifi.cfg` on the SD card and used for
|
||||
auto-reconnect on subsequent launches.
|
||||
|
||||
On the 4G variant, the web reader currently uses WiFi. A future update will add
|
||||
PPP support via the A7682E cellular modem, allowing the browser and IRC to work
|
||||
over cellular data without WiFi.
|
||||
|
||||
---
|
||||
|
||||
## SD Card Structure
|
||||
@@ -54,4 +168,14 @@ When PPP support is added to the 4G modem driver, the web reader will work over
|
||||
wifi.cfg - Saved WiFi credentials (auto-reconnect)
|
||||
bookmarks.txt - One URL per line
|
||||
history.txt - Recent URLs, newest first
|
||||
```
|
||||
irc.cfg - IRC server/port/nick/channel config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conditional Compilation
|
||||
All web reader code is wrapped in `#ifdef MECK_WEB_READER` guards. The flag is set:
|
||||
- **meck_audio_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi available via BLE radio stack
|
||||
- **meck_4g_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi now, PPP via A7682E in future
|
||||
- **meck_4g_standalone**: Yes (`-D MECK_WEB_READER=1`) — WiFi works better without BLE (no teardown needed, more free heap)
|
||||
- **meck_audio_standalone**: No — excluded to preserve zero-radio-power design
|
||||
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,14 +41,18 @@ 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) {}
|
||||
virtual void forceRefresh() {}
|
||||
virtual void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {}
|
||||
|
||||
// Mark a channel as read when BLE companion app syncs a message
|
||||
virtual void markChannelReadFromBLE(uint8_t channel_idx) {}
|
||||
|
||||
// Repeater admin callbacks (from MyMesh)
|
||||
virtual void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {}
|
||||
virtual void onAdminCliResponse(const char* from_name, const char* text) {}
|
||||
virtual void onAdminTelemetryResult(const uint8_t* data, uint8_t len) {}
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
#include <Mesh.h>
|
||||
#include "RadioPresets.h" // Shared radio presets (serial CLI + settings screen)
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "ModemManager.h" // Serial CLI modem commands
|
||||
#endif
|
||||
|
||||
#define CMD_APP_START 1
|
||||
#define CMD_SEND_TXT_MSG 2
|
||||
@@ -53,9 +58,14 @@
|
||||
#define CMD_SET_FLOOD_SCOPE 54 // v8+
|
||||
#define CMD_SEND_CONTROL_DATA 55 // v8+
|
||||
#define CMD_GET_STATS 56 // v8+, second byte is stats type
|
||||
|
||||
// Control data sub-types for active node discovery
|
||||
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80
|
||||
#define CTL_TYPE_NODE_DISCOVER_RESP 0x90
|
||||
#define CMD_SEND_ANON_REQ 57
|
||||
#define CMD_SET_AUTOADD_CONFIG 58
|
||||
#define CMD_GET_AUTOADD_CONFIG 59
|
||||
#define CMD_SET_PATH_HASH_MODE 61
|
||||
|
||||
// Stats sub-types for CMD_GET_STATS
|
||||
#define STATS_TYPE_CORE 0
|
||||
@@ -259,6 +269,20 @@ uint8_t MyMesh::getExtraAckTransmitCount() const {
|
||||
return _prefs.multi_acks;
|
||||
}
|
||||
|
||||
uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) {
|
||||
uint32_t t = (uint32_t)(_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.5f);
|
||||
return getRNG()->nextInt(0, 5*t + 1);
|
||||
}
|
||||
|
||||
uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) {
|
||||
uint32_t t = (uint32_t)(_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.2f);
|
||||
return getRNG()->nextInt(0, 5*t + 1);
|
||||
}
|
||||
|
||||
uint8_t MyMesh::getAutoAddMaxHops() const {
|
||||
return _prefs.autoadd_max_hops;
|
||||
}
|
||||
|
||||
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
|
||||
if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) {
|
||||
int i = 0;
|
||||
@@ -336,7 +360,7 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path
|
||||
#endif
|
||||
|
||||
// add inbound-path to mem cache
|
||||
if (path && path_len <= sizeof(AdvertPath::path)) { // check path is valid
|
||||
if (path && mesh::Packet::isValidPathLen(path_len)) { // check path is valid
|
||||
AdvertPath* p = advert_paths;
|
||||
uint32_t oldest = 0xFFFFFFFF;
|
||||
for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) { // check if already in table, otherwise evict oldest
|
||||
@@ -353,8 +377,33 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path
|
||||
memcpy(p->pubkey_prefix, contact.id.pub_key, sizeof(p->pubkey_prefix));
|
||||
strcpy(p->name, contact.name);
|
||||
p->recv_timestamp = getRTCClock()->getCurrentTime();
|
||||
p->path_len = path_len;
|
||||
memcpy(p->path, path, p->path_len);
|
||||
p->path_len = mesh::Packet::copyPath(p->path, path, path_len);
|
||||
}
|
||||
|
||||
// Buffer for on-device discovery UI
|
||||
if (_discoveryActive && _discoveredCount < MAX_DISCOVERED_NODES) {
|
||||
bool dup = false;
|
||||
for (int i = 0; i < _discoveredCount; i++) {
|
||||
if (contact.id.matches(_discovered[i].contact.id)) {
|
||||
// Update existing entry with fresher data
|
||||
_discovered[i].contact = contact;
|
||||
_discovered[i].path_len = path_len;
|
||||
_discovered[i].already_in_contacts = !is_new;
|
||||
// Preserve snr if already set by active discovery response
|
||||
dup = true;
|
||||
Serial.printf("[Discovery] Updated: %s (hops=%d)\n", contact.name, path_len);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!dup) {
|
||||
_discovered[_discoveredCount].contact = contact;
|
||||
_discovered[_discoveredCount].path_len = path_len;
|
||||
_discovered[_discoveredCount].snr = 0; // no SNR from passive advert
|
||||
_discovered[_discoveredCount].already_in_contacts = !is_new;
|
||||
_discoveredCount++;
|
||||
Serial.printf("[Discovery] Found: %s (hops=%d, is_new=%d, total=%d)\n",
|
||||
contact.name, path_len, is_new, _discoveredCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_new) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // only schedule lazy write for contacts that are in contacts[]
|
||||
@@ -440,7 +489,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
||||
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
|
||||
if (should_display && _ui) {
|
||||
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path);
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
|
||||
}
|
||||
#endif
|
||||
@@ -481,14 +530,16 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
|
||||
}
|
||||
|
||||
void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
Serial.printf("[sendFloodScoped] to '%s', delay=%lu, hash_mode=%d, bph=%d\n",
|
||||
recipient.name, delay_millis, _prefs.path_hash_mode, _prefs.path_hash_mode + 1);
|
||||
// TODO: dynamic send_scope, depending on recipient and current 'home' Region
|
||||
if (send_scope.isNull()) {
|
||||
sendFlood(pkt, delay_millis);
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
} else {
|
||||
uint16_t codes[2];
|
||||
codes[0] = send_scope.calcTransportCode(pkt);
|
||||
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
|
||||
sendFlood(pkt, codes, delay_millis);
|
||||
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
|
||||
}
|
||||
}
|
||||
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
@@ -505,12 +556,12 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
|
||||
|
||||
// TODO: have per-channel send_scope
|
||||
if (send_scope.isNull()) {
|
||||
sendFlood(pkt, delay_millis);
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
} else {
|
||||
uint16_t codes[2];
|
||||
codes[0] = send_scope.calcTransportCode(pkt);
|
||||
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
|
||||
sendFlood(pkt, codes, delay_millis);
|
||||
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,7 +634,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
|
||||
}
|
||||
if (_ui) {
|
||||
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
|
||||
_ui->newMsg(path_len, channel_name, text, offline_queue_len, msg_path);
|
||||
_ui->newMsg(path_len, channel_name, text, offline_queue_len, msg_path, pkt->_snr);
|
||||
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::channelMessage); //buzz if enabled
|
||||
}
|
||||
#endif
|
||||
@@ -659,17 +710,36 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password) {
|
||||
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
if (!getContactByIdx(contact_idx, contact)) {
|
||||
Serial.println("[uiLogin] getContactByIdx FAILED");
|
||||
return false;
|
||||
}
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
if (!recipient) {
|
||||
Serial.println("[uiLogin] lookupContactByPubKey FAILED");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Force flood routing for login — a mobile repeater's direct path may be stale.
|
||||
// The companion protocol does the same for telemetry requests.
|
||||
uint8_t save_path_len = recipient->out_path_len;
|
||||
recipient->out_path_len = OUT_PATH_UNKNOWN;
|
||||
|
||||
Serial.printf("[uiLogin] Sending login to '%s' (idx=%d, path was 0x%02X, now 0x%02X, hash_mode=%d)\n",
|
||||
recipient->name, contact_idx, save_path_len, recipient->out_path_len, _prefs.path_hash_mode);
|
||||
|
||||
int result = sendLogin(*recipient, password, est_timeout_ms);
|
||||
|
||||
recipient->out_path_len = save_path_len; // restore
|
||||
|
||||
Serial.printf("[uiLogin] sendLogin result=%d est_timeout=%ums\n", result, est_timeout_ms);
|
||||
|
||||
uint32_t est_timeout;
|
||||
int result = sendLogin(*recipient, password, est_timeout);
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
MESH_DEBUG_PRINTLN("UI: Admin login send failed to %s", recipient->name);
|
||||
Serial.println("[uiLogin] FAILED - MSG_SEND_FAILED");
|
||||
est_timeout_ms = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -677,9 +747,8 @@ bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password) {
|
||||
memcpy(&pending_login, recipient->id.pub_key, 4);
|
||||
_admin_contact_idx = contact_idx;
|
||||
|
||||
MESH_DEBUG_PRINTLN("UI: Admin login sent to %s (%s), timeout=%dms",
|
||||
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
|
||||
est_timeout);
|
||||
Serial.printf("[uiLogin] SUCCESS - login sent to %s (flood), timeout=%dms\n",
|
||||
recipient->name, est_timeout_ms);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -706,6 +775,29 @@ bool MyMesh::uiSendCliCommand(uint32_t contact_idx, const char* command) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiSendTelemetryRequest(uint32_t contact_idx) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
|
||||
uint32_t tag, est_timeout;
|
||||
int result = sendRequest(*recipient, REQ_TYPE_GET_TELEMETRY_DATA, tag, est_timeout);
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
MESH_DEBUG_PRINTLN("UI: Telemetry request send failed to %s", recipient->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
clearPendingReqs();
|
||||
pending_telemetry = tag;
|
||||
|
||||
MESH_DEBUG_PRINTLN("UI: Telemetry request sent to %s (%s), timeout=%dms",
|
||||
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
|
||||
est_timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
|
||||
uint8_t len, uint8_t *reply) {
|
||||
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
|
||||
@@ -754,6 +846,9 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
uint32_t tag;
|
||||
memcpy(&tag, data, 4);
|
||||
|
||||
Serial.printf("[onContactResponse] from '%s', tag=0x%08X, len=%d, pending_login=0x%08X\n",
|
||||
contact.name, tag, len, pending_login);
|
||||
|
||||
if (pending_login && memcmp(&pending_login, contact.id.pub_key, 4) == 0) { // check for login response
|
||||
// yes, is response to pending sendLogin()
|
||||
pending_login = 0;
|
||||
@@ -816,6 +911,7 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
_serial->writeFrame(out_frame, i);
|
||||
} else if (len > 4 && tag == pending_telemetry) { // check for matching response tag
|
||||
pending_telemetry = 0;
|
||||
MESH_DEBUG_PRINTLN("Telemetry response received from %s, len=%d", contact.name, len);
|
||||
|
||||
int i = 0;
|
||||
out_frame[i++] = PUSH_CODE_TELEMETRY_RESPONSE;
|
||||
@@ -825,6 +921,11 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
memcpy(&out_frame[i], &data[4], len - 4);
|
||||
i += (len - 4);
|
||||
_serial->writeFrame(out_frame, i);
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Route telemetry data to UI (LPP buffer after the 4-byte tag)
|
||||
if (_ui) _ui->onAdminTelemetryResult(&data[4], len - 4);
|
||||
#endif
|
||||
} else if (len > 4 && tag == pending_req) { // check for matching response tag
|
||||
pending_req = 0;
|
||||
|
||||
@@ -847,7 +948,7 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
|
||||
if (tag == pending_discovery) { // check for matching response tag)
|
||||
pending_discovery = 0;
|
||||
|
||||
if (in_path_len > MAX_PATH_SIZE || out_path_len > MAX_PATH_SIZE) {
|
||||
if (!mesh::Packet::isValidPathLen(in_path_len) || !mesh::Packet::isValidPathLen(out_path_len)) {
|
||||
MESH_DEBUG_PRINTLN("onContactPathRecv, invalid path sizes: %d, %d", in_path_len, out_path_len);
|
||||
} else {
|
||||
int i = 0;
|
||||
@@ -856,11 +957,9 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
|
||||
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
||||
i += 6; // pub_key_prefix
|
||||
out_frame[i++] = out_path_len;
|
||||
memcpy(&out_frame[i], out_path, out_path_len);
|
||||
i += out_path_len;
|
||||
i += mesh::Packet::writePath(&out_frame[i], out_path, out_path_len);
|
||||
out_frame[i++] = in_path_len;
|
||||
memcpy(&out_frame[i], in_path, in_path_len);
|
||||
i += in_path_len;
|
||||
i += mesh::Packet::writePath(&out_frame[i], in_path, in_path_len);
|
||||
// NOTE: telemetry data in 'extra' is discarded at present
|
||||
|
||||
_serial->writeFrame(out_frame, i);
|
||||
@@ -873,6 +972,62 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
|
||||
}
|
||||
|
||||
void MyMesh::onControlDataRecv(mesh::Packet *packet) {
|
||||
// --- Active discovery response interception ---
|
||||
if (_discoveryActive && packet->payload_len >= 6) {
|
||||
uint8_t resp_type = packet->payload[0] & 0xF0;
|
||||
if (resp_type == CTL_TYPE_NODE_DISCOVER_RESP) {
|
||||
uint8_t node_type = packet->payload[0] & 0x0F;
|
||||
int8_t snr_scaled = (int8_t)packet->payload[1]; // SNR × 4 (how well repeater heard us)
|
||||
uint32_t tag;
|
||||
memcpy(&tag, &packet->payload[2], 4);
|
||||
|
||||
// Validate: tag must match ours AND payload must include full 32-byte pubkey
|
||||
if (tag == _discoveryTag && packet->payload_len >= 6 + PUB_KEY_SIZE) {
|
||||
const uint8_t* pubkey = &packet->payload[6];
|
||||
|
||||
// Dedup check against existing buffer entries (pre-seeded or earlier responses)
|
||||
for (int i = 0; i < _discoveredCount; i++) {
|
||||
if (_discovered[i].contact.id.matches(pubkey)) {
|
||||
// Already in buffer — update SNR (active discovery data is fresher)
|
||||
_discovered[i].snr = snr_scaled;
|
||||
Serial.printf("[Discovery] Updated SNR for %s: %d\n",
|
||||
_discovered[i].contact.name, snr_scaled);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// New node — add if room
|
||||
if (_discoveredCount < MAX_DISCOVERED_NODES) {
|
||||
DiscoveredNode& node = _discovered[_discoveredCount];
|
||||
memset(&node.contact, 0, sizeof(ContactInfo));
|
||||
memcpy(node.contact.id.pub_key, pubkey, PUB_KEY_SIZE);
|
||||
node.contact.type = node_type;
|
||||
node.snr = snr_scaled;
|
||||
node.path_len = packet->path_len;
|
||||
|
||||
// Try to resolve name from contacts table
|
||||
ContactInfo* existing = lookupContactByPubKey(pubkey, PUB_KEY_SIZE);
|
||||
if (existing) {
|
||||
strncpy(node.contact.name, existing->name, sizeof(node.contact.name) - 1);
|
||||
node.already_in_contacts = true;
|
||||
} else {
|
||||
// Show hex prefix as placeholder name
|
||||
snprintf(node.contact.name, sizeof(node.contact.name),
|
||||
"%02X%02X%02X%02X",
|
||||
pubkey[0], pubkey[1], pubkey[2], pubkey[3]);
|
||||
node.already_in_contacts = false;
|
||||
}
|
||||
|
||||
_discoveredCount++;
|
||||
Serial.printf("[Discovery] Active response: %s type=%d snr=%d hops=%d (total=%d)\n",
|
||||
node.contact.name, node_type, snr_scaled, packet->path_len, _discoveredCount);
|
||||
}
|
||||
}
|
||||
return; // consumed — don't forward discovery responses to BLE
|
||||
}
|
||||
}
|
||||
|
||||
// --- Original BLE forwarding for non-discovery control data ---
|
||||
if (packet->payload_len + 4 > sizeof(out_frame)) {
|
||||
MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len);
|
||||
return;
|
||||
@@ -946,9 +1101,10 @@ uint32_t MyMesh::calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const {
|
||||
return SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * pkt_airtime_millis);
|
||||
}
|
||||
uint32_t MyMesh::calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const {
|
||||
uint8_t hop_count = path_len & 63; // extract hops, ignore mode bits
|
||||
return SEND_TIMEOUT_BASE_MILLIS +
|
||||
((pkt_airtime_millis * DIRECT_SEND_PERHOP_FACTOR + DIRECT_SEND_PERHOP_EXTRA_MILLIS) *
|
||||
(path_len + 1));
|
||||
(hop_count + 1));
|
||||
}
|
||||
|
||||
void MyMesh::onSendTimeout() {}
|
||||
@@ -969,6 +1125,10 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
memset(_sent_track, 0, sizeof(_sent_track));
|
||||
_sent_track_idx = 0;
|
||||
_admin_contact_idx = -1;
|
||||
_discoveredCount = 0;
|
||||
_discoveryActive = false;
|
||||
_discoveryTimeout = 0;
|
||||
_discoveryTag = 0;
|
||||
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
@@ -1271,7 +1431,8 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
}
|
||||
if (pkt) {
|
||||
if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop)
|
||||
sendFlood(pkt);
|
||||
unsigned long delay_millis = 0;
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
} else {
|
||||
sendZeroHop(pkt);
|
||||
}
|
||||
@@ -1283,7 +1444,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
uint8_t *pub_key = &cmd_frame[1];
|
||||
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
||||
if (recipient) {
|
||||
recipient->out_path_len = -1;
|
||||
recipient->out_path_len = OUT_PATH_UNKNOWN;
|
||||
// recipient->lastmod = ?? shouldn't be needed, app already has this version of contact
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
writeOKFrame();
|
||||
@@ -1372,6 +1533,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
}
|
||||
} else if (cmd_frame[0] == CMD_IMPORT_CONTACT && len > 2 + 32 + 64) {
|
||||
if (importContact(&cmd_frame[1], len - 1)) {
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
writeOKFrame();
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
||||
@@ -1381,7 +1543,19 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
if ((out_len = getFromOfflineQueue(out_frame)) > 0) {
|
||||
_serial->writeFrame(out_frame, out_len);
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_ui) _ui->msgRead(offline_queue_len);
|
||||
if (_ui) {
|
||||
_ui->msgRead(offline_queue_len);
|
||||
|
||||
// Mark channel as read when BLE companion app syncs the message.
|
||||
// Frame layout V3: [resp_code][snr][res1][res2][channel_idx][path_len]...
|
||||
// Frame layout V1: [resp_code][channel_idx][path_len]...
|
||||
bool is_v3_ch = (out_frame[0] == RESP_CODE_CHANNEL_MSG_RECV_V3);
|
||||
bool is_old_ch = (out_frame[0] == RESP_CODE_CHANNEL_MSG_RECV);
|
||||
if (is_v3_ch || is_old_ch) {
|
||||
uint8_t ch_idx = is_v3_ch ? out_frame[4] : out_frame[1];
|
||||
_ui->markChannelReadFromBLE(ch_idx);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
out_frame[0] = RESP_CODE_NO_MORE_MESSAGES;
|
||||
@@ -1459,6 +1633,14 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
}
|
||||
savePrefs();
|
||||
writeOKFrame();
|
||||
} else if (cmd_frame[0] == CMD_SET_PATH_HASH_MODE && cmd_frame[1] == 0 && len >= 3) {
|
||||
if (cmd_frame[2] >= 3) {
|
||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
||||
} else {
|
||||
_prefs.path_hash_mode = cmd_frame[2];
|
||||
savePrefs();
|
||||
writeOKFrame();
|
||||
}
|
||||
} else if (cmd_frame[0] == CMD_REBOOT && memcmp(&cmd_frame[1], "reboot", 6) == 0) {
|
||||
if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed?
|
||||
saveContacts();
|
||||
@@ -1506,10 +1688,10 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
#endif
|
||||
} else if (cmd_frame[0] == CMD_SEND_RAW_DATA && len >= 6) {
|
||||
int i = 1;
|
||||
int8_t path_len = cmd_frame[i++];
|
||||
if (path_len >= 0 && i + path_len + 4 <= len) { // minimum 4 byte payload
|
||||
uint8_t path_len = cmd_frame[i++];
|
||||
if (path_len != OUT_PATH_UNKNOWN && i + mesh::Packet::getPathByteLenFor(path_len) + 4 <= len) { // minimum 4 byte payload
|
||||
uint8_t *path = &cmd_frame[i];
|
||||
i += path_len;
|
||||
i += mesh::Packet::getPathByteLenFor(path_len);
|
||||
auto pkt = createRawData(&cmd_frame[i], len - i);
|
||||
if (pkt) {
|
||||
sendDirect(pkt, path, path_len);
|
||||
@@ -1596,7 +1778,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
memset(&req_data[2], 0, 3); // reserved
|
||||
getRNG()->random(&req_data[5], 4); // random blob to help make packet-hash unique
|
||||
auto save = recipient->out_path_len; // temporarily force sendRequest() to flood
|
||||
recipient->out_path_len = -1;
|
||||
recipient->out_path_len = OUT_PATH_UNKNOWN;
|
||||
int result = sendRequest(*recipient, req_data, sizeof(req_data), tag, est_timeout);
|
||||
recipient->out_path_len = save;
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
@@ -1839,11 +2021,12 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
out_frame[0] = RESP_CODE_ADVERT_PATH;
|
||||
memcpy(&out_frame[1], &found->recv_timestamp, 4);
|
||||
out_frame[5] = found->path_len;
|
||||
memcpy(&out_frame[6], found->path, found->path_len);
|
||||
_serial->writeFrame(out_frame, 6 + found->path_len);
|
||||
int i = 0;
|
||||
out_frame[i++] = RESP_CODE_ADVERT_PATH;
|
||||
memcpy(&out_frame[i], &found->recv_timestamp, 4); i += 4;
|
||||
out_frame[i++] = found->path_len;
|
||||
i += mesh::Packet::writePath(&out_frame[i], found->path, found->path_len);
|
||||
_serial->writeFrame(out_frame, i);
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND);
|
||||
}
|
||||
@@ -1962,15 +2145,461 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
if (len > 0 && cli_command[len - 1] == '\r') { // received complete line
|
||||
cli_command[len - 1] = 0; // replace newline with C string null terminator
|
||||
|
||||
if (memcmp(cli_command, "set ", 4) == 0) {
|
||||
// =====================================================================
|
||||
// GET commands — read settings
|
||||
// =====================================================================
|
||||
if (memcmp(cli_command, "get ", 4) == 0) {
|
||||
const char* key = &cli_command[4];
|
||||
|
||||
if (strcmp(key, "name") == 0) {
|
||||
Serial.printf(" > %s\n", _prefs.node_name);
|
||||
} else if (strcmp(key, "freq") == 0) {
|
||||
Serial.printf(" > %.3f\n", _prefs.freq);
|
||||
} else if (strcmp(key, "bw") == 0) {
|
||||
Serial.printf(" > %.1f\n", _prefs.bw);
|
||||
} else if (strcmp(key, "sf") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.sf);
|
||||
} else if (strcmp(key, "cr") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.cr);
|
||||
} else if (strcmp(key, "tx") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.tx_power_dbm);
|
||||
} else if (strcmp(key, "utc") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.utc_offset_hours);
|
||||
} else if (strcmp(key, "notify") == 0) {
|
||||
Serial.printf(" > %s\n", _prefs.kb_flash_notify ? "on" : "off");
|
||||
} else if (strcmp(key, "path.hash.mode") == 0) {
|
||||
Serial.printf(" > %d (%d-byte path hashes)\n", _prefs.path_hash_mode, _prefs.path_hash_mode + 1);
|
||||
} else if (strcmp(key, "gps") == 0) {
|
||||
Serial.printf(" > %s (interval: %ds)\n",
|
||||
_prefs.gps_enabled ? "on" : "off", _prefs.gps_interval);
|
||||
} else if (strcmp(key, "pin") == 0) {
|
||||
Serial.printf(" > %06d\n", _prefs.ble_pin);
|
||||
} else if (strcmp(key, "radio") == 0) {
|
||||
Serial.printf(" > freq=%.3f bw=%.1f sf=%d cr=%d tx=%d\n",
|
||||
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
|
||||
} else if (strcmp(key, "pubkey") == 0) {
|
||||
char hex[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(hex, self_id.pub_key, PUB_KEY_SIZE);
|
||||
Serial.printf(" > %s\n", hex);
|
||||
} else if (strcmp(key, "firmware") == 0) {
|
||||
Serial.printf(" > %s\n", FIRMWARE_VERSION);
|
||||
} else if (strcmp(key, "channels") == 0) {
|
||||
bool found = false;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
Serial.printf(" [%d] %s\n", i, ch.name);
|
||||
found = true;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) Serial.println(" (no channels)");
|
||||
} else if (strcmp(key, "presets") == 0) {
|
||||
Serial.println(" Available radio presets:");
|
||||
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
|
||||
Serial.printf(" %2d %-30s %.3f MHz BW%.1f SF%d CR%d TX%d\n",
|
||||
i, RADIO_PRESETS[i].name, RADIO_PRESETS[i].freq,
|
||||
RADIO_PRESETS[i].bw, RADIO_PRESETS[i].sf,
|
||||
RADIO_PRESETS[i].cr, RADIO_PRESETS[i].tx_power);
|
||||
}
|
||||
#ifdef HAS_4G_MODEM
|
||||
} else if (strcmp(key, "modem") == 0) {
|
||||
Serial.printf(" > %s\n", ModemManager::loadEnabledConfig() ? "on" : "off");
|
||||
} else if (strcmp(key, "apn") == 0) {
|
||||
Serial.printf(" > %s\n", modemManager.getAPN());
|
||||
} else if (strcmp(key, "imei") == 0) {
|
||||
Serial.printf(" > %s\n", modemManager.getIMEI());
|
||||
#endif
|
||||
} else if (strcmp(key, "all") == 0) {
|
||||
Serial.println(" === Meck Device Settings ===");
|
||||
Serial.printf(" name: %s\n", _prefs.node_name);
|
||||
Serial.printf(" freq: %.3f\n", _prefs.freq);
|
||||
Serial.printf(" bw: %.1f\n", _prefs.bw);
|
||||
Serial.printf(" sf: %d\n", _prefs.sf);
|
||||
Serial.printf(" cr: %d\n", _prefs.cr);
|
||||
Serial.printf(" tx: %d\n", _prefs.tx_power_dbm);
|
||||
Serial.printf(" utc: %d\n", _prefs.utc_offset_hours);
|
||||
Serial.printf(" notify: %s\n", _prefs.kb_flash_notify ? "on" : "off");
|
||||
Serial.printf(" path.hash: %d (%d-byte)\n", _prefs.path_hash_mode, _prefs.path_hash_mode + 1);
|
||||
Serial.printf(" gps: %s (interval: %ds)\n",
|
||||
_prefs.gps_enabled ? "on" : "off", _prefs.gps_interval);
|
||||
Serial.printf(" pin: %06d\n", _prefs.ble_pin);
|
||||
#ifdef HAS_4G_MODEM
|
||||
Serial.printf(" modem: %s\n", ModemManager::loadEnabledConfig() ? "on" : "off");
|
||||
Serial.printf(" apn: %s\n", modemManager.getAPN());
|
||||
Serial.printf(" imei: %s\n", modemManager.getIMEI());
|
||||
#endif
|
||||
// Detect current preset
|
||||
bool presetFound = false;
|
||||
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
|
||||
if (_prefs.freq == RADIO_PRESETS[i].freq && _prefs.bw == RADIO_PRESETS[i].bw &&
|
||||
_prefs.sf == RADIO_PRESETS[i].sf && _prefs.cr == RADIO_PRESETS[i].cr) {
|
||||
Serial.printf(" preset: %s\n", RADIO_PRESETS[i].name);
|
||||
presetFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!presetFound) Serial.println(" preset: (custom)");
|
||||
Serial.printf(" firmware: %s\n", FIRMWARE_VERSION);
|
||||
char hex[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(hex, self_id.pub_key, PUB_KEY_SIZE);
|
||||
Serial.printf(" pubkey: %s\n", hex);
|
||||
// List channels
|
||||
Serial.println(" channels:");
|
||||
bool chFound = false;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
Serial.printf(" [%d] %s\n", i, ch.name);
|
||||
chFound = true;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!chFound) Serial.println(" (none)");
|
||||
} else {
|
||||
Serial.printf(" Error: unknown key '%s' (try 'help')\n", key);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// SET commands — write settings
|
||||
// =====================================================================
|
||||
} else if (memcmp(cli_command, "set ", 4) == 0) {
|
||||
const char* config = &cli_command[4];
|
||||
if (memcmp(config, "pin ", 4) == 0) {
|
||||
|
||||
if (memcmp(config, "name ", 5) == 0) {
|
||||
const char* val = &config[5];
|
||||
// Validate name (same rules as CommonCLI)
|
||||
bool valid = true;
|
||||
const char* p = val;
|
||||
while (*p) {
|
||||
if (*p == '[' || *p == ']' || *p == '/' || *p == '\\' ||
|
||||
*p == ':' || *p == ',' || *p == '?' || *p == '*') {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
if (valid && strlen(val) > 0) {
|
||||
strncpy(_prefs.node_name, val, sizeof(_prefs.node_name) - 1);
|
||||
_prefs.node_name[sizeof(_prefs.node_name) - 1] = '\0';
|
||||
savePrefs();
|
||||
Serial.printf(" > name = %s\n", _prefs.node_name);
|
||||
} else {
|
||||
Serial.println(" Error: invalid name (no []/:,?* chars)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "freq ", 5) == 0) {
|
||||
float f = atof(&config[5]);
|
||||
if (f >= 400.0f && f <= 928.0f) {
|
||||
_prefs.freq = f;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
Serial.printf(" > freq = %.3f (applied)\n", _prefs.freq);
|
||||
} else {
|
||||
Serial.println(" Error: freq out of range (400-928)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "bw ", 3) == 0) {
|
||||
float bw = atof(&config[3]);
|
||||
if (bw >= 7.8f && bw <= 500.0f) {
|
||||
_prefs.bw = bw;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
Serial.printf(" > bw = %.1f (applied)\n", _prefs.bw);
|
||||
} else {
|
||||
Serial.println(" Error: bw out of range (7.8-500)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "sf ", 3) == 0) {
|
||||
int sf = atoi(&config[3]);
|
||||
if (sf >= 5 && sf <= 12) {
|
||||
_prefs.sf = (uint8_t)sf;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
Serial.printf(" > sf = %d (applied)\n", _prefs.sf);
|
||||
} else {
|
||||
Serial.println(" Error: sf out of range (5-12)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "cr ", 3) == 0) {
|
||||
int cr = atoi(&config[3]);
|
||||
if (cr >= 5 && cr <= 8) {
|
||||
_prefs.cr = (uint8_t)cr;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
Serial.printf(" > cr = %d (applied)\n", _prefs.cr);
|
||||
} else {
|
||||
Serial.println(" Error: cr out of range (5-8)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "tx ", 3) == 0) {
|
||||
int tx = atoi(&config[3]);
|
||||
if (tx >= 1 && tx <= MAX_LORA_TX_POWER) {
|
||||
_prefs.tx_power_dbm = (uint8_t)tx;
|
||||
savePrefs();
|
||||
radio_set_tx_power(_prefs.tx_power_dbm);
|
||||
Serial.printf(" > tx = %d (applied)\n", _prefs.tx_power_dbm);
|
||||
} else {
|
||||
Serial.printf(" Error: tx out of range (1-%d)\n", MAX_LORA_TX_POWER);
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "utc ", 4) == 0) {
|
||||
int utc = atoi(&config[4]);
|
||||
if (utc >= -12 && utc <= 14) {
|
||||
_prefs.utc_offset_hours = (int8_t)utc;
|
||||
savePrefs();
|
||||
Serial.printf(" > utc = %d\n", _prefs.utc_offset_hours);
|
||||
} else {
|
||||
Serial.println(" Error: utc out of range (-12 to 14)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "notify ", 7) == 0) {
|
||||
if (strcmp(&config[7], "on") == 0) {
|
||||
_prefs.kb_flash_notify = 1;
|
||||
} else if (strcmp(&config[7], "off") == 0) {
|
||||
_prefs.kb_flash_notify = 0;
|
||||
} else {
|
||||
Serial.println(" Error: use 'on' or 'off'");
|
||||
cli_command[0] = 0;
|
||||
return;
|
||||
}
|
||||
savePrefs();
|
||||
Serial.printf(" > notify = %s\n", _prefs.kb_flash_notify ? "on" : "off");
|
||||
|
||||
} else if (memcmp(config, "path.hash.mode ", 15) == 0) {
|
||||
int mode = atoi(&config[15]);
|
||||
if (mode >= 0 && mode <= 2) {
|
||||
_prefs.path_hash_mode = (uint8_t)mode;
|
||||
savePrefs();
|
||||
Serial.printf(" > path.hash.mode = %d (%d-byte path hashes)\n", mode, mode + 1);
|
||||
} else {
|
||||
Serial.println(" Error: mode must be 0, 1, or 2 (1-byte, 2-byte, 3-byte)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "pin ", 4) == 0) {
|
||||
_prefs.ble_pin = atoi(&config[4]);
|
||||
savePrefs();
|
||||
Serial.printf(" > pin is now %06d\n", _prefs.ble_pin);
|
||||
|
||||
} else if (memcmp(config, "radio ", 6) == 0) {
|
||||
// Composite: "set radio <freq> <bw> <sf> <cr>"
|
||||
char tmp[64];
|
||||
strncpy(tmp, &config[6], sizeof(tmp) - 1);
|
||||
tmp[sizeof(tmp) - 1] = '\0';
|
||||
const char* parts[4];
|
||||
int num = mesh::Utils::parseTextParts(tmp, parts, 4);
|
||||
if (num == 4) {
|
||||
float freq = strtof(parts[0], nullptr);
|
||||
float bw = strtof(parts[1], nullptr);
|
||||
int sf = atoi(parts[2]);
|
||||
int cr = atoi(parts[3]);
|
||||
if (freq >= 400.0f && freq <= 928.0f && bw >= 7.8f && bw <= 500.0f
|
||||
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8) {
|
||||
_prefs.freq = freq;
|
||||
_prefs.bw = bw;
|
||||
_prefs.sf = (uint8_t)sf;
|
||||
_prefs.cr = (uint8_t)cr;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
radio_set_tx_power(_prefs.tx_power_dbm);
|
||||
Serial.printf(" > radio = %.3f/%.1f/SF%d/CR%d TX:%d (applied)\n",
|
||||
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
|
||||
} else {
|
||||
Serial.println(" Error: invalid radio params");
|
||||
}
|
||||
} else {
|
||||
Serial.println(" Usage: set radio <freq> <bw> <sf> <cr>");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "preset ", 7) == 0) {
|
||||
const char* name = &config[7];
|
||||
// Try exact match first (case-insensitive)
|
||||
bool found = false;
|
||||
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
|
||||
if (strcasecmp(RADIO_PRESETS[i].name, name) == 0) {
|
||||
_prefs.freq = RADIO_PRESETS[i].freq;
|
||||
_prefs.bw = RADIO_PRESETS[i].bw;
|
||||
_prefs.sf = RADIO_PRESETS[i].sf;
|
||||
_prefs.cr = RADIO_PRESETS[i].cr;
|
||||
_prefs.tx_power_dbm = RADIO_PRESETS[i].tx_power;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
radio_set_tx_power(_prefs.tx_power_dbm);
|
||||
Serial.printf(" > Applied preset '%s' (%.3f/%.1f/SF%d/CR%d TX:%d)\n",
|
||||
RADIO_PRESETS[i].name, _prefs.freq, _prefs.bw,
|
||||
_prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Try by index number if name didn't match
|
||||
if (!found) {
|
||||
char* endp;
|
||||
long idx = strtol(name, &endp, 10);
|
||||
if (endp != name && *endp == '\0' && idx >= 0 && idx < (int)NUM_RADIO_PRESETS) {
|
||||
_prefs.freq = RADIO_PRESETS[idx].freq;
|
||||
_prefs.bw = RADIO_PRESETS[idx].bw;
|
||||
_prefs.sf = RADIO_PRESETS[idx].sf;
|
||||
_prefs.cr = RADIO_PRESETS[idx].cr;
|
||||
_prefs.tx_power_dbm = RADIO_PRESETS[idx].tx_power;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
radio_set_tx_power(_prefs.tx_power_dbm);
|
||||
Serial.printf(" > Applied preset '%s' (%.3f/%.1f/SF%d/CR%d TX:%d)\n",
|
||||
RADIO_PRESETS[idx].name, _prefs.freq, _prefs.bw,
|
||||
_prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
Serial.printf(" Error: unknown preset '%s' (try 'get presets')\n", name);
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "channel.add ", 12) == 0) {
|
||||
const char* name = &config[12];
|
||||
if (strlen(name) == 0) {
|
||||
Serial.println(" Error: channel name required");
|
||||
cli_command[0] = 0;
|
||||
return;
|
||||
}
|
||||
// Build channel name with # prefix if not present
|
||||
char chanName[32];
|
||||
if (name[0] == '#') {
|
||||
strncpy(chanName, name, sizeof(chanName));
|
||||
} else {
|
||||
chanName[0] = '#';
|
||||
strncpy(&chanName[1], name, sizeof(chanName) - 1);
|
||||
}
|
||||
chanName[31] = '\0';
|
||||
|
||||
// Generate 128-bit PSK from SHA-256 of channel name
|
||||
ChannelDetails newCh;
|
||||
memset(&newCh, 0, sizeof(newCh));
|
||||
strncpy(newCh.name, chanName, sizeof(newCh.name));
|
||||
newCh.name[31] = '\0';
|
||||
|
||||
uint8_t hash[32];
|
||||
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
|
||||
memcpy(newCh.channel.secret, hash, 16);
|
||||
|
||||
// Find next empty slot
|
||||
bool added = false;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails existing;
|
||||
if (!getChannel(i, existing) || existing.name[0] == '\0') {
|
||||
if (setChannel(i, newCh)) {
|
||||
saveChannels();
|
||||
Serial.printf(" > Added channel '%s' at slot %d\n", chanName, i);
|
||||
added = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!added) Serial.println(" Error: no empty channel slots");
|
||||
|
||||
} else if (memcmp(config, "channel.del ", 12) == 0) {
|
||||
int idx = atoi(&config[12]);
|
||||
if (idx <= 0) {
|
||||
Serial.println(" Error: cannot delete channel 0 (public)");
|
||||
} else if (idx >= MAX_GROUP_CHANNELS) {
|
||||
Serial.printf(" Error: index out of range (1-%d)\n", MAX_GROUP_CHANNELS - 1);
|
||||
} else {
|
||||
// Verify channel exists
|
||||
ChannelDetails ch;
|
||||
if (!getChannel(idx, ch) || ch.name[0] == '\0') {
|
||||
Serial.printf(" Error: no channel at index %d\n", idx);
|
||||
} else {
|
||||
// Compact: shift channels down
|
||||
int total = 0;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails tmp;
|
||||
if (getChannel(i, tmp) && tmp.name[0] != '\0') {
|
||||
total = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (int i = idx; i < total - 1; i++) {
|
||||
ChannelDetails next;
|
||||
if (getChannel(i + 1, next)) {
|
||||
setChannel(i, next);
|
||||
}
|
||||
}
|
||||
ChannelDetails empty;
|
||||
memset(&empty, 0, sizeof(empty));
|
||||
setChannel(total - 1, empty);
|
||||
saveChannels();
|
||||
Serial.printf(" > Deleted channel %d ('%s'), compacted %d channels\n",
|
||||
idx, ch.name, total);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
} else if (memcmp(config, "apn ", 4) == 0) {
|
||||
const char* apn = &config[4];
|
||||
if (strlen(apn) > 0) {
|
||||
modemManager.setAPN(apn);
|
||||
Serial.printf(" > apn = %s\n", apn);
|
||||
} else {
|
||||
ModemManager::saveAPNConfig("");
|
||||
Serial.println(" > apn cleared (will auto-detect on next boot)");
|
||||
}
|
||||
|
||||
} else if (strcmp(config, "modem on") == 0) {
|
||||
ModemManager::saveEnabledConfig(true);
|
||||
modemManager.begin();
|
||||
Serial.println(" > modem enabled");
|
||||
|
||||
} else if (strcmp(config, "modem off") == 0) {
|
||||
ModemManager::saveEnabledConfig(false);
|
||||
modemManager.shutdown();
|
||||
Serial.println(" > modem disabled");
|
||||
#endif
|
||||
|
||||
} else {
|
||||
Serial.printf(" Error: unknown config: %s\n", config);
|
||||
Serial.printf(" Error: unknown setting '%s' (try 'help')\n", config);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// HELP command
|
||||
// =====================================================================
|
||||
} else if (strcmp(cli_command, "help") == 0) {
|
||||
Serial.println("=== Meck Serial CLI ===");
|
||||
Serial.println(" get <key> Read a setting");
|
||||
Serial.println(" set <key> <value> Write a setting");
|
||||
Serial.println("");
|
||||
Serial.println(" Settings keys:");
|
||||
Serial.println(" name, freq, bw, sf, cr, tx, utc, notify, pin");
|
||||
Serial.println(" path.hash.mode Path hash size (0=1B, 1=2B, 2=3B)");
|
||||
Serial.println("");
|
||||
Serial.println(" Compound commands:");
|
||||
Serial.println(" get all Dump all settings");
|
||||
Serial.println(" get radio Show all radio params");
|
||||
Serial.println(" get channels List channels");
|
||||
Serial.println(" get presets List radio presets");
|
||||
Serial.println(" get pubkey Show public key");
|
||||
Serial.println(" get firmware Show firmware version");
|
||||
Serial.println(" set radio <f> <bw> <sf> <cr> Set all radio params");
|
||||
Serial.println(" set preset <name|num> Apply radio preset");
|
||||
Serial.println(" set channel.add <name> Add hashtag channel");
|
||||
Serial.println(" set channel.del <idx> Delete channel by index");
|
||||
#ifdef HAS_4G_MODEM
|
||||
Serial.println("");
|
||||
Serial.println(" 4G modem:");
|
||||
Serial.println(" get/set apn, get imei, set modem on/off");
|
||||
#endif
|
||||
Serial.println("");
|
||||
Serial.println(" System:");
|
||||
Serial.println(" rebuild Erase & rebuild filesystem");
|
||||
Serial.println(" erase Format filesystem");
|
||||
Serial.println(" reboot Restart device");
|
||||
Serial.println(" ls / cat / rm File operations");
|
||||
|
||||
// =====================================================================
|
||||
// Existing system commands (unchanged)
|
||||
// =====================================================================
|
||||
} else if (strcmp(cli_command, "rebuild") == 0) {
|
||||
bool success = _store->formatFileSystem();
|
||||
if (success) {
|
||||
@@ -2110,7 +2739,7 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
} else if (strcmp(cli_command, "reboot") == 0) {
|
||||
board.reboot(); // doesn't return
|
||||
} else {
|
||||
Serial.println(" Error: unknown command");
|
||||
Serial.println(" Error: unknown command (try 'help')");
|
||||
}
|
||||
|
||||
cli_command[0] = 0; // reset command buffer
|
||||
@@ -2147,9 +2776,11 @@ void MyMesh::checkSerialInterface() {
|
||||
void MyMesh::loop() {
|
||||
BaseChatMesh::loop();
|
||||
|
||||
if (_cli_rescue) {
|
||||
checkCLIRescueCmd();
|
||||
} else {
|
||||
// Always check USB serial for text CLI commands (independent of BLE)
|
||||
checkCLIRescueCmd();
|
||||
|
||||
// Process BLE/WiFi companion app binary frames
|
||||
if (!_cli_rescue) {
|
||||
checkSerialInterface();
|
||||
}
|
||||
|
||||
@@ -2159,6 +2790,12 @@ void MyMesh::loop() {
|
||||
dirty_contacts_expiry = 0;
|
||||
}
|
||||
|
||||
// Discovery scan timeout
|
||||
if (_discoveryActive && millisHasNowPassed(_discoveryTimeout)) {
|
||||
_discoveryActive = false;
|
||||
Serial.printf("[Discovery] Scan complete: %d nodes found\n", _discoveredCount);
|
||||
}
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_ui) _ui->setHasConnection(_serial->isConnected());
|
||||
#endif
|
||||
@@ -2177,4 +2814,56 @@ bool MyMesh::advert() {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::startDiscovery(uint32_t duration_ms) {
|
||||
_discoveredCount = 0;
|
||||
_discoveryActive = true;
|
||||
_discoveryTimeout = futureMillis(duration_ms);
|
||||
_discoveryTag = getRNG()->nextInt(1, 0xFFFFFFFF);
|
||||
|
||||
Serial.printf("[Discovery] Active scan started (%lu ms, tag=%08X)\n",
|
||||
duration_ms, _discoveryTag);
|
||||
|
||||
// --- Send active discovery request (CTL_TYPE_NODE_DISCOVER_REQ) ---
|
||||
// Repeaters with firmware v1.11+ will respond with their pubkey + SNR
|
||||
uint8_t ctl_payload[10];
|
||||
ctl_payload[0] = CTL_TYPE_NODE_DISCOVER_REQ; // 0x80, prefix_only=0 (full 32-byte pubkeys)
|
||||
ctl_payload[1] = (1 << ADV_TYPE_REPEATER) // repeaters
|
||||
| (1 << ADV_TYPE_ROOM); // rooms (repeaters with chat)
|
||||
memcpy(&ctl_payload[2], &_discoveryTag, 4); // random correlation tag
|
||||
uint32_t since = 0; // accept all firmware versions
|
||||
memcpy(&ctl_payload[6], &since, 4);
|
||||
|
||||
auto pkt = createControlData(ctl_payload, sizeof(ctl_payload));
|
||||
if (pkt) {
|
||||
sendZeroHop(pkt);
|
||||
Serial.println("[Discovery] Sent CTL_TYPE_NODE_DISCOVER_REQ (zero-hop)");
|
||||
} else {
|
||||
Serial.println("[Discovery] ERROR: createControlData returned NULL (packet pool full?)");
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::stopDiscovery() {
|
||||
_discoveryActive = false;
|
||||
}
|
||||
|
||||
bool MyMesh::addDiscoveredToContacts(int idx) {
|
||||
if (idx < 0 || idx >= _discoveredCount) return false;
|
||||
if (_discovered[idx].already_in_contacts) return true; // already there
|
||||
|
||||
// Retrieve cached raw advert packet and import it
|
||||
uint8_t buf[256];
|
||||
int plen = getBlobByKey(_discovered[idx].contact.id.pub_key, PUB_KEY_SIZE, buf);
|
||||
if (plen > 0) {
|
||||
bool ok = importContact(buf, (uint8_t)plen);
|
||||
if (ok) {
|
||||
_discovered[idx].already_in_contacts = true;
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
MESH_DEBUG_PRINTLN("Discovery: added contact '%s'", _discovered[idx].contact.name);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("Discovery: no cached advert blob for contact '%s'", _discovered[idx].contact.name);
|
||||
return false;
|
||||
}
|
||||
@@ -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 "7 March 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.9.5"
|
||||
#define FIRMWARE_VERSION "Meck v0.9.9"
|
||||
#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,8 +127,9 @@ 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; }
|
||||
|
||||
|
||||
@@ -118,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;
|
||||
@@ -256,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,7 @@ 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)
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
|
||||
#ifdef BLE_PIN_CODE
|
||||
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
|
||||
#endif
|
||||
#include <Mesh.h>
|
||||
#include "MyMesh.h"
|
||||
#include "variant.h" // Board-specific defines (HAS_GPS, etc.)
|
||||
#include "target.h" // For sensors, board, etc.
|
||||
#include "GPSDutyCycle.h"
|
||||
#include "CPUPowerManager.h"
|
||||
|
||||
// T-Deck Pro Keyboard support
|
||||
@@ -17,6 +18,7 @@
|
||||
#include "ChannelScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "DiscoveryScreen.h"
|
||||
#ifdef MECK_WEB_READER
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
@@ -88,10 +90,6 @@
|
||||
TouchInput touchInput(&Wire);
|
||||
#endif
|
||||
|
||||
// Power management
|
||||
#if HAS_GPS
|
||||
GPSDutyCycle gpsDuty;
|
||||
#endif
|
||||
CPUPowerManager cpuPower;
|
||||
|
||||
void initKeyboard();
|
||||
@@ -205,7 +203,10 @@
|
||||
// Returns number of contacts exported, or -1 on error.
|
||||
// -----------------------------------------------------------------------
|
||||
int exportContactsToSD() {
|
||||
if (!sdCardReady) return -1;
|
||||
if (!sdCardReady) {
|
||||
Serial.println("Export: SD card not ready");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Ensure in-memory contacts are flushed to SPIFFS first
|
||||
the_mesh.saveContacts();
|
||||
@@ -213,40 +214,53 @@
|
||||
if (!SD.exists("/meshcore")) SD.mkdir("/meshcore");
|
||||
|
||||
// 1) Binary backup: SPIFFS /contacts3 → SD /meshcore/contacts.bin
|
||||
if (!SPIFFS.exists("/contacts3")) return -1;
|
||||
if (!copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin")) return -1;
|
||||
// Non-fatal — text export reads from memory and doesn't need this.
|
||||
if (SPIFFS.exists("/contacts3")) {
|
||||
if (copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin")) {
|
||||
Serial.println("Export: binary backup OK");
|
||||
} else {
|
||||
Serial.println("Export: binary copy to SD failed (continuing with text export)");
|
||||
}
|
||||
} else {
|
||||
Serial.println("Export: /contacts3 not found on SPIFFS (skipping binary backup)");
|
||||
}
|
||||
|
||||
// 2) Human-readable listing for inspection on a computer
|
||||
// Reads from in-memory contact table — always works if SD is writable.
|
||||
int count = 0;
|
||||
File txt = SD.open("/meshcore/contacts_export.txt", "w", true);
|
||||
if (txt) {
|
||||
txt.printf("Meck Contacts Export (%d total)\n", (int)the_mesh.getNumContacts());
|
||||
txt.printf("========================================\n");
|
||||
txt.printf("%-5s %-30s %s\n", "Type", "Name", "PubKey (prefix)");
|
||||
txt.printf("----------------------------------------\n");
|
||||
|
||||
ContactInfo c;
|
||||
for (uint32_t i = 0; i < (uint32_t)the_mesh.getNumContacts(); i++) {
|
||||
if (the_mesh.getContactByIdx(i, c)) {
|
||||
const char* typeStr = "???";
|
||||
switch (c.type) {
|
||||
case ADV_TYPE_CHAT: typeStr = "Chat"; break;
|
||||
case ADV_TYPE_REPEATER: typeStr = "Rptr"; break;
|
||||
case ADV_TYPE_ROOM: typeStr = "Room"; break;
|
||||
}
|
||||
// First 8 bytes of pub key as hex identifier
|
||||
char hexBuf[20];
|
||||
mesh::Utils::toHex(hexBuf, c.id.pub_key, 8);
|
||||
txt.printf("%-5s %-30s %s\n", typeStr, c.name, hexBuf);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
txt.printf("========================================\n");
|
||||
txt.printf("Total: %d contacts\n", count);
|
||||
txt.close();
|
||||
if (!txt) {
|
||||
Serial.println("Export: failed to open contacts_export.txt for writing");
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
return -1;
|
||||
}
|
||||
|
||||
txt.printf("Meck Contacts Export (%d total)\n", (int)the_mesh.getNumContacts());
|
||||
txt.printf("========================================\n");
|
||||
txt.printf("%-5s %-30s %s\n", "Type", "Name", "PubKey (prefix)");
|
||||
txt.printf("----------------------------------------\n");
|
||||
|
||||
ContactInfo c;
|
||||
for (uint32_t i = 0; i < (uint32_t)the_mesh.getNumContacts(); i++) {
|
||||
if (the_mesh.getContactByIdx(i, c)) {
|
||||
const char* typeStr = "???";
|
||||
switch (c.type) {
|
||||
case ADV_TYPE_CHAT: typeStr = "Chat"; break;
|
||||
case ADV_TYPE_REPEATER: typeStr = "Rptr"; break;
|
||||
case ADV_TYPE_ROOM: typeStr = "Room"; break;
|
||||
}
|
||||
// First 8 bytes of pub key as hex identifier
|
||||
char hexBuf[20];
|
||||
mesh::Utils::toHex(hexBuf, c.id.pub_key, 8);
|
||||
txt.printf("%-5s %-30s %s\n", typeStr, c.name, hexBuf);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
txt.printf("========================================\n");
|
||||
txt.printf("Total: %d contacts\n", count);
|
||||
txt.close();
|
||||
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.printf("Contacts exported to SD: %d contacts\n", count);
|
||||
return count;
|
||||
@@ -368,6 +382,13 @@ static uint32_t _atoi(const char* sp) {
|
||||
#ifndef TCP_PORT
|
||||
#define TCP_PORT 5000
|
||||
#endif
|
||||
#elif defined(MECK_WIFI_COMPANION)
|
||||
#include <WiFi.h>
|
||||
#include <helpers/esp32/SerialWifiInterface.h>
|
||||
SerialWifiInterface serial_interface;
|
||||
#ifndef TCP_PORT
|
||||
#define TCP_PORT 5000
|
||||
#endif
|
||||
#elif defined(BLE_PIN_CODE)
|
||||
#include <helpers/esp32/SerialBLEInterface.h>
|
||||
SerialBLEInterface serial_interface;
|
||||
@@ -415,6 +436,7 @@ static uint32_t _atoi(const char* sp) {
|
||||
/* GLOBAL OBJECTS */
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include "UITask.h"
|
||||
#include "MapScreen.h" // After BLE — PNGdec headers conflict with BLE if included earlier
|
||||
UITask ui_task(&board, &serial_interface);
|
||||
#endif
|
||||
|
||||
@@ -643,9 +665,44 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - the_mesh.begin() done");
|
||||
|
||||
#ifdef WIFI_SSID
|
||||
MESH_DEBUG_PRINTLN("setup() - WiFi mode");
|
||||
MESH_DEBUG_PRINTLN("setup() - WiFi mode (compile-time credentials)");
|
||||
WiFi.begin(WIFI_SSID, WIFI_PWD);
|
||||
serial_interface.begin(TCP_PORT);
|
||||
#elif defined(MECK_WIFI_COMPANION)
|
||||
{
|
||||
// WiFi companion: load credentials from SD at runtime.
|
||||
// TCP server starts regardless — companion connects when WiFi comes up.
|
||||
MESH_DEBUG_PRINTLN("setup() - WiFi companion mode (runtime credentials)");
|
||||
WiFi.mode(WIFI_STA);
|
||||
if (sdCardReady) {
|
||||
File f = SD.open("/web/wifi.cfg", FILE_READ);
|
||||
if (f) {
|
||||
String ssid = f.readStringUntil('\n'); ssid.trim();
|
||||
String pass = f.readStringUntil('\n'); pass.trim();
|
||||
f.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
if (ssid.length() > 0) {
|
||||
MESH_DEBUG_PRINTLN("setup() - WiFi: connecting to '%s'", ssid.c_str());
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
unsigned long timeout = millis() + 8000;
|
||||
while (WiFi.status() != WL_CONNECTED && millis() < timeout) {
|
||||
delay(100);
|
||||
}
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("WiFi companion: connected to %s, IP: %s\n",
|
||||
ssid.c_str(), WiFi.localIP().toString().c_str());
|
||||
} else {
|
||||
Serial.println("WiFi companion: auto-connect failed (configure in Settings)");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.println("WiFi companion: no /web/wifi.cfg found (configure in Settings)");
|
||||
}
|
||||
}
|
||||
serial_interface.begin(TCP_PORT);
|
||||
MESH_DEBUG_PRINTLN("setup() - WiFi TCP server started on port %d", TCP_PORT);
|
||||
}
|
||||
#elif defined(BLE_PIN_CODE)
|
||||
MESH_DEBUG_PRINTLN("setup() - about to call serial_interface.begin() with BLE");
|
||||
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
||||
@@ -781,18 +838,22 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// GPS duty cycle  honour saved pref, default to enabled on first boot
|
||||
// GPS power — honour saved pref, default to enabled on first boot
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
|
||||
gpsDuty.setStreamCounter(&gpsStream);
|
||||
gpsDuty.begin(gps_wanted);
|
||||
if (gps_wanted) {
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
|
||||
#endif
|
||||
sensors.setSettingValue("gps", "1");
|
||||
} else {
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
#endif
|
||||
sensors.setSettingValue("gps", "0");
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("setup() - GPS duty cycle started (enabled=%d)", gps_wanted);
|
||||
MESH_DEBUG_PRINTLN("setup() - GPS power %s", gps_wanted ? "on" : "off");
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -811,20 +872,35 @@ void setup() {
|
||||
void loop() {
|
||||
the_mesh.loop();
|
||||
|
||||
// GPS duty cycle  check for fix and manage power state
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_hw_on = gpsDuty.loop();
|
||||
if (gps_hw_on) {
|
||||
LocationProvider* lp = sensors.getLocationProvider();
|
||||
if (lp != NULL && lp->isValid()) {
|
||||
gpsDuty.notifyFix();
|
||||
|
||||
sensors.loop();
|
||||
|
||||
// Map screen: periodically update own GPS position and contact markers
|
||||
if (ui_task.isOnMapScreen()) {
|
||||
static unsigned long lastMapUpdate = 0;
|
||||
if (millis() - lastMapUpdate > 30000) { // Every 30 seconds
|
||||
lastMapUpdate = millis();
|
||||
MapScreen* ms = (MapScreen*)ui_task.getMapScreen();
|
||||
if (ms) {
|
||||
// Update own GPS position when GPS is enabled
|
||||
#if HAS_GPS
|
||||
ms->updateGPSPosition(sensors.node_lat, sensors.node_lon);
|
||||
#endif
|
||||
|
||||
// Always refresh contact markers (new contacts arrive via radio)
|
||||
ms->clearMarkers();
|
||||
ContactsIterator it = the_mesh.startContactsIterator();
|
||||
ContactInfo ci;
|
||||
while (it.hasNext(&the_mesh, ci)) {
|
||||
if (ci.gps_lat != 0 || ci.gps_lon != 0) {
|
||||
double lat = ((double)ci.gps_lat) / 1000000.0;
|
||||
double lon = ((double)ci.gps_lon) / 1000000.0;
|
||||
ms->addMarker(lat, lon, ci.name, ci.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
sensors.loop();
|
||||
|
||||
// CPU frequency auto-timeout back to idle
|
||||
cpuPower.loop();
|
||||
@@ -1156,7 +1232,7 @@ void handleKeyboardInput() {
|
||||
if (wasDM) {
|
||||
ui_task.gotoContactsScreen();
|
||||
} else {
|
||||
ui_task.gotoHomeScreen();
|
||||
ui_task.gotoChannelScreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1176,7 +1252,7 @@ void handleKeyboardInput() {
|
||||
if (wasDM) {
|
||||
ui_task.gotoContactsScreen();
|
||||
} else {
|
||||
ui_task.gotoHomeScreen();
|
||||
ui_task.gotoChannelScreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1484,8 +1560,8 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// In menu state: Shift+Del exits to contacts, C opens compose
|
||||
if (astate == RepeaterAdminScreen::STATE_MENU) {
|
||||
// In category menu (top level): Shift+Del exits to contacts, C opens compose
|
||||
if (astate == RepeaterAdminScreen::STATE_CATEGORY_MENU) {
|
||||
if (shiftDel) {
|
||||
Serial.println("Nav: Back to contacts from admin menu");
|
||||
ui_task.gotoContactsScreen();
|
||||
@@ -1507,8 +1583,9 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// In waiting/response/error states: convert Shift+Del to exit signal,
|
||||
// pass all other keys through
|
||||
// All other states (command menu, param entry, confirm, waiting,
|
||||
// response, error): convert Shift+Del to exit signal and let the
|
||||
// screen handle back-navigation internally
|
||||
if (shiftDel) {
|
||||
ui_task.injectKey(KEY_ADMIN_EXIT);
|
||||
} else {
|
||||
@@ -1681,7 +1758,12 @@ void handleKeyboardInput() {
|
||||
Serial.println("Opening web reader");
|
||||
{
|
||||
static bool webReaderWifiReady = false;
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
// WiFi companion: WiFi is already up from boot, no BLE to tear down
|
||||
webReaderWifiReady = true;
|
||||
#endif
|
||||
if (!webReaderWifiReady) {
|
||||
#ifdef BLE_PIN_CODE
|
||||
// WiFi needs ~40KB contiguous heap. The BLE controller holds ~30KB,
|
||||
// leaving only ~30KB largest block. We MUST release BLE memory first.
|
||||
//
|
||||
@@ -1700,14 +1782,14 @@ void handleKeyboardInput() {
|
||||
|
||||
Serial.printf("WebReader: heap AFTER BT release: free=%d, largest=%d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
#endif
|
||||
|
||||
// 3) Now init WiFi while we have maximum contiguous heap
|
||||
// Init WiFi while we have maximum contiguous heap
|
||||
if (WiFi.mode(WIFI_STA)) {
|
||||
Serial.println("WebReader: WiFi STA init OK");
|
||||
webReaderWifiReady = true;
|
||||
} else {
|
||||
Serial.println("WebReader: WiFi STA init FAILED even after BT release");
|
||||
// Clean up partial WiFi init to avoid memory leak
|
||||
Serial.println("WebReader: WiFi STA init FAILED");
|
||||
WiFi.mode(WIFI_OFF);
|
||||
}
|
||||
|
||||
@@ -1719,6 +1801,39 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
#endif
|
||||
|
||||
case 'g':
|
||||
// Open map screen, or re-center on GPS if already on map
|
||||
if (ui_task.isOnMapScreen()) {
|
||||
ui_task.injectKey('g'); // Re-center on GPS
|
||||
} else {
|
||||
Serial.println("Opening map");
|
||||
{
|
||||
MapScreen* ms = (MapScreen*)ui_task.getMapScreen();
|
||||
if (ms) {
|
||||
ms->setSDReady(sdCardReady);
|
||||
ms->setGPSPosition(sensors.node_lat,
|
||||
sensors.node_lon);
|
||||
// Populate contact markers via iterator
|
||||
ms->clearMarkers();
|
||||
ContactsIterator it = the_mesh.startContactsIterator();
|
||||
ContactInfo ci;
|
||||
int markerCount = 0;
|
||||
while (it.hasNext(&the_mesh, ci)) {
|
||||
if (ci.gps_lat != 0 || ci.gps_lon != 0) {
|
||||
double lat = ((double)ci.gps_lat) / 1000000.0;
|
||||
double lon = ((double)ci.gps_lon) / 1000000.0;
|
||||
ms->addMarker(lat, lon, ci.name, ci.type);
|
||||
markerCount++;
|
||||
Serial.printf(" marker: %s @ %.4f,%.4f (type=%d)\n", ci.name, lat, lon, ci.type);
|
||||
}
|
||||
}
|
||||
Serial.printf("MapScreen: %d contacts with GPS position\n", markerCount);
|
||||
}
|
||||
}
|
||||
ui_task.gotoMapScreen();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'n':
|
||||
// Open notes
|
||||
Serial.println("Opening notes");
|
||||
@@ -1734,11 +1849,13 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case 's':
|
||||
// Open settings (from home), or navigate down on channel/contacts/admin/web
|
||||
// Open settings (from home), or navigate down on channel/contacts/admin/web/map/discovery
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|
||||
|| ui_task.isOnDiscoveryScreen()
|
||||
#ifdef MECK_WEB_READER
|
||||
|| ui_task.isOnWebReader()
|
||||
#endif
|
||||
|| ui_task.isOnMapScreen()
|
||||
) {
|
||||
ui_task.injectKey('s'); // Pass directly for scrolling
|
||||
} else {
|
||||
@@ -1750,9 +1867,11 @@ void handleKeyboardInput() {
|
||||
case 'w':
|
||||
// Navigate up/previous (scroll on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|
||||
|| ui_task.isOnDiscoveryScreen()
|
||||
#ifdef MECK_WEB_READER
|
||||
|| ui_task.isOnWebReader()
|
||||
#endif
|
||||
|| ui_task.isOnMapScreen()
|
||||
) {
|
||||
ui_task.injectKey('w'); // Pass directly for scrolling
|
||||
} else {
|
||||
@@ -1763,7 +1882,7 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'a':
|
||||
// Navigate left or switch channel (on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()) {
|
||||
ui_task.injectKey('a'); // Pass directly for channel/contacts switching
|
||||
} else {
|
||||
Serial.println("Nav: Previous");
|
||||
@@ -1773,7 +1892,7 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'd':
|
||||
// Navigate right or switch channel (on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()) {
|
||||
ui_task.injectKey('d'); // Pass directly for channel/contacts switching
|
||||
} else {
|
||||
Serial.println("Nav: Next");
|
||||
@@ -1856,15 +1975,41 @@ void handleKeyboardInput() {
|
||||
}
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
} else if (ui_task.isOnDiscoveryScreen()) {
|
||||
// Discovery screen: Enter adds selected node to contacts
|
||||
DiscoveryScreen* ds = (DiscoveryScreen*)ui_task.getDiscoveryScreen();
|
||||
int didx = ds->getSelectedIdx();
|
||||
if (didx >= 0 && didx < the_mesh.getDiscoveredCount()) {
|
||||
const DiscoveredNode& node = the_mesh.getDiscovered(didx);
|
||||
if (node.already_in_contacts) {
|
||||
ui_task.showAlert("Already in contacts", 800);
|
||||
} else if (the_mesh.addDiscoveredToContacts(didx)) {
|
||||
char alertBuf[48];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "Added: %s", node.contact.name);
|
||||
ui_task.showAlert(alertBuf, 1500);
|
||||
ui_task.notify(UIEventType::ack);
|
||||
} else {
|
||||
ui_task.showAlert("Add failed", 1000);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Other screens: pass Enter as generic select
|
||||
ui_task.injectKey(13);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'z':
|
||||
// Zoom in on map screen
|
||||
if (ui_task.isOnMapScreen()) {
|
||||
ui_task.injectKey('z');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'x':
|
||||
// Export contacts to SD card (contacts screen only)
|
||||
if (ui_task.isOnContactsScreen()) {
|
||||
// Zoom out on map screen, or export contacts on contacts screen
|
||||
if (ui_task.isOnMapScreen()) {
|
||||
ui_task.injectKey('x');
|
||||
} else if (ui_task.isOnContactsScreen()) {
|
||||
Serial.println("Contacts: Exporting to SD...");
|
||||
int exported = exportContactsToSD();
|
||||
if (exported >= 0) {
|
||||
@@ -1872,7 +2017,7 @@ void handleKeyboardInput() {
|
||||
snprintf(alertBuf, sizeof(alertBuf), "Exported %d to SD", exported);
|
||||
ui_task.showAlert(alertBuf, 2000);
|
||||
} else {
|
||||
ui_task.showAlert("Export failed (no SD?)", 2000);
|
||||
ui_task.showAlert("Export failed (check serial)", 2000);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -1900,6 +2045,17 @@ void handleKeyboardInput() {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'f':
|
||||
// Start discovery scan from contacts screen, or rescan on discovery screen
|
||||
if (ui_task.isOnContactsScreen()) {
|
||||
Serial.println("Contacts: Starting discovery scan...");
|
||||
the_mesh.startDiscovery();
|
||||
ui_task.gotoDiscoveryScreen();
|
||||
} else if (ui_task.isOnDiscoveryScreen()) {
|
||||
ui_task.injectKey('f'); // pass through for rescan
|
||||
}
|
||||
break;
|
||||
|
||||
case 'q':
|
||||
case '\b':
|
||||
// If channel screen reply select or path overlay is showing, dismiss it
|
||||
@@ -1925,6 +2081,13 @@ void handleKeyboardInput() {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Discovery screen: Q goes back to contacts (not home)
|
||||
if (ui_task.isOnDiscoveryScreen()) {
|
||||
the_mesh.stopDiscovery();
|
||||
Serial.println("Nav: Discovery -> Contacts");
|
||||
ui_task.gotoContactsScreen();
|
||||
break;
|
||||
}
|
||||
// Go back to home screen (admin mode handled above)
|
||||
Serial.println("Nav: Back to home");
|
||||
ui_task.gotoHomeScreen();
|
||||
@@ -1956,6 +2119,11 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
// Pass unhandled keys to map screen (+, -, i, o for zoom)
|
||||
if (ui_task.isOnMapScreen()) {
|
||||
ui_task.injectKey(key);
|
||||
break;
|
||||
}
|
||||
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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]))
|
||||
@@ -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();
|
||||
@@ -1486,6 +1577,12 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
|
||||
((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) {
|
||||
@@ -1511,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)
|
||||
@@ -1536,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);
|
||||
@@ -1550,6 +1670,14 @@ void UITask::onAdminCliResponse(const char* from_name, const char* text) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
@@ -155,9 +175,13 @@ public:
|
||||
// Add a sent message to the channel screen history
|
||||
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
|
||||
|
||||
// Mark channel as read when BLE companion app syncs messages
|
||||
void markChannelReadFromBLE(uint8_t channel_idx) override;
|
||||
|
||||
// Repeater admin callbacks
|
||||
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
|
||||
void onAdminCliResponse(const char* from_name, const char* text) override;
|
||||
void onAdminTelemetryResult(const uint8_t* data, uint8_t len) override;
|
||||
|
||||
// Get current screen for checking state
|
||||
UIScreen* getCurrentScreen() const { return curr; }
|
||||
@@ -170,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
|
||||
@@ -177,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];
|
||||
};
|
||||
};
|
||||
@@ -12,7 +12,31 @@
|
||||
#include <Fonts/FreeSans9pt7b.h>
|
||||
#include <Fonts/FreeSansBold12pt7b.h>
|
||||
#include <Fonts/FreeSans18pt7b.h>
|
||||
#include <CRC32.h>
|
||||
|
||||
// Inline CRC32 for frame change detection (replaces bakercp/CRC32
|
||||
// to avoid naming collision with PNGdec's bundled CRC32.h)
|
||||
class FrameCRC32 {
|
||||
uint32_t _crc = 0xFFFFFFFF;
|
||||
public:
|
||||
void reset() { _crc = 0xFFFFFFFF; }
|
||||
template<typename T> void update(T val) {
|
||||
const uint8_t* p = (const uint8_t*)&val;
|
||||
for (size_t i = 0; i < sizeof(T); i++) {
|
||||
_crc ^= p[i];
|
||||
for (int b = 0; b < 8; b++)
|
||||
_crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1));
|
||||
}
|
||||
}
|
||||
template<typename T> void update(const T* data, size_t len) {
|
||||
const uint8_t* p = (const uint8_t*)data;
|
||||
for (size_t i = 0; i < len * sizeof(T); i++) {
|
||||
_crc ^= p[i];
|
||||
for (int b = 0; b < 8; b++)
|
||||
_crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1));
|
||||
}
|
||||
}
|
||||
uint32_t finalize() { return _crc ^ 0xFFFFFFFF; }
|
||||
};
|
||||
|
||||
#include "DisplayDriver.h"
|
||||
|
||||
@@ -34,7 +58,7 @@ class GxEPDDisplay : public DisplayDriver {
|
||||
bool _init = false;
|
||||
bool _isOn = false;
|
||||
uint16_t _curr_color;
|
||||
CRC32 display_crc;
|
||||
FrameCRC32 display_crc;
|
||||
int last_display_crc_value = 0;
|
||||
|
||||
public:
|
||||
@@ -60,4 +84,24 @@ public:
|
||||
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
|
||||
uint16_t getTextWidth(const char* str) override;
|
||||
void endFrame() override;
|
||||
};
|
||||
|
||||
// --- Raw pixel access for MapScreen (bypasses scaling) ---
|
||||
void drawPixelRaw(int16_t x, int16_t y, uint16_t color) {
|
||||
display.drawPixel(x, y, color);
|
||||
}
|
||||
int16_t rawWidth() { return display.width(); }
|
||||
int16_t rawHeight() { return display.height(); }
|
||||
|
||||
// Draw text at raw (unscaled) physical coordinates using built-in 5x7 font
|
||||
void drawTextRaw(int16_t x, int16_t y, const char* text, uint16_t color) {
|
||||
display.setFont(NULL); // Built-in 5x7 font
|
||||
display.setTextSize(1);
|
||||
display.setTextColor(color);
|
||||
display.setCursor(x, y);
|
||||
display.print(text);
|
||||
}
|
||||
|
||||
// Force endFrame() to push to display even if CRC unchanged
|
||||
// (needed because drawPixelRaw bypasses CRC tracking)
|
||||
void invalidateFrameCRC() { last_display_crc_value = 0; }
|
||||
};
|
||||
@@ -1,185 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "variant.h"
|
||||
#include "GPSStreamCounter.h"
|
||||
|
||||
// GPS Duty Cycle Manager
|
||||
// Controls the hardware GPS enable pin (PIN_GPS_EN) to save power.
|
||||
// When enabled, cycles between acquiring a fix and sleeping with power cut.
|
||||
//
|
||||
// States:
|
||||
// OFF – User has disabled GPS. Hardware power is cut.
|
||||
// ACQUIRING – GPS module powered on, waiting for a fix or timeout.
|
||||
// SLEEPING – GPS module powered off, timer counting down to next cycle.
|
||||
|
||||
#if HAS_GPS
|
||||
|
||||
// How long to leave GPS powered on while acquiring a fix (ms)
|
||||
#ifndef GPS_ACQUIRE_TIMEOUT_MS
|
||||
#define GPS_ACQUIRE_TIMEOUT_MS 180000 // 3 minutes
|
||||
#endif
|
||||
|
||||
// How long to sleep between acquisition cycles (ms)
|
||||
#ifndef GPS_SLEEP_DURATION_MS
|
||||
#define GPS_SLEEP_DURATION_MS 900000 // 15 minutes
|
||||
#endif
|
||||
|
||||
// If we get a fix quickly, power off immediately but still respect
|
||||
// a minimum on-time so the RTC can sync properly
|
||||
#ifndef GPS_MIN_ON_TIME_MS
|
||||
#define GPS_MIN_ON_TIME_MS 5000 // 5 seconds after fix
|
||||
#endif
|
||||
|
||||
enum class GPSDutyState : uint8_t {
|
||||
OFF = 0, // User-disabled, hardware power off
|
||||
ACQUIRING, // Hardware on, waiting for fix
|
||||
SLEEPING // Hardware off, timer running
|
||||
};
|
||||
|
||||
class GPSDutyCycle {
|
||||
public:
|
||||
GPSDutyCycle() : _state(GPSDutyState::OFF), _state_entered(0),
|
||||
_last_fix_time(0), _got_fix(false), _time_synced(false),
|
||||
_stream(nullptr) {}
|
||||
|
||||
// Attach the stream counter so we can reset it on power cycles
|
||||
void setStreamCounter(GPSStreamCounter* stream) { _stream = stream; }
|
||||
|
||||
// Call once in setup() after board.begin() and GPS serial init.
|
||||
void begin(bool initial_enable) {
|
||||
if (initial_enable) {
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
} else {
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::OFF);
|
||||
}
|
||||
}
|
||||
|
||||
// Call every iteration of loop().
|
||||
// Returns true if GPS hardware is currently powered on.
|
||||
bool loop() {
|
||||
switch (_state) {
|
||||
case GPSDutyState::OFF:
|
||||
return false;
|
||||
|
||||
case GPSDutyState::ACQUIRING: {
|
||||
unsigned long elapsed = millis() - _state_entered;
|
||||
|
||||
if (_got_fix && elapsed >= GPS_MIN_ON_TIME_MS) {
|
||||
MESH_DEBUG_PRINTLN("GPS duty: fix acquired, powering off for %u min",
|
||||
(unsigned)(GPS_SLEEP_DURATION_MS / 60000));
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::SLEEPING);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (elapsed >= GPS_ACQUIRE_TIMEOUT_MS) {
|
||||
MESH_DEBUG_PRINTLN("GPS duty: acquire timeout (%us), sleeping",
|
||||
(unsigned)(GPS_ACQUIRE_TIMEOUT_MS / 1000));
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::SLEEPING);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
case GPSDutyState::SLEEPING: {
|
||||
if (millis() - _state_entered >= GPS_SLEEP_DURATION_MS) {
|
||||
MESH_DEBUG_PRINTLN("GPS duty: waking up for next acquisition cycle");
|
||||
_got_fix = false;
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void notifyFix() {
|
||||
if (_state == GPSDutyState::ACQUIRING && !_got_fix) {
|
||||
_got_fix = true;
|
||||
_last_fix_time = millis();
|
||||
MESH_DEBUG_PRINTLN("GPS duty: fix notification received");
|
||||
}
|
||||
}
|
||||
|
||||
void notifyTimeSync() {
|
||||
_time_synced = true;
|
||||
}
|
||||
|
||||
void enable() {
|
||||
if (_state == GPSDutyState::OFF) {
|
||||
_got_fix = false;
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
MESH_DEBUG_PRINTLN("GPS duty: enabled, starting acquisition");
|
||||
}
|
||||
}
|
||||
|
||||
void disable() {
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::OFF);
|
||||
_got_fix = false;
|
||||
MESH_DEBUG_PRINTLN("GPS duty: disabled, power off");
|
||||
}
|
||||
|
||||
void forceWake() {
|
||||
if (_state == GPSDutyState::SLEEPING) {
|
||||
_got_fix = false;
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
MESH_DEBUG_PRINTLN("GPS duty: forced wake for user request");
|
||||
}
|
||||
}
|
||||
|
||||
GPSDutyState getState() const { return _state; }
|
||||
bool isHardwareOn() const { return _state == GPSDutyState::ACQUIRING; }
|
||||
bool hadFix() const { return _got_fix; }
|
||||
bool hasTimeSynced() const { return _time_synced; }
|
||||
|
||||
uint32_t sleepRemainingSecs() const {
|
||||
if (_state != GPSDutyState::SLEEPING) return 0;
|
||||
unsigned long elapsed = millis() - _state_entered;
|
||||
if (elapsed >= GPS_SLEEP_DURATION_MS) return 0;
|
||||
return (GPS_SLEEP_DURATION_MS - elapsed) / 1000;
|
||||
}
|
||||
|
||||
uint32_t acquireElapsedSecs() const {
|
||||
if (_state != GPSDutyState::ACQUIRING) return 0;
|
||||
return (millis() - _state_entered) / 1000;
|
||||
}
|
||||
|
||||
private:
|
||||
void _powerOn() {
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
|
||||
delay(10);
|
||||
#endif
|
||||
if (_stream) _stream->resetCounters();
|
||||
}
|
||||
|
||||
void _powerOff() {
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
#endif
|
||||
}
|
||||
|
||||
void _setState(GPSDutyState s) {
|
||||
_state = s;
|
||||
_state_entered = millis();
|
||||
}
|
||||
|
||||
GPSDutyState _state;
|
||||
unsigned long _state_entered;
|
||||
unsigned long _last_fix_time;
|
||||
bool _got_fix;
|
||||
bool _time_synced;
|
||||
GPSStreamCounter* _stream;
|
||||
};
|
||||
|
||||
#endif // HAS_GPS
|
||||
@@ -78,6 +78,25 @@ void TDeckBoard::begin() {
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - Battery voltage: %d mV", voltage);
|
||||
configureFuelGauge();
|
||||
#endif
|
||||
|
||||
// --- Early low-voltage protection ---
|
||||
// If we boot below the shutdown threshold, go straight to deep sleep
|
||||
// WITHOUT touching the filesystem. This breaks the brown-out reboot
|
||||
// loop that corrupts contacts when battery is deeply depleted (~2.5V).
|
||||
#if HAS_BQ27220 && defined(AUTO_SHUTDOWN_MILLIVOLTS)
|
||||
{
|
||||
uint16_t bootMv = getBattMilliVolts();
|
||||
if (bootMv > 0 && bootMv < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
Serial.printf("CRITICAL: Boot voltage %dmV < %dmV — sleeping immediately\n",
|
||||
bootMv, AUTO_SHUTDOWN_MILLIVOLTS);
|
||||
// Don't mount SD, don't load contacts, don't pass Go.
|
||||
// Only wake on user button press (presumably after plugging in charger).
|
||||
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
|
||||
esp_sleep_enable_ext1_wakeup(1ULL << PIN_USER_BTN, ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
esp_deep_sleep_start(); // CPU halts here
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - complete");
|
||||
}
|
||||
@@ -161,7 +180,7 @@ static bool bq27220_writeControl(uint16_t subcmd) {
|
||||
#endif
|
||||
|
||||
// ---- BQ27220 Design Capacity configuration ----
|
||||
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 1400 mAh
|
||||
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 2000 mAh
|
||||
// cell. This function checks on boot and writes the correct value via the
|
||||
// MAC Data Memory interface if needed. The value persists in battery-backed
|
||||
// RAM, so this typically only writes once (or after a full battery disconnect).
|
||||
@@ -178,29 +197,23 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
|
||||
if (currentDC == designCapacity_mAh) {
|
||||
// Design Capacity correct, but check if Full Charge Capacity is sane.
|
||||
// After a Design Capacity change, FCC may still hold the old factory
|
||||
// value (e.g. 3000 mAh) until a RESET forces reinitialization.
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity already correct, FCC=%d mAh\n", fcc);
|
||||
if (fcc >= designCapacity_mAh * 3 / 2) {
|
||||
// FCC is >=150% of design — stale from factory defaults.
|
||||
// The gauge derives FCC from Design Energy (not just Design Capacity).
|
||||
// Design Energy = capacity × nominal voltage (3.7V for LiPo).
|
||||
// If Design Energy still reflects 3000 mAh, FCC stays at 3000.
|
||||
// Fix: enter CFG_UPDATE and write correct Design Energy.
|
||||
Serial.printf("BQ27220: FCC %d >> DC %d, updating Design Energy\n",
|
||||
fcc, designCapacity_mAh);
|
||||
|
||||
// FCC is >=150% of design — stale from factory defaults (typically 3000 mAh).
|
||||
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
|
||||
Serial.printf("BQ27220: Target Design Energy = %d mWh\n", designEnergy);
|
||||
Serial.printf("BQ27220: FCC %d >> DC %d, checking Design Energy (target %d mWh)\n",
|
||||
fcc, designCapacity_mAh, designEnergy);
|
||||
|
||||
// Unseal
|
||||
// Unseal to read data memory and issue RESET
|
||||
bq27220_writeControl(0x0414); delay(2);
|
||||
bq27220_writeControl(0x3672); delay(2);
|
||||
// Full Access
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
// Enter CFG_UPDATE
|
||||
|
||||
// Read current Design Energy from data memory to check if it needs writing
|
||||
// Enter CFG_UPDATE to access data memory
|
||||
bq27220_writeControl(0x0090);
|
||||
bool ready = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
@@ -209,52 +222,135 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
if (opSt & 0x0400) { ready = true; break; }
|
||||
}
|
||||
if (ready) {
|
||||
// Design Energy is at data memory address 0x92A1 (2 bytes after DC at 0x929F)
|
||||
// Read old values for checksum calculation
|
||||
// Read Design Energy at data memory address 0x92A1
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint16_t currentDE = (oldMSB << 8) | oldLSB;
|
||||
|
||||
uint8_t newMSB = (designEnergy >> 8) & 0xFF;
|
||||
uint8_t newLSB = designEnergy & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
if (currentDE != designEnergy) {
|
||||
// Design Energy actually needs updating — write it
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint8_t newMSB = (designEnergy >> 8) & 0xFF;
|
||||
uint8_t newLSB = designEnergy & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: DE old=0x%02X%02X new=0x%02X%02X chk=0x%02X\n",
|
||||
oldMSB, oldLSB, newMSB, newLSB, newChk);
|
||||
Serial.printf("BQ27220: DE old=%d new=%d mWh, writing\n", currentDE, designEnergy);
|
||||
|
||||
// Write new Design Energy
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.write(newMSB); Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
// Write checksum
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(newChk); Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.write(newMSB); Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(newChk); Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
// Exit CFG_UPDATE with reinit
|
||||
bq27220_writeControl(0x0091);
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Design Energy updated, exited CFG_UPDATE");
|
||||
// Exit with reinit since we actually changed data
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Design Energy written, exited CFG_UPDATE");
|
||||
} else {
|
||||
// DC=2000, DE=7400, Update Status=0x00, but FCC is stuck at 3000.
|
||||
// Diagnostic scan found the culprits:
|
||||
// 0x9106 = Qmax Cell 0 (IT Cfg class) — the raw capacity the
|
||||
// gauge uses for FCC calculation. Factory default 3000.
|
||||
// 0x929D = Stored FCC reference (Gas Gauging class, 2 bytes
|
||||
// before Design Capacity). Also stuck at 3000.
|
||||
//
|
||||
// Fix: overwrite both with designCapacity_mAh (2000).
|
||||
Serial.printf("BQ27220: DE correct (%d mWh) — fixing Qmax + stored FCC\n", currentDE);
|
||||
|
||||
// --- Helper lambda for MAC data memory 2-byte write ---
|
||||
// Reads old value + checksum, computes differential checksum, writes new value.
|
||||
auto writeDM16 = [](uint16_t addr, uint16_t newVal) -> bool {
|
||||
// Select address
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(addr & 0xFF);
|
||||
Wire.write((addr >> 8) & 0xFF);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint16_t oldVal = (oldMSB << 8) | oldLSB;
|
||||
|
||||
if (oldVal == newVal) {
|
||||
Serial.printf("BQ27220: [0x%04X] already %d, skip\n", addr, newVal);
|
||||
return true; // already correct
|
||||
}
|
||||
|
||||
uint8_t newMSB = (newVal >> 8) & 0xFF;
|
||||
uint8_t newLSB = newVal & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: [0x%04X] %d -> %d\n", addr, oldVal, newVal);
|
||||
|
||||
// Write new value
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(addr & 0xFF);
|
||||
Wire.write((addr >> 8) & 0xFF);
|
||||
Wire.write(newMSB);
|
||||
Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
|
||||
// Write checksum
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60);
|
||||
Wire.write(newChk);
|
||||
Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Overwrite Qmax Cell 0 (IT Cfg) — this is what FCC is derived from
|
||||
writeDM16(0x9106, designCapacity_mAh);
|
||||
|
||||
// Overwrite stored FCC reference (Gas Gauging, 2 bytes before DC)
|
||||
writeDM16(0x929D, designCapacity_mAh);
|
||||
|
||||
// Exit with reinit to apply the new values
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Qmax + stored FCC updated, exited CFG_UPDATE");
|
||||
}
|
||||
} else {
|
||||
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE fix");
|
||||
bq27220_writeControl(0x0092); // Exit cleanly
|
||||
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE check");
|
||||
}
|
||||
|
||||
// Seal
|
||||
bq27220_writeControl(0x0030);
|
||||
// Seal first, then issue RESET.
|
||||
// RESET forces the gauge to fully reinitialize its Impedance Track
|
||||
// algorithm and recalculate FCC from the current DC/DE values.
|
||||
// This is the actual fix when DC and DE are correct but FCC is stuck.
|
||||
bq27220_writeControl(0x0030); // SEAL
|
||||
delay(5);
|
||||
Serial.println("BQ27220: Issuing RESET to force FCC recalculation...");
|
||||
bq27220_writeControl(0x0041); // RESET
|
||||
delay(2000); // Full reset needs generous settle time
|
||||
|
||||
fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: FCC after Design Energy update: %d mAh\n", fcc);
|
||||
Serial.printf("BQ27220: FCC after RESET: %d mAh (target <= %d)\n", fcc, designCapacity_mAh);
|
||||
|
||||
if (fcc > designCapacity_mAh * 3 / 2) {
|
||||
// RESET didn't fix FCC — the gauge IT algorithm is stubbornly
|
||||
// retaining its learned value. This typically resolves after one
|
||||
// full charge/discharge cycle. Software clamp in
|
||||
// getFullChargeCapacity() ensures correct display regardless.
|
||||
Serial.printf("BQ27220: FCC still stale at %d — software clamp active\n", fcc);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -415,6 +511,17 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
bq27220_writeControl(0x0030);
|
||||
delay(5);
|
||||
|
||||
// Step 7: Force full gauge RESET to reinitialize FCC from new DC/DE.
|
||||
// Without this, the Impedance Track algorithm retains the old FCC
|
||||
// (often 3000 mAh from factory) until a full charge/discharge cycle.
|
||||
bq27220_writeControl(0x0041); // RESET
|
||||
delay(1000); // Gauge needs time to fully reinitialize
|
||||
|
||||
// Re-verify after hard reset
|
||||
verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
newFCC = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Post-RESET DC=%d FCC=%d mAh\n", verifyDC, newFCC);
|
||||
|
||||
return verifyDC == designCapacity_mAh;
|
||||
#else
|
||||
return false;
|
||||
@@ -455,7 +562,12 @@ uint16_t TDeckBoard::getRemainingCapacity() {
|
||||
|
||||
uint16_t TDeckBoard::getFullChargeCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
// Clamp to design capacity — the gauge may report a stale factory FCC
|
||||
// (e.g. 3000 mAh) until it completes a full learning cycle. Never let
|
||||
// the reported FCC exceed what the actual cell can hold.
|
||||
if (fcc > BQ27220_DESIGN_CAPACITY_MAH) fcc = BQ27220_DESIGN_CAPACITY_MAH;
|
||||
return fcc;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
|
||||
@@ -62,6 +62,8 @@ build_flags =
|
||||
-D EINK_MOSI=33
|
||||
-D EINK_BL=45
|
||||
-D EINK_NOT_HIBERNATE=1
|
||||
-D HAS_BQ27220=1
|
||||
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
|
||||
-D EINK_LIMIT_FASTREFRESH=10
|
||||
-D EINK_LIMIT_GHOSTING_PX=2000
|
||||
-D DISPLAY_ROTATION=0
|
||||
@@ -89,10 +91,10 @@ lib_deps =
|
||||
${sensor_base.lib_deps}
|
||||
zinggjm/GxEPD2@^1.5.9
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
bakercp/CRC32@^2.0.0
|
||||
bitbank2/PNGdec@^1.0.1
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Meck unified builds — one codebase, three variants via build flags
|
||||
; Meck unified builds — one codebase, six variants via build flags
|
||||
; ---------------------------------------------------------------------------
|
||||
|
||||
; Audio + BLE companion (audio-player hardware with BLE phone bridging)
|
||||
@@ -120,6 +122,39 @@ lib_deps =
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
; Audio + WiFi companion (audio-player hardware with WiFi app bridging)
|
||||
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
|
||||
; Connect via MeshCore web app, meshcore.js, or Python CLI over local network.
|
||||
; No BLE protocol ceiling on contacts; bumped to 1500 (PSRAM-backed).
|
||||
; WiFi always on from boot — web reader works without teardown, extra free heap.
|
||||
; WiFi credentials loaded from SD card at runtime (/web/wifi.cfg).
|
||||
; Configure via Settings > WiFi Setup, or through the web reader.
|
||||
[env:meck_audio_wifi]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D MECK_WIFI_COMPANION=1
|
||||
-D TCP_PORT=5000
|
||||
-D WIFI_DEBUG_LOGGING=1
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.9WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
|
||||
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
|
||||
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
|
||||
@@ -157,7 +192,37 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.5.4G"'
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.94G"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
; 4G + WiFi companion (4G modem hardware with WiFi app bridging, no audio)
|
||||
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
|
||||
; Connect via MeshCore web app, meshcore.js, or Python CLI over local network.
|
||||
; WiFi credentials loaded from SD card at runtime (/web/wifi.cfg).
|
||||
; Configure via Settings > WiFi Setup, or through the web reader.
|
||||
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
|
||||
[env:meck_4g_wifi]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D MECK_WIFI_COMPANION=1
|
||||
-D TCP_PORT=5000
|
||||
-D WIFI_DEBUG_LOGGING=1
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.94G.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -183,7 +248,7 @@ build_flags =
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.5.4G.SA"'
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.94G.SA"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
|
||||