Compare commits

..

11 Commits

Author SHA1 Message Date
pelgraine 60ec294ee6 update readme for Meck v1.6 2026-03-31 03:46:59 +11:00
pelgraine 5497950892 tdpro remote repeater ota firmware update update 2026-03-31 03:15:30 +11:00
pelgraine c687133b05 tdpro refined file export contacts selection json 2026-03-31 02:49:57 +11:00
pelgraine c7d0449181 remove sleep for remote repeater 2026-03-30 13:20:31 +11:00
pelgraine 9ddb692806 fix mqttsubscribe 2026-03-30 13:11:54 +11:00
pelgraine 0cab2ddfa7 fix tdpro remote admin display and lora init sd card mix 2026-03-30 13:02:31 +11:00
pelgraine d07ad71d5d tdpro remote 4g repeater admin via web app 2026-03-30 12:23:02 +11:00
pelgraine b4983e48f0 set custom contact paths 2026-03-29 17:06:45 +11:00
pelgraine b991eb0fe7 bumped up max contacts for BLE companions to 510 2026-03-29 16:15:55 +11:00
pelgraine c15b30079c update f send key for previously recorded voice notes 2026-03-29 14:49:31 +11:00
pelgraine 9d7cbd4866 tdpro audio only - voice notes over lora - 5 seconds stage 1 2026-03-29 14:04:54 +11:00
19 changed files with 6934 additions and 2104 deletions
+170 -3
View File
@@ -34,7 +34,15 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
- [SMS & Phone App (4G only)](#sms--phone-app-4g-only)
- [Web Browser & IRC](#web-browser--irc)
- [Alarm Clock (Audio only)](#alarm-clock-audio-only)
- [Voice Notes Over LoRa (Audio only)](#voice-notes-over-lora-audio-only)
- [Contact Management — Select, Export & Import](#contact-management--select-export--import)
- [Lock Screen (T-Deck Pro)](#lock-screen-t-deck-pro)
- [Remote Repeater (T-Deck Pro 4G)](#remote-repeater-t-deck-pro-4g)
- [Remote Repeater Build Variant](#remote-repeater-build-variant)
- [Setting Up HiveMQ Cloud (Free MQTT Broker)](#setting-up-hivemq-cloud-free-mqtt-broker)
- [SD Card Configuration](#remote-repeater-sd-card-configuration)
- [Deploying the Remote Repeater](#deploying-the-remote-repeater)
- [Remote Dashboard (Meck-Mycelium)](#remote-dashboard-meck-mycelium)
- [T5S3 E-Paper Pro](#t5s3-e-paper-pro)
- [Build Variants](#t5s3-build-variants)
- [Touch Navigation](#touch-navigation)
@@ -50,6 +58,7 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
- [Text & EPUB Reader](TXT___EPUB_Reader_Guide.md)
- [Web Browser & IRC Guide](Web_App_Guide.md)
- [SMS & Phone App Guide](SMS___Phone_App_Guide.md)
- [Meck-Mycelium Web App](#meck-mycelium-web-app)
- [About MeshCore](#about-meshcore)
- [What is MeshCore?](#what-is-meshcore)
- [Key Features](#key-features)
@@ -187,8 +196,9 @@ For a detailed explanation of what multibyte path hash means and why it matters,
| 4G + BLE | `meck_4g_ble` | Yes | Yes | A7682E | — | Yes | 500 |
| 4G + WiFi | `meck_4g_wifi` | — | Yes (TCP:5000) | A7682E | — | Yes | 1,500 |
| 4G + Standalone | `meck_4g_standalone` | — | Yes | A7682E | — | Yes | 1,500 |
| Remote Repeater (4G) | `meck_remote_repeater` | — | — | A7682E (MQTT) | — | No | — |
The audio DAC and 4G modem occupy the same hardware slot and are mutually exclusive.
The audio DAC and 4G modem occupy the same hardware slot and are mutually exclusive. The remote repeater variant operates as a dedicated MeshCore repeater with cellular MQTT management — see [Remote Repeater](#remote-repeater-t-deck-pro-4g) below.
### T-Deck Pro Keyboard Controls
@@ -210,6 +220,7 @@ The T-Deck Pro firmware includes full keyboard support for standalone messaging
| T | Open SMS & Phone app (4G variant only) |
| P | Open audiobook player (audio variant only) |
| K | Open alarm clock (audio variant only) |
| Mic (0) | Open voice messages (audio variant only) |
| F | Open node discovery (search for nearby repeaters/nodes) |
| H | Open last heard list (passive advert history) |
| G | Open map screen (shows contacts with GPS positions) |
@@ -281,12 +292,16 @@ Press **C** from the home screen to open the contacts list. All known mesh conta
| W / S | Scroll up / down through contacts |
| A / D | Cycle filter: All → Chat → Repeater → Room → Sensor → Favourites |
| Enter | Open DM compose (Chat contact) or repeater admin (Repeater contact) |
| X | Export contacts to SD card (wait 510 seconds for confirmation popup) |
| R | Import contacts from SD card (wait 510 seconds for confirmation popup) |
| Long-press Enter | Enter select mode (see [Contact Management](#contact-management--select-export--import)) |
| P | Open Path Editor for selected contact (set direct or multi-hop path) |
| X | Export contacts to SD card — exports selected contacts if in select mode, or all contacts otherwise |
| R | Import contacts from SD card (auto-selects most recent export by timestamp) |
| Q | Back to home screen |
**Contact limits:** Standalone and WiFi variants support up to 1,500 contacts (stored in PSRAM). BLE variants (Audio-BLE and 4G-BLE) are limited to 500 contacts due to BLE protocol constraints.
For detailed documentation on select mode, bulk operations, export format, and companion app interoperability, see [Contact Management — Select, Export & Import](#contact-management--select-export--import).
### Sending a Direct Message
Select a **Chat** contact in the contacts list and press **Enter** to start composing a direct message. The compose screen will show `DM: ContactName` in the header. Type your message and press **Enter** to send. The DM is sent encrypted directly to that contact (or flooded if no direct path is known). After sending or cancelling, you're returned to the contacts list.
@@ -480,6 +495,55 @@ SD Card
└── ...
```
### Voice Notes Over LoRa (Audio only)
Press the **Microphone key** (the zero key on the keyboard) to open the Voice Messages screen. This is available on the audio variant of the T-Deck Pro (PCM5102A DAC).
Record and send voice messages of up to 12 seconds over LoRa. Audio is encoded on-device using Codec2 at 1200 bps, compressing each second of speech into a single 150-byte LoRa packet. Voice notes use very little airtime relative to what they deliver — a 5-second message is just 5 packets.
Voice notes can be sent to another T-Deck Pro Audio device (plays automatically through the headphone jack) or to any MeshCore companion device connected to the [Meck-Mycelium web app](https://pelgraine.github.io/Meck-Mycelium) (plays through your phone's speaker as a tappable bubble in the DM view).
**Before sending, your contact must have a path set.** Go to Contacts (press **C**), select your contact, and press **P** to open the Path Editor. Set a direct (zero-hop) or multi-hop path and save.
**Sending a voice note:**
1. Press the **Microphone key** to open the Voice Messages screen
2. Press and **hold** the Microphone key to record — release to stop (max 12 seconds)
3. Press **S** to open the contact picker — contacts with a direct path appear at the top
4. Tap or scroll to your contact, then press **Enter** to send
Packets are sent with staggered 3-second delays to avoid congesting the channel. On a 62.5 kHz / SF7 radio preset (e.g. Australia Narrow), a 5-second voice note arrives in roughly 20 seconds and a 12-second recording in about 42 seconds.
**Receiving voice notes:**
* **On a T-Deck Pro Audio device:** the voice message screen opens automatically and the message plays through the headphone jack. **Headphones are recommended** — the built-in speaker is very quiet.
* **Via Meck-Mycelium:** voice messages appear as "🎙️ Voice message" bubbles in the DM view. Tap to play. Codec2 decoding happens entirely in the browser via WebAssembly.
> **Note:** Voice recording and sending requires the **Audio variant** hardware (PCM5102A DAC). 4G and standalone variants cannot record or send voice notes, but any device connected to Meck-Mycelium can receive and play them.
### Contact Management — Select, Export & Import
The contacts screen supports a **select mode** for fine-grained contact management, as well as full export and import with MeshCore companion app compatibility.
**Select mode (T-Deck Pro):** Long-press **Enter** on the Contacts screen to enter select mode. Use **W / S** to scroll and press **Enter** to toggle selection on individual contacts.
**Select mode (T5S3):** Long-press the screen on the Contacts screen to enter select mode. Tap individual contacts to toggle their selection.
| Action | T-Deck Pro | T5S3 |
|--------|-----------|------|
| Enter select mode | Long-press Enter | Long-press screen |
| Toggle selection | Enter | Tap |
| Export selected | X | — |
| Bulk delete selected | Shift+Del (double-confirm) | — |
| Toggle favourite | F | — |
| Exit select mode | Q | Boot button |
**Exporting contacts:** Press **X** to export. If contacts are selected in select mode, only those contacts are exported. If no contacts are selected (pressing **X** outside select mode), all contacts are exported. Contacts are saved as a JSON file to `/meshcore/meshcore_contacts.json` on the SD card with a timestamp in the filename. The JSON format is compatible with MeshCore companion apps — you can transfer the file to your phone or computer and import it into the Android, iOS, or web companion app.
**Importing contacts:** Press **R** on the Contacts screen (outside select mode) to import. The importer automatically finds the most recent export file by looking at the timestamp in the filename. Import is a non-destructive merge — new contacts are added without removing existing ones.
**Viewing and transferring exports:** Browse and download your exported JSON files using **OTA Tools → SD File Manager** (Settings → OTA Tools → SD File Manager — connects via WiFi AP and browser), or remove the SD card and copy the files directly.
### Lock Screen (T-Deck Pro)
Double-click the Boot button to lock the screen. The lock screen shows the current time, battery percentage, and unread message count. The CPU drops to 40 MHz while locked to reduce power consumption.
@@ -490,6 +554,95 @@ An auto-lock timer can be configured in **Settings → Auto Lock** (None / 2 / 5
---
## Remote Repeater (T-Deck Pro 4G)
The remote repeater firmware turns a T-Deck Pro 4G into a self-contained MeshCore repeater with remote management over the internet. Insert an active SIM card with a data plan, configure your MQTT broker credentials on the SD card, and you can log in and manage the repeater from anywhere in the world via the [Meck-Mycelium remote dashboard](https://pelgraine.github.io/Meck-Mycelium).
The device connects to a free HiveMQ Cloud MQTT broker over the cellular network, publishing status updates (uptime, battery, signal strength, temperature, neighbour count) and subscribing to commands. The web dashboard lets you view live telemetry, sync the repeater's clock, trigger adverts, reboot the device, and more — all from a browser.
This is ideal for deploying repeaters in remote or hard-to-reach locations where you can't physically visit to administer them, but where cellular coverage exists.
### Remote Repeater Build Variant
| Variant | Environment | Companion | 4G Modem | Audio | Max Contacts |
|---------|------------|-----------|----------|-------|-------------|
| Remote Repeater (4G) | `meck_remote_repeater` | — | A7682E (MQTT) | — | — |
The remote repeater variant does not function as a companion device or chat client. It operates exclusively as a MeshCore repeater node with cellular MQTT telemetry and remote command support.
### Setting Up HiveMQ Cloud (Free MQTT Broker)
The remote repeater requires an MQTT broker to relay telemetry and commands between the device and the web dashboard. [HiveMQ Cloud](https://www.hivemq.com/mqtt-cloud-broker/) offers a free tier that is more than sufficient.
**Step 1 — Create a HiveMQ Cloud account:**
1. Go to https://console.hivemq.cloud/ and sign up for a free account
2. After logging in, a **Serverless** cluster is created automatically
3. Note the **cluster URL** shown on the overview page — it will look something like `abc123def456.s1.eu.hivemq.cloud`
4. Note the **port** — for the T-Deck Pro 4G, use the TLS port which is typically **8883**
**Step 2 — Create MQTT credentials:**
1. In the HiveMQ console, go to **Access Management**
2. Create a new set of credentials — enter a **username** and **password**
3. Save these — you'll need them for the SD card configuration file
**Step 3 — Note your connection details:**
You'll need these four values for the config file:
- **Host:** your cluster URL (e.g. `abc123def456.s1.eu.hivemq.cloud`)
- **Port:** `8883`
- **Username:** the credentials you just created
- **Password:** the credentials you just created
### Remote Repeater SD Card Configuration
Create a file called `mqtt.cfg` in the root of the SD card with your MQTT broker details:
```
abc123.s1.eu.hivemq.cloud
8883
your_hivemq_username
your_hivemq_password
repeater-name
```
The `topic` field sets the base MQTT topic. The device publishes status to `<topic>/status` and subscribes to commands on `<topic>/cmd`. If you're running multiple remote repeaters, give each one a unique topic (e.g. `meck/repeater/hilltop`, `meck/repeater/valley`).
**SD Card Folder Structure:**
```
SD Card
├── mqtt.cfg (MQTT broker credentials — required)
├── meshcore/
│ ├── contacts.bin (auto-created, repeater contact table)
│ └── ...
└── ...
```
### Deploying the Remote Repeater
1. Flash `v1.6-Meck-Remote-Repeater-merged.bin` to your T-Deck Pro 4G using the MeshCore Web Flasher or esptool.py
2. Insert a nano SIM card with an active data plan
3. Insert an SD card with your `mqtt.cfg` file
4. Power on the device — the modem will register on the cellular network (red LED indicates modem power)
5. The device boots as a MeshCore repeater, connects to the cellular network, and begins publishing status updates to your MQTT broker
6. Open the Meck-Mycelium remote dashboard to connect and manage it
The e-ink display shows the repeater's current status including node name, uptime, battery level, LoRa activity, cellular signal strength, and MQTT connection state.
### Remote Dashboard (Meck-Mycelium)
Open https://pelgraine.github.io/Meck-Mycelium and navigate to the **Remote** tab. Enter the same MQTT broker credentials and topic from your `mqtt.cfg` file. The dashboard connects directly to HiveMQ Cloud via secure WebSocket (no data passes through any third-party server) and displays live telemetry from your remote repeater.
**Dashboard features:**
- Live status: uptime, battery, cellular signal strength, temperature, neighbour count
- Clock sync: push your browser's clock to the repeater
- Send advert: trigger a MeshCore advertisement broadcast
- Reboot: remotely restart the device
---
## T5S3 E-Paper Pro
The LilyGo T5S3 E-Paper Pro (V2, H752-B) is a 4.7-inch e-ink device with capacitive touch and no physical keyboard. All navigation is done via touch gestures and the Boot button (GPIO0). The larger 960×540 display provides significantly more screen real estate than the T-Deck Pro's 240×320 panel.
@@ -717,6 +870,17 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
---
## Meck-Mycelium Web App
[Meck-Mycelium](https://pelgraine.github.io/Meck-Mycelium) is a browser-based companion app that connects to your MeshCore device via BLE (using WebBLE in Chrome). It is a fork of [WattleFoxxo's Mycelium](https://github.com/WattleFoxxo/Mycelium) PWA, extended with Meck-specific features:
- **Voice message playback** — voice notes sent from a Meck Audio device appear as tappable "🎙️ Voice message" bubbles in the DM view. Codec2 decoding happens entirely in the browser via WebAssembly — no native app or plugin required.
- **Remote repeater dashboard** — connect to your MQTT broker to view live telemetry from remote repeater devices, send commands, sync clocks, and reboot remotely.
Open **https://pelgraine.github.io/Meck-Mycelium** in Chrome on your phone or computer. WebBLE requires Chrome or a Chromium-based browser (Edge, Brave, etc.) — Firefox and Safari do not support WebBLE.
---
## About MeshCore
MeshCore is a lightweight, portable C++ library that enables multi-hop packet routing for embedded projects using LoRa and other packet radios. It is designed for developers who want to create resilient, decentralized communication networks that work without the internet.
@@ -811,6 +975,9 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] Roomserver message handling and mark-read on login
- [X] Alarm clock with custom MP3 sounds (audio variant)
- [X] Customised user option for larger-font mode
- [X] Voice notes over LoRa (audio variant) with Meck-Mycelium web app playback
- [X] Remote repeater firmware with cellular MQTT management (4G variant)
- [X] Contact management: select mode, selective export, JSON import/export, bulk delete
- [ ] Fix M4B rendering to enable chaptered audiobook playback
- [ ] Better JPEG and PNG decoding
- [ ] Improve EPUB rendering and EPUB format handling
+108 -3
View File
@@ -604,6 +604,13 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const char *text) {
markConnectionActive(from); // in case this is from a server, and we have a connection
// Detect VE3 voice envelope and notify voice handler
if (_voiceEnvHandler && text && strncmp(text, "VE3:", 4) == 0) {
MESH_DEBUG_PRINTLN("Voice: VE3 envelope from %s: %s", from.name, text);
_voiceEnvHandler(from.name, text);
}
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text);
}
@@ -746,6 +753,31 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
return true;
}
bool MyMesh::uiSendRawToContact(uint32_t contact_idx, const uint8_t* data, uint8_t len) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
// Raw custom packets are direct-route only — cannot flood
if (recipient->out_path_len == OUT_PATH_UNKNOWN) {
MESH_DEBUG_PRINTLN("UI: Raw send to %s failed — no direct path", recipient->name);
return false;
}
mesh::Packet* pkt = createRawData(data, len);
if (!pkt) {
MESH_DEBUG_PRINTLN("UI: Raw send to %s failed — packet pool empty", recipient->name);
return false;
}
sendDirect(pkt, recipient->out_path, recipient->out_path_len);
MESH_DEBUG_PRINTLN("UI: Raw sent %d bytes to %s (direct, path_len=0x%02X)",
len, recipient->name, recipient->out_path_len);
return true;
}
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) {
@@ -841,6 +873,43 @@ bool MyMesh::uiSendTelemetryRequest(uint32_t contact_idx) {
return true;
}
bool MyMesh::setCustomPath(int contactIdx, const uint8_t* path, uint8_t pathLen, bool lock) {
ContactInfo contact;
if (!getContactByIdx(contactIdx, contact)) return false;
ContactInfo* c = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!c) return false;
c->out_path_len = pathLen;
int byteLen = mesh::Packet::getPathByteLenFor(pathLen);
if (byteLen > MAX_PATH_SIZE) byteLen = MAX_PATH_SIZE;
memcpy(c->out_path, path, byteLen);
c->lastmod = getRTCClock()->getCurrentTime();
if (lock) {
c->flags |= CONTACT_FLAG_CUSTOM_PATH;
}
MESH_DEBUG_PRINTLN("setCustomPath: contact %s, pathLen=0x%02X (%d hops, %dB/hop), lock=%d",
c->name, pathLen, pathLen & 0x3F, ((pathLen >> 6) & 3) + 1, lock);
return true;
}
void MyMesh::clearCustomPath(int contactIdx) {
ContactInfo contact;
if (!getContactByIdx(contactIdx, contact)) return;
ContactInfo* c = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!c) return;
c->out_path_len = OUT_PATH_UNKNOWN;
memset(c->out_path, 0, MAX_PATH_SIZE);
c->flags &= ~CONTACT_FLAG_CUSTOM_PATH;
c->lastmod = getRTCClock()->getCurrentTime();
MESH_DEBUG_PRINTLN("clearCustomPath: contact %s — reverted to auto-discovery", c->name);
}
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) {
@@ -1011,6 +1080,13 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
}
}
// let base class handle received path and data
// BUT: if this contact has a custom (manually set) path lock, don't let
// auto-discovery overwrite it. Skip the base class call entirely — ACKs
// embedded in path responses will still be delivered via separate ACK packets.
if (contact.flags & CONTACT_FLAG_CUSTOM_PATH) {
MESH_DEBUG_PRINTLN("onContactPathRecv: skipping path update for custom-path contact %s", contact.name);
return false;
}
return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len);
}
@@ -1095,6 +1171,32 @@ void MyMesh::onRawDataRecv(mesh::Packet *packet) {
MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len);
return;
}
// Log ALL incoming raw packets for diagnosis
Serial.printf("onRawDataRecv: len=%d, magic=0x%02X, route=%s\n",
packet->payload_len,
packet->payload_len > 0 ? packet->payload[0] : 0,
packet->isRouteDirect() ? "direct" : "flood");
// Voice-over-LoRa (dz0ny VE3 protocol): intercept voice packets and fetch requests
// before forwarding to BLE companion. In standalone mode (no BLE), this is the
// only way to handle them. In BLE mode, we still intercept so on-device voice works.
if (packet->payload_len > 1 && _voiceHandler) {
uint8_t magic = packet->payload[0];
if (magic == 0x56 || magic == 0x72) { // Voice data (V) or fetch request (r)
Serial.printf("onRawDataRecv: voice %s, payload_len=%d, first6=[%02X %02X %02X %02X %02X %02X]\n",
magic == 0x56 ? "PKT" : "FETCH", packet->payload_len,
packet->payload[0],
packet->payload_len > 1 ? packet->payload[1] : 0,
packet->payload_len > 2 ? packet->payload[2] : 0,
packet->payload_len > 3 ? packet->payload[3] : 0,
packet->payload_len > 4 ? packet->payload[4] : 0,
packet->payload_len > 5 ? packet->payload[5] : 0);
_voiceHandler(magic, packet->payload, packet->payload_len);
// Don't return — still forward to BLE companion if connected
}
}
int i = 0;
out_frame[i++] = PUSH_CODE_RAW_DATA;
out_frame[i++] = (int8_t)(_radio->getLastSNR() * 4);
@@ -3073,14 +3175,17 @@ void MyMesh::loop() {
// is there are pending dirty contacts write needed?
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
if (!_store->isSaveInProgress()) {
if (_deferSaves) {
// Voice session receiving — push save forward to avoid SPI contention
dirty_contacts_expiry = futureMillis(2000);
} else if (!_store->isSaveInProgress()) {
_store->beginSaveContacts(this);
dirty_contacts_expiry = 0;
}
dirty_contacts_expiry = 0;
}
// Drive chunked contact save — write a batch each loop iteration
if (_store->isSaveInProgress()) {
if (_store->isSaveInProgress() && !_deferSaves) {
if (!_store->saveContactsChunk(20)) { // 20 contacts per chunk (~3KB, ~30ms)
_store->finishSaveContacts(); // Done or error — verify and commit
}
+34 -2
View File
@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 10
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "28 March 2026"
#define FIRMWARE_BUILD_DATE "31 March 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v1.5"
#define FIRMWARE_VERSION "Meck v1.6"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -70,6 +70,11 @@
#include <helpers/BaseChatMesh.h>
#include <helpers/TransportKeyStore.h>
// Custom path lock flag — bit 7 of ContactInfo.flags
// When set, onContactPathRecv skips auto-updating this contact's out_path.
// Bits 0-6 remain available (bit 0 = favourite, bits 1-3 = telemetry perms).
#define CONTACT_FLAG_CUSTOM_PATH 0x80
/* -------------------------------------------------------------------------------------- */
#define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS
@@ -133,12 +138,36 @@ public:
// Send a direct message from the UI (no BLE dependency)
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
// Send raw binary data to a contact (PAYLOAD_TYPE_RAW_CUSTOM, direct route only)
// Used for dz0ny VE3 voice protocol: voice packets (0x56) and fetch requests (0x72)
bool uiSendRawToContact(uint32_t contact_idx, const uint8_t* data, uint8_t len);
// Voice-over-LoRa: callback for incoming raw voice packets (dz0ny VE3 protocol)
// magic 0x56 = voice data packet, 0x72 = fetch request
typedef void (*VoiceRawHandler)(uint8_t magic, const uint8_t* payload, uint8_t len);
void setVoiceHandler(VoiceRawHandler h) { _voiceHandler = h; }
// Voice-over-LoRa: callback for incoming VE3 envelope in a DM
// Called with sender name and the VE3 text (e.g. "VE3:a:1:3:2")
typedef void (*VoiceEnvelopeHandler)(const char* senderName, const char* ve3Text);
void setVoiceEnvelopeHandler(VoiceEnvelopeHandler h) { _voiceEnvHandler = h; }
// Defer contact saves while voice packets are being received
// (SD writes block SPI bus shared with LoRa radio)
void setDeferSaves(bool defer) { _deferSaves = defer; }
bool isDeferSaves() const { return _deferSaves; }
// Repeater admin - UI-initiated operations
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; }
// Custom path editor — set or clear a manually configured path for a contact
// When locked, automatic path discovery will not overwrite this contact's path.
bool setCustomPath(int contactIdx, const uint8_t* path, uint8_t pathLen, bool lock);
void clearCustomPath(int contactIdx);
protected:
float getAirtimeBudgetFactor() const override;
@@ -230,6 +259,9 @@ private:
DataStore* _store;
NodePrefs _prefs;
VoiceRawHandler _voiceHandler = nullptr;
VoiceEnvelopeHandler _voiceEnvHandler = nullptr;
bool _deferSaves = false;
uint32_t pending_login;
uint32_t pending_status;
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
File diff suppressed because it is too large Load Diff
+141 -18
View File
@@ -43,6 +43,10 @@ private:
// Pointer to per-contact DM unread array (owned by UITask, set via setter)
const uint8_t* _dmUnread = nullptr;
// --- Select mode state ---
bool _selectMode;
uint8_t* _selectedBits; // Bitfield: 1 bit per MAX_CONTACTS raw index
// --- helpers ---
static const char* filterLabel(FilterMode f) {
@@ -133,16 +137,30 @@ private:
}
}
// --- Bitfield helpers ---
bool isSelectedRaw(int rawIdx) const {
if (rawIdx < 0 || rawIdx >= MAX_CONTACTS) return false;
return (_selectedBits[rawIdx / 8] & (1 << (rawIdx % 8))) != 0;
}
void setSelectedRaw(int rawIdx, bool sel) {
if (rawIdx < 0 || rawIdx >= MAX_CONTACTS) return;
if (sel) _selectedBits[rawIdx / 8] |= (1 << (rawIdx % 8));
else _selectedBits[rawIdx / 8] &= ~(1 << (rawIdx % 8));
}
public:
ContactsScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL),
_filteredCount(0), _cacheValid(false), _rowsPerPage(5) {
_filteredCount(0), _cacheValid(false), _rowsPerPage(5),
_selectMode(false) {
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_filteredIdx = (uint16_t*)ps_calloc(MAX_CONTACTS, sizeof(uint16_t));
_filteredTs = (uint32_t*)ps_calloc(MAX_CONTACTS, sizeof(uint32_t));
_selectedBits = (uint8_t*)ps_calloc((MAX_CONTACTS + 7) / 8, 1);
#else
_filteredIdx = new uint16_t[MAX_CONTACTS]();
_filteredTs = new uint32_t[MAX_CONTACTS]();
_selectedBits = new uint8_t[(MAX_CONTACTS + 7) / 8]();
#endif
}
@@ -158,6 +176,58 @@ public:
FilterMode getFilter() const { return _filter; }
// --- Select mode API ---
bool isInSelectMode() const { return _selectMode; }
void enterSelectMode() {
_selectMode = true;
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
// Pre-select the currently highlighted contact
if (_filteredCount > 0 && _scrollPos < _filteredCount) {
setSelectedRaw(_filteredIdx[_scrollPos], true);
}
}
void exitSelectMode() {
_selectMode = false;
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
}
void toggleSelected() {
if (_filteredCount == 0 || _scrollPos >= _filteredCount) return;
int rawIdx = _filteredIdx[_scrollPos];
setSelectedRaw(rawIdx, !isSelectedRaw(rawIdx));
}
void selectAll() {
for (int i = 0; i < _filteredCount; i++) {
setSelectedRaw(_filteredIdx[i], true);
}
}
void deselectAll() {
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
}
int getSelectedCount() const {
int count = 0;
for (int i = 0; i < _filteredCount; i++) {
if (isSelectedRaw(_filteredIdx[i])) count++;
}
return count;
}
// Fill outBuf with raw contact table indices of selected contacts
int getSelectedRawIndices(uint16_t* outBuf, int maxOut) const {
int count = 0;
for (int i = 0; i < _filteredCount && count < maxOut; i++) {
if (isSelectedRaw(_filteredIdx[i])) {
outBuf[count++] = _filteredIdx[i];
}
}
return count;
}
// Tap-to-select: given virtual Y, select contact row.
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
@@ -219,7 +289,12 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
if (_selectMode) {
int selCount = getSelectedCount();
snprintf(tmp, sizeof(tmp), "%d Selected [%s]", selCount, filterLabel(_filter));
} else {
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
}
display.print(tmp);
// Count on right: All → total/max, filtered → matched/total
@@ -268,6 +343,7 @@ public:
if (!the_mesh.getContactByIdx(_filteredIdx[i], contact)) continue;
bool selected = (i == _scrollPos);
bool sel = _selectMode && isSelectedRaw(_filteredIdx[i]);
// Highlight: fill LIGHT rect first, then draw DARK text on top
if (selected) {
@@ -285,9 +361,13 @@ public:
// Set cursor AFTER fillRect so text draws on top of highlight
display.setCursor(0, y);
// Prefix: "> " for selected, type char + space for others
// Prefix: select mode uses * for selected, normal uses > for cursor
char prefix[4];
if (selected) {
if (_selectMode) {
snprintf(prefix, sizeof(prefix), "%c%c",
sel ? '*' : (selected ? '>' : ' '),
typeChar(contact.type));
} else if (selected) {
snprintf(prefix, sizeof(prefix), ">%c", typeChar(contact.type));
} else {
snprintf(prefix, sizeof(prefix), " %c", typeChar(contact.type));
@@ -300,10 +380,19 @@ public:
// Reserve space for hops + age on right side
char hopStr[6];
if (contact.out_path_len == 0xFF || contact.out_path_len == 0) {
strcpy(hopStr, "D"); // direct
if (contact.out_path_len == 0xFF) {
strcpy(hopStr, "?"); // unknown path
} else if (contact.out_path_len == 0) {
bool customDirect = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
strcpy(hopStr, customDirect ? "D*" : "D");
} else {
snprintf(hopStr, sizeof(hopStr), "%d", contact.out_path_len);
int hops = contact.out_path_len & 0x3F; // lower 6 bits = hop count
bool customPath = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
if (customPath) {
snprintf(hopStr, sizeof(hopStr), "%d*", hops); // asterisk = custom/locked path
} else {
snprintf(hopStr, sizeof(hopStr), "%d", hops);
}
}
char ageStr[6];
@@ -343,19 +432,30 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Filter");
const char* right = "Hold:DM/Admin";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
if (_selectMode) {
display.print("Swipe:All/Clr");
const char* right = "Tap:Tog Hold:Exit";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
} else {
display.print("Swipe:Filter");
const char* right = "Hold:DM/Admin";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
#else
// Left: Q:Bk
display.setCursor(0, footerY);
display.print("Q:Bk A/D:Filter");
// Right: Tap/Ent:Select
const char* right = "Tap/Ent:Select";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
if (_selectMode) {
display.print("A:All D:Clr");
const char* right = "X:Exp F:Fav Q:Done";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
} else {
display.print("Q:Bk A/D:Filter");
const char* right = "P:Path Ent:Sel";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
#endif
return 5000; // e-ink: next render after 5s
@@ -378,6 +478,29 @@ public:
}
}
// --- Select mode key handling ---
if (_selectMode) {
// Enter/tap: toggle selection on current contact
if (c == 13 || c == KEY_ENTER) {
toggleSelected();
return true;
}
// A: select all in current filter
if (c == 'a' || c == 'A') {
selectAll();
return true;
}
// D: deselect all
if (c == 'd' || c == 'D') {
deselectAll();
return true;
}
// Q, X, F, Backspace — handled by main.cpp (needs mesh/SD access)
return false;
}
// --- Normal mode key handling ---
// A - previous filter
if (c == 'a' || c == 'A') {
_filter = (FilterMode)(((int)_filter + FILTER_COUNT - 1) % FILTER_COUNT);
@@ -0,0 +1,805 @@
#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <MeshCore.h>
#include <Packet.h>
// Forward declarations
class UITask;
class MyMesh;
extern MyMesh the_mesh;
class PathEditorScreen : public UIScreen {
public:
enum EditorState {
STATE_MAIN,
STATE_PICK_HOP
};
// Main-state menu items (dynamic, built each render)
enum MenuItem {
MENU_MODE = 0, // "Mode: 1B/hop" or "Mode: 2B/hop"
// After mode: hop lines (MENU_HOP_BASE + i)
// Then: action items
MENU_HOP_BASE = 1,
// Dynamic items after hops:
MENU_ADD_HOP = 100,
MENU_SET_DIRECT,
MENU_REMOVE_LAST,
MENU_CLEAR_PATH,
MENU_SAVE_EXIT
};
private:
UITask* _task;
mesh::RTCClock* _rtc;
int _contactIdx; // Index into contact table
char _contactName[32]; // Contact name for header
EditorState _state;
int _menuSel; // Selected menu item index (0-based in visible list)
int _menuCount; // Total visible menu items
// Path being edited (working copy)
uint8_t _pathBuf[MAX_PATH_SIZE];
uint8_t _pathLen; // Encoded: bits[7:6]=mode, bits[5:0]=hops
int _hopCount; // Decoded hop count
int _bytesPerHop; // 1 or 2
// Repeater picker state
static const int MAX_REPEATERS = 200;
uint16_t* _repIdx; // Indices into contact table (PSRAM)
int _repCount; // Number of repeaters found
int _repSel; // Selected repeater in picker
int _repScroll; // Scroll offset in picker
bool _dirty; // Path has been modified
bool _wantExit; // Set by Save & Exit — caller should navigate back
bool _directLocked; // True = path is explicitly set to direct (0 hops, locked)
// --- helpers ---
void decodePath() {
_hopCount = _pathLen & 0x3F;
uint8_t mode = (_pathLen >> 6) & 0x03;
_bytesPerHop = mode + 1;
}
uint8_t encodePath() const {
uint8_t mode = (_bytesPerHop - 1) & 0x03;
return (mode << 6) | (_hopCount & 0x3F);
}
void buildRepeaterList() {
_repCount = 0;
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo c;
for (uint32_t i = 0; i < numContacts && _repCount < MAX_REPEATERS; i++) {
if (the_mesh.getContactByIdx(i, c)) {
if (c.type == ADV_TYPE_REPEATER) {
_repIdx[_repCount++] = (uint16_t)i;
}
}
}
}
// Look up a contact name by matching pub_key prefix bytes
bool findNameForHop(int hopIndex, char* name, size_t nameLen) const {
if (hopIndex < 0 || hopIndex >= _hopCount) return false;
int offset = hopIndex * _bytesPerHop;
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo c;
for (uint32_t i = 0; i < numContacts; i++) {
if (the_mesh.getContactByIdx(i, c)) {
bool match = true;
for (int b = 0; b < _bytesPerHop; b++) {
if (c.id.pub_key[b] != _pathBuf[offset + b]) {
match = false;
break;
}
}
if (match) {
strncpy(name, c.name, nameLen);
name[nameLen - 1] = '\0';
return true;
}
}
}
return false;
}
// Build the visible menu items list and return count
// Menu layout:
// 0: Mode selector
// 1..hopCount: each hop
// hopCount+1: Add hop
// hopCount+2: Remove last (only if hops > 0)
// hopCount+2 or +3: Clear path (only if custom path flag set or hops > 0)
// last: Save & Exit
int buildMenuCount() const {
int count = 1; // Mode selector
count += _hopCount; // One per hop
if (_hopCount < 8) count++; // Add hop (max 8 hops)
count++; // Set Direct (always visible)
if (_hopCount > 0) count++; // Remove last
if (_hopCount > 0 || _directLocked || isCustomPathSet()) count++; // Clear path
count++; // Save & Exit
return count;
}
// Map a menu index to a MenuItem enum
MenuItem menuItemAt(int idx) const {
if (idx == 0) return MENU_MODE;
int pos = 1;
// Hop lines
for (int h = 0; h < _hopCount; h++) {
if (idx == pos) return (MenuItem)(MENU_HOP_BASE + h);
pos++;
}
// Add hop
if (_hopCount < 8) {
if (idx == pos) return MENU_ADD_HOP;
pos++;
}
// Set Direct
if (idx == pos) return MENU_SET_DIRECT;
pos++;
// Remove last
if (_hopCount > 0) {
if (idx == pos) return MENU_REMOVE_LAST;
pos++;
}
// Clear path
if (_hopCount > 0 || _directLocked || isCustomPathSet()) {
if (idx == pos) return MENU_CLEAR_PATH;
pos++;
}
// Save & Exit
return MENU_SAVE_EXIT;
}
bool isCustomPathSet() const {
ContactInfo c;
if (!the_mesh.getContactByIdx(_contactIdx, c)) return false;
return (c.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
}
public:
PathEditorScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _contactIdx(-1), _state(STATE_MAIN),
_menuSel(0), _menuCount(1), _pathLen(0), _hopCount(0),
_bytesPerHop(1), _repCount(0), _repSel(0), _repScroll(0),
_dirty(false), _wantExit(false), _directLocked(false) {
memset(_contactName, 0, sizeof(_contactName));
memset(_pathBuf, 0, sizeof(_pathBuf));
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_repIdx = (uint16_t*)ps_calloc(MAX_REPEATERS, sizeof(uint16_t));
#else
_repIdx = new uint16_t[MAX_REPEATERS]();
#endif
}
void openForContact(int contactIdx) {
_contactIdx = contactIdx;
_state = STATE_MAIN;
_menuSel = 0;
_repSel = 0;
_repScroll = 0;
_dirty = false;
_wantExit = false;
_directLocked = false;
// Load contact info
ContactInfo c;
if (the_mesh.getContactByIdx(contactIdx, c)) {
strncpy(_contactName, c.name, sizeof(_contactName) - 1);
_contactName[sizeof(_contactName) - 1] = '\0';
// Copy current path
if (c.out_path_len != OUT_PATH_UNKNOWN) {
_pathLen = c.out_path_len;
decodePath();
int byteLen = _hopCount * _bytesPerHop;
if (byteLen > MAX_PATH_SIZE) byteLen = MAX_PATH_SIZE;
memcpy(_pathBuf, c.out_path, byteLen);
// Detect existing direct-locked path
if (_hopCount == 0 && (c.flags & CONTACT_FLAG_CUSTOM_PATH)) {
_directLocked = true;
}
} else {
_pathLen = 0;
_hopCount = 0;
_bytesPerHop = 1;
memset(_pathBuf, 0, sizeof(_pathBuf));
}
} else {
strcpy(_contactName, "Unknown");
_pathLen = 0;
_hopCount = 0;
_bytesPerHop = 1;
}
_menuCount = buildMenuCount();
}
int render(DisplayDriver& display) override {
if (_state == STATE_PICK_HOP) {
return renderPicker(display);
}
return renderMain(display);
}
int renderMain(DisplayDriver& display) {
char tmp[64];
// === Header ===
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Path: %s", _contactName);
// Truncate if too long
if (display.getTextWidth(tmp) > display.width() - 4) {
snprintf(tmp, sizeof(tmp), "Path: %.12s..", _contactName);
}
display.print(tmp);
// Show lock icon or dirty indicator on right
if (_dirty) {
const char* mod = "[*]";
display.setCursor(display.width() - display.getTextWidth(mod) - 2, 0);
display.print(mod);
} else if (isCustomPathSet()) {
const char* lock = "[L]";
display.setCursor(display.width() - display.getTextWidth(lock) - 2, 0);
display.print(lock);
}
display.drawRect(0, 11, display.width(), 1);
// === Body ===
display.setTextSize(0);
int lineH = 9;
int headerH = 14;
int footerH = 14;
int maxY = display.height() - footerH;
int y = headerH;
_menuCount = buildMenuCount();
// Center visible window around selected item
int maxVisible = (maxY - headerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_menuSel - maxVisible / 2, _menuCount - maxVisible));
if (startIdx < 0) startIdx = 0;
int endIdx = min(_menuCount, startIdx + maxVisible);
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
bool selected = (i == _menuSel);
MenuItem item = menuItemAt(i);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineH);
#else
display.fillRect(0, y + 5, display.width(), lineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(2, y);
char prefix = selected ? '>' : ' ';
switch (item) {
case MENU_MODE:
if (_directLocked) {
snprintf(tmp, sizeof(tmp), "%c Mode: DIRECT", prefix);
} else {
snprintf(tmp, sizeof(tmp), "%c Mode: %dB/hop", prefix, _bytesPerHop);
}
display.print(tmp);
// Show hint on right
if (!_directLocked) {
const char* hint = "(A/D)";
display.setCursor(display.width() - display.getTextWidth(hint) - 4, y);
display.print(hint);
}
break;
case MENU_ADD_HOP:
snprintf(tmp, sizeof(tmp), "%c + Add hop...", prefix);
display.print(tmp);
break;
case MENU_SET_DIRECT:
if (_directLocked) {
snprintf(tmp, sizeof(tmp), "%c * Direct (set)", prefix);
} else {
snprintf(tmp, sizeof(tmp), "%c * Set Direct", prefix);
}
display.print(tmp);
break;
case MENU_REMOVE_LAST:
snprintf(tmp, sizeof(tmp), "%c - Remove last hop", prefix);
display.print(tmp);
break;
case MENU_CLEAR_PATH:
snprintf(tmp, sizeof(tmp), "%c Clear custom path", prefix);
display.print(tmp);
break;
case MENU_SAVE_EXIT:
snprintf(tmp, sizeof(tmp), "%c Save & Exit", prefix);
display.print(tmp);
break;
default:
// Hop line: MENU_HOP_BASE + hopIndex
if (item >= MENU_HOP_BASE && item < MENU_HOP_BASE + 64) {
int hopIdx = item - MENU_HOP_BASE;
char hopName[24];
int offset = hopIdx * _bytesPerHop;
if (findNameForHop(hopIdx, hopName, sizeof(hopName))) {
if (_bytesPerHop == 1) {
snprintf(tmp, sizeof(tmp), "%c %d: %s (%02X)", prefix, hopIdx + 1,
hopName, _pathBuf[offset]);
} else {
snprintf(tmp, sizeof(tmp), "%c %d: %s (%02X%02X)", prefix, hopIdx + 1,
hopName, _pathBuf[offset], _pathBuf[offset + 1]);
}
} else {
if (_bytesPerHop == 1) {
snprintf(tmp, sizeof(tmp), "%c %d: ??? (%02X)", prefix, hopIdx + 1,
_pathBuf[offset]);
} else {
snprintf(tmp, sizeof(tmp), "%c %d: ??? (%02X%02X)", prefix, hopIdx + 1,
_pathBuf[offset], _pathBuf[offset + 1]);
}
}
display.drawTextEllipsized(2, y, display.width() - 4, tmp);
}
break;
}
y += lineH;
}
// === Footer ===
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Nav");
const char* right = "Hold:Select";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
display.setCursor(0, footerY);
display.print("Q:Bk W/S:Nav");
const char* right = "Enter:Sel";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif
return 5000;
}
int renderPicker(DisplayDriver& display) {
char tmp[64];
// === Header ===
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Select Repeater (%d)", _repCount);
display.print(tmp);
display.drawRect(0, 11, display.width(), 1);
// === Body ===
display.setTextSize(0);
int lineH = 9;
int headerH = 14;
int footerH = 14;
int maxY = display.height() - footerH;
int y = headerH;
if (_repCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
display.print("No repeaters in contacts");
display.setCursor(0, y + lineH);
display.print("Add repeaters first");
} else {
int maxVisible = (maxY - headerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_repSel - maxVisible / 2, _repCount - maxVisible));
if (startIdx < 0) startIdx = 0;
int endIdx = min(_repCount, startIdx + maxVisible);
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(_repIdx[i], c)) continue;
bool selected = (i == _repSel);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineH);
#else
display.fillRect(0, y + 5, display.width(), lineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(2, y);
char prefix = selected ? '>' : ' ';
if (_bytesPerHop == 1) {
snprintf(tmp, sizeof(tmp), "%c %s (%02X)", prefix, c.name, c.id.pub_key[0]);
} else {
snprintf(tmp, sizeof(tmp), "%c %s (%02X%02X)", prefix, c.name,
c.id.pub_key[0], c.id.pub_key[1]);
}
display.drawTextEllipsized(2, y, display.width() - 4, tmp);
y += lineH;
}
}
// === Footer ===
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Scroll");
const char* right = "Hold:Add Back:Cancel";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
display.setCursor(0, footerY);
display.print("Q:Cancel W/S:Scroll");
const char* right = "Enter:Add";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif
return 5000;
}
bool handleInput(char c) override {
if (_state == STATE_PICK_HOP) {
return handlePickerInput(c);
}
return handleMainInput(c);
}
bool handleMainInput(char c) {
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_menuSel > 0) {
_menuSel--;
return true;
}
return false;
}
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_menuSel < _menuCount - 1) {
_menuSel++;
return true;
}
return false;
}
// A/D — toggle mode (only when Mode item is selected and not direct-locked)
if (c == 'a' || c == 'A' || c == 'd' || c == 'D') {
MenuItem item = menuItemAt(_menuSel);
if (item == MENU_MODE && !_directLocked) {
// Toggle between 1-byte and 2-byte
if (_bytesPerHop == 1) {
switchMode(2);
} else {
switchMode(1);
}
_dirty = true;
_menuCount = buildMenuCount();
return true;
}
return false;
}
// Enter - select
if (c == 13 || c == KEY_ENTER || c == '\r') {
MenuItem item = menuItemAt(_menuSel);
switch (item) {
case MENU_MODE:
// Toggle mode on Enter too (no-op if direct locked)
if (!_directLocked) {
if (_bytesPerHop == 1) {
switchMode(2);
} else {
switchMode(1);
}
_dirty = true;
_menuCount = buildMenuCount();
}
return true;
case MENU_ADD_HOP:
// Enter picker mode — adding a hop clears direct lock
_directLocked = false;
buildRepeaterList();
_repSel = 0;
_repScroll = 0;
_state = STATE_PICK_HOP;
return true;
case MENU_SET_DIRECT:
// Set path to direct (0 hops, locked)
_hopCount = 0;
_pathLen = 0;
memset(_pathBuf, 0, sizeof(_pathBuf));
_directLocked = true;
_dirty = true;
_menuCount = buildMenuCount();
return true;
case MENU_REMOVE_LAST:
if (_hopCount > 0) {
_hopCount--;
_pathLen = encodePath();
_dirty = true;
_menuCount = buildMenuCount();
// Clamp selection
if (_menuSel >= _menuCount) _menuSel = _menuCount - 1;
}
return true;
case MENU_CLEAR_PATH:
_hopCount = 0;
_pathLen = 0;
_directLocked = false;
memset(_pathBuf, 0, sizeof(_pathBuf));
_dirty = true;
_menuCount = buildMenuCount();
_menuSel = 0;
return true;
case MENU_SAVE_EXIT:
savePath();
_wantExit = true; // Signal to main.cpp to navigate back to contacts
return true;
default:
// Hop line — no action (could add remove-specific-hop later)
break;
}
return true;
}
// Q - back (discard changes or prompt?)
// For simplicity, just go back without saving
if (c == 'q' || c == 'Q') {
// Return to contacts screen without saving
// The UITask will handle this via the key falling through
return false; // Let UITask handle Q as back
}
return false;
}
bool handlePickerInput(char c) {
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_repSel > 0) {
_repSel--;
return true;
}
return false;
}
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_repSel < _repCount - 1) {
_repSel++;
return true;
}
return false;
}
// Enter - add selected repeater as hop
if (c == 13 || c == KEY_ENTER || c == '\r') {
if (_repCount > 0 && _repSel >= 0 && _repSel < _repCount) {
addHopFromContact(_repIdx[_repSel]);
}
_state = STATE_MAIN;
_menuCount = buildMenuCount();
return true;
}
// Q - cancel picker, return to main
if (c == 'q' || c == 'Q') {
_state = STATE_MAIN;
return true;
}
return false;
}
// Tap-to-select for T5S3 touch
int selectRowAtVY(int vy) {
if (_state == STATE_PICK_HOP) {
return selectPickerRowAtVY(vy);
}
return selectMainRowAtVY(vy);
}
int selectMainRowAtVY(int vy) {
if (_menuCount == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int maxVisible = (128 - headerH - footerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_menuSel - maxVisible / 2, _menuCount - maxVisible));
if (startIdx < 0) startIdx = 0;
int tappedRow = startIdx + (vy - bodyTop) / lineH;
if (tappedRow < 0 || tappedRow >= _menuCount) return 0;
if (tappedRow == _menuSel) return 2;
_menuSel = tappedRow;
return 1;
}
int selectPickerRowAtVY(int vy) {
if (_repCount == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int maxVisible = (128 - headerH - footerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_repSel - maxVisible / 2, _repCount - maxVisible));
if (startIdx < 0) startIdx = 0;
int tappedRow = startIdx + (vy - bodyTop) / lineH;
if (tappedRow < 0 || tappedRow >= _repCount) return 0;
if (tappedRow == _repSel) return 2;
_repSel = tappedRow;
return 1;
}
EditorState getState() const { return _state; }
bool isDirty() const { return _dirty; }
bool wantsExit() const { return _wantExit; }
private:
void switchMode(int newBytesPerHop) {
if (newBytesPerHop == _bytesPerHop) return;
if (_hopCount > 0) {
// Rebuild path buffer for new mode
// We need the full pub_keys to re-extract the right prefix bytes
uint8_t newBuf[MAX_PATH_SIZE];
memset(newBuf, 0, sizeof(newBuf));
int newHopCount = 0;
for (int h = 0; h < _hopCount && newHopCount < 8; h++) {
int oldOffset = h * _bytesPerHop;
// Try to find the contact that matches this hop
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo c;
bool found = false;
for (uint32_t i = 0; i < numContacts; i++) {
if (the_mesh.getContactByIdx(i, c)) {
bool match = true;
for (int b = 0; b < _bytesPerHop; b++) {
if (c.id.pub_key[b] != _pathBuf[oldOffset + b]) {
match = false;
break;
}
}
if (match) {
// Found the contact — copy new prefix size
int newOffset = newHopCount * newBytesPerHop;
for (int b = 0; b < newBytesPerHop; b++) {
newBuf[newOffset + b] = c.id.pub_key[b];
}
newHopCount++;
found = true;
break;
}
}
}
if (!found) {
// Contact not found — copy what we can
int newOffset = newHopCount * newBytesPerHop;
int oldOff = h * _bytesPerHop;
for (int b = 0; b < newBytesPerHop; b++) {
if (b < _bytesPerHop) {
newBuf[newOffset + b] = _pathBuf[oldOff + b];
} else {
newBuf[newOffset + b] = 0x00; // pad with zero
}
}
newHopCount++;
}
}
_hopCount = newHopCount;
memcpy(_pathBuf, newBuf, sizeof(newBuf));
}
_bytesPerHop = newBytesPerHop;
_pathLen = encodePath();
}
void addHopFromContact(uint16_t contactTableIdx) {
if (_hopCount >= 8) return;
ContactInfo c;
if (!the_mesh.getContactByIdx(contactTableIdx, c)) return;
int offset = _hopCount * _bytesPerHop;
if (offset + _bytesPerHop > MAX_PATH_SIZE) return;
for (int b = 0; b < _bytesPerHop; b++) {
_pathBuf[offset + b] = c.id.pub_key[b];
}
_hopCount++;
_pathLen = encodePath();
_dirty = true;
}
void savePath() {
if (_contactIdx < 0) return;
if (_directLocked) {
// Set as direct (0 hops) with lock — prevents flood routing
the_mesh.setCustomPath(_contactIdx, _pathBuf, 0, true);
Serial.printf("PathEditor: set DIRECT path for contact %d (%s)\n",
_contactIdx, _contactName);
} else if (_hopCount > 0) {
// Set custom path with lock
the_mesh.setCustomPath(_contactIdx, _pathBuf, encodePath(), true);
Serial.printf("PathEditor: saved %d-hop %dB/hop path for contact %d (%s)\n",
_hopCount, _bytesPerHop, _contactIdx, _contactName);
} else {
// Clear custom path — revert to auto-discovery
the_mesh.clearCustomPath(_contactIdx);
Serial.printf("PathEditor: cleared custom path for contact %d (%s)\n",
_contactIdx, _contactName);
}
// Trigger contact save to SD
the_mesh.saveContacts();
_dirty = false;
}
};
@@ -3,6 +3,7 @@
#include "../MyMesh.h"
#include "NotesScreen.h"
#include "RepeaterAdminScreen.h"
#include "PathEditorScreen.h"
#include "DiscoveryScreen.h"
#include "LastHeardScreen.h"
#ifdef MECK_WEB_READER
@@ -58,6 +59,7 @@
#include "SettingsScreen.h"
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.h"
#include "VoiceMessageScreen.h"
#endif
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
@@ -1290,6 +1292,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
notes_screen = new NotesScreen(this, node_prefs);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
path_editor = nullptr; // Lazy-initialized on first use from contacts screen
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
last_heard_screen = new LastHeardScreen(&rtc_clock);
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
@@ -1298,6 +1301,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
#ifdef MECK_AUDIO_VARIANT
alarm_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
voice_screen = nullptr; // Created and assigned from main.cpp on first mic key press
#endif
#ifdef HAS_4G_MODEM
sms_screen = new SMSScreen(this, node_prefs);
@@ -2654,6 +2658,20 @@ void UITask::gotoAlarmScreen() {
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoVoiceScreen() {
if (voice_screen == nullptr) return;
VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)voice_screen;
if (_display != NULL) {
voiceScr->enter(*_display);
}
setCurrScreen(voice_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
#ifdef HAS_4G_MODEM
@@ -2760,6 +2778,23 @@ void UITask::gotoRepeaterAdminDirect(int contactIdx) {
}
}
void UITask::gotoPathEditor(int contactIdx) {
// Lazy-initialize on first use
if (path_editor == nullptr) {
path_editor = new PathEditorScreen(this, &rtc_clock);
}
PathEditorScreen* editor = (PathEditorScreen*)path_editor;
editor->openForContact(contactIdx);
setCurrScreen(path_editor);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoDiscoveryScreen() {
((DiscoveryScreen*)discovery_screen)->resetScroll();
setCurrScreen(discovery_screen);
+9
View File
@@ -88,11 +88,13 @@ class UITask : public AbstractUITask {
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
#ifdef MECK_AUDIO_VARIANT
UIScreen* alarm_screen; // Alarm clock screen (audio variant only)
UIScreen* voice_screen; // Voice message screen (audio variant only)
#endif
#ifdef HAS_4G_MODEM
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
#endif
UIScreen* repeater_admin; // Repeater admin screen
UIScreen* path_editor; // Custom path editor screen (lazy-init)
UIScreen* discovery_screen; // Node discovery scan screen
UIScreen* last_heard_screen; // Last heard passive advert list
#ifdef MECK_WEB_READER
@@ -188,9 +190,11 @@ public:
void gotoAudiobookPlayer(); // Navigate to audiobook player
#ifdef MECK_AUDIO_VARIANT
void gotoAlarmScreen(); // Navigate to alarm clock
void gotoVoiceScreen(); // Navigate to voice message recorder
#endif
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
void gotoRepeaterAdminDirect(int contactIdx); // Auto-login admin (L key from conversation)
void gotoPathEditor(int contactIdx); // Navigate to custom path editor
void gotoDiscoveryScreen(); // Navigate to node discovery scan
void gotoLastHeardScreen(); // Navigate to last heard passive list
#if HAS_GPS
@@ -240,8 +244,10 @@ public:
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
#ifdef MECK_AUDIO_VARIANT
bool isOnAlarmScreen() const { return curr == alarm_screen; }
bool isOnVoiceScreen() const { return curr == voice_screen; }
#endif
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
bool isOnPathEditor() const { return curr == path_editor; }
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
bool isOnLastHeardScreen() const { return curr == last_heard_screen; }
bool isOnMapScreen() const { return curr == map_screen; }
@@ -312,8 +318,11 @@ public:
#ifdef MECK_AUDIO_VARIANT
UIScreen* getAlarmScreen() const { return alarm_screen; }
void setAlarmScreen(UIScreen* s) { alarm_screen = s; }
UIScreen* getVoiceScreen() const { return voice_screen; }
void setVoiceScreen(UIScreen* s) { voice_screen = s; }
#endif
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
UIScreen* getPathEditorScreen() const { return path_editor; }
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
UIScreen* getLastHeardScreen() const { return last_heard_screen; }
UIScreen* getMapScreen() const { return map_screen; }
File diff suppressed because it is too large Load Diff
+372
View File
@@ -0,0 +1,372 @@
#pragma once
// =============================================================================
// ApnDatabase.h - Embedded APN Lookup Table
//
// Maps MCC/MNC (Mobile Country Code / Mobile Network Code) to default APN
// settings for common carriers worldwide. Compiled directly into flash (~3KB)
// so users never need to manually install a lookup file.
//
// The modem queries IMSI via AT+CIMI to extract MCC (3 digits) + MNC (2-3
// digits), then looks up the APN here. If not found, falls back to the
// modem's existing PDP context (AT+CGDCONT?) or user-configured APN.
//
// To add a carrier: append to APN_DATABASE[] with the MCC+MNC as a single
// integer. MNC can be 2 or 3 digits:
// MCC=310, MNC=260 → mccmnc = 310260
// MCC=505, MNC=01 → mccmnc = 50501
//
// Guard: HAS_4G_MODEM
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef APN_DATABASE_H
#define APN_DATABASE_H
struct ApnEntry {
uint32_t mccmnc; // MCC+MNC as integer (e.g. 310260 for T-Mobile US)
const char* apn; // APN string
const char* carrier; // Human-readable carrier name (for debug/display)
};
// ---------------------------------------------------------------------------
// APN Database — sorted by MCC for binary search potential (not required)
//
// Sources: carrier documentation, GSMA databases, community wikis.
// This covers ~120 major carriers across key regions. Users with less
// common carriers can set APN manually in Settings.
// ---------------------------------------------------------------------------
static const ApnEntry APN_DATABASE[] = {
// =========================================================================
// Australia (MCC 505)
// =========================================================================
{ 50501, "telstra.internet", "Telstra" },
{ 50502, "yesinternet", "Optus" },
{ 50503, "vfinternet.au", "Vodafone AU" },
{ 50506, "3netaccess", "Three AU" },
{ 50507, "telstra.internet", "Vodafone AU (MVNO)" }, // Many MVNOs on Telstra
{ 50510, "telstra.internet", "Norfolk Tel" },
{ 50512, "3netaccess", "Amaysim" }, // Optus MVNO
{ 50514, "yesinternet", "Aussie Broadband" }, // Optus MVNO
{ 50590, "yesinternet", "Optus MVNO" },
// =========================================================================
// New Zealand (MCC 530)
// =========================================================================
{ 53001, "internet", "Vodafone NZ" },
{ 53005, "internet", "Spark NZ" },
{ 53024, "internet", "2degrees" },
// =========================================================================
// United States (MCC 310, 311, 312, 313, 316)
// =========================================================================
{ 310012, "fast.t-mobile.com", "Verizon (old)" },
{ 310026, "fast.t-mobile.com", "T-Mobile US" },
{ 310030, "fast.t-mobile.com", "T-Mobile US" },
{ 310032, "fast.t-mobile.com", "T-Mobile US" },
{ 310060, "fast.t-mobile.com", "T-Mobile US" },
{ 310160, "fast.t-mobile.com", "T-Mobile US" },
{ 310200, "fast.t-mobile.com", "T-Mobile US" },
{ 310210, "fast.t-mobile.com", "T-Mobile US" },
{ 310220, "fast.t-mobile.com", "T-Mobile US" },
{ 310230, "fast.t-mobile.com", "T-Mobile US" },
{ 310240, "fast.t-mobile.com", "T-Mobile US" },
{ 310250, "fast.t-mobile.com", "T-Mobile US" },
{ 310260, "fast.t-mobile.com", "T-Mobile US" },
{ 310270, "fast.t-mobile.com", "T-Mobile US" },
{ 310310, "fast.t-mobile.com", "T-Mobile US" },
{ 310490, "fast.t-mobile.com", "T-Mobile US" },
{ 310530, "fast.t-mobile.com", "T-Mobile US" },
{ 310580, "fast.t-mobile.com", "T-Mobile US" },
{ 310660, "fast.t-mobile.com", "T-Mobile US" },
{ 310800, "fast.t-mobile.com", "T-Mobile US" },
{ 311480, "vzwinternet", "Verizon" },
{ 311481, "vzwinternet", "Verizon" },
{ 311482, "vzwinternet", "Verizon" },
{ 311483, "vzwinternet", "Verizon" },
{ 311484, "vzwinternet", "Verizon" },
{ 311489, "vzwinternet", "Verizon" },
{ 310410, "fast.t-mobile.com", "AT&T (migrated)" },
{ 310120, "att.mvno", "AT&T (Sprint)" },
{ 312530, "iot.1nce.net", "1NCE IoT" },
{ 310120, "tfdata", "Tracfone" },
// =========================================================================
// Canada (MCC 302)
// =========================================================================
{ 30220, "internet.com", "Rogers" },
{ 30221, "internet.com", "Rogers" },
{ 30237, "internet.com", "Rogers" },
{ 30272, "internet.com", "Rogers" },
{ 30234, "sp.telus.com", "Telus" },
{ 30286, "sp.telus.com", "Telus" },
{ 30236, "sp.telus.com", "Telus" },
{ 30261, "sp.bell.ca", "Bell" },
{ 30263, "sp.bell.ca", "Bell" },
{ 30267, "sp.bell.ca", "Bell" },
{ 30268, "fido-core-appl1.apn", "Fido" },
{ 30278, "internet.com", "SaskTel" },
{ 30266, "sp.mb.com", "MTS" },
// =========================================================================
// United Kingdom (MCC 234, 235)
// =========================================================================
{ 23410, "o2-internet", "O2 UK" },
{ 23415, "three.co.uk", "Vodafone UK" },
{ 23420, "three.co.uk", "Three UK" },
{ 23430, "everywhere", "EE" },
{ 23431, "everywhere", "EE" },
{ 23432, "everywhere", "EE" },
{ 23433, "everywhere", "EE" },
{ 23450, "data.lycamobile.co.uk","Lycamobile UK" },
{ 23486, "three.co.uk", "Three UK" },
// =========================================================================
// Germany (MCC 262)
// =========================================================================
{ 26201, "internet.t-mobile", "Telekom DE" },
{ 26202, "web.vodafone.de", "Vodafone DE" },
{ 26203, "internet", "O2 DE" },
{ 26207, "internet", "O2 DE" },
// =========================================================================
// France (MCC 208)
// =========================================================================
{ 20801, "orange", "Orange FR" },
{ 20810, "sl2sfr", "SFR" },
{ 20815, "free", "Free Mobile" },
{ 20820, "ofnew.fr", "Bouygues" },
// =========================================================================
// Italy (MCC 222)
// =========================================================================
{ 22201, "mobile.vodafone.it", "TIM" },
{ 22210, "mobile.vodafone.it", "Vodafone IT" },
{ 22250, "internet.it", "Iliad IT" },
{ 22288, "internet.wind", "WindTre" },
{ 22299, "internet.wind", "WindTre" },
// =========================================================================
// Spain (MCC 214)
// =========================================================================
{ 21401, "internet", "Vodafone ES" },
{ 21403, "internet", "Orange ES" },
{ 21404, "internet", "Yoigo" },
{ 21407, "internet", "Movistar" },
// =========================================================================
// Netherlands (MCC 204)
// =========================================================================
{ 20404, "internet", "Vodafone NL" },
{ 20408, "internet", "KPN" },
{ 20412, "internet", "Telfort" },
{ 20416, "internet", "T-Mobile NL" },
{ 20420, "internet", "T-Mobile NL" },
// =========================================================================
// Sweden (MCC 240)
// =========================================================================
{ 24001, "internet.telia.se", "Telia SE" },
{ 24002, "tre.se", "Three SE" },
{ 24007, "internet.telenor.se", "Telenor SE" },
// =========================================================================
// Norway (MCC 242)
// =========================================================================
{ 24201, "internet.telenor.no", "Telenor NO" },
{ 24202, "internet.netcom.no", "Telia NO" },
// =========================================================================
// Denmark (MCC 238)
// =========================================================================
{ 23801, "internet", "TDC" },
{ 23802, "internet", "Telenor DK" },
{ 23806, "internet", "Three DK" },
{ 23820, "internet", "Telia DK" },
// =========================================================================
// Switzerland (MCC 228)
// =========================================================================
{ 22801, "gprs.swisscom.ch", "Swisscom" },
{ 22802, "internet", "Sunrise" },
{ 22803, "internet", "Salt" },
// =========================================================================
// Austria (MCC 232)
// =========================================================================
{ 23201, "a1.net", "A1" },
{ 23203, "web.one.at", "Three AT" },
{ 23205, "web", "T-Mobile AT" },
// =========================================================================
// Japan (MCC 440, 441)
// =========================================================================
{ 44010, "spmode.ne.jp", "NTT Docomo" },
{ 44020, "plus.4g", "SoftBank" },
{ 44051, "au.au-net.ne.jp", "KDDI au" },
// =========================================================================
// South Korea (MCC 450)
// =========================================================================
{ 45005, "lte.sktelecom.com", "SK Telecom" },
{ 45006, "lte.ktfwing.com", "KT" },
{ 45008, "lte.lguplus.co.kr", "LG U+" },
// =========================================================================
// India (MCC 404, 405)
// =========================================================================
{ 40445, "airtelgprs.com", "Airtel" },
{ 40410, "airtelgprs.com", "Airtel" },
{ 40411, "www", "Vodafone IN (Vi)" },
{ 40413, "www", "Vodafone IN (Vi)" },
{ 40486, "www", "Vodafone IN (Vi)" },
{ 40553, "jionet", "Jio" },
{ 40554, "jionet", "Jio" },
{ 40512, "bsnlnet", "BSNL" },
// =========================================================================
// Singapore (MCC 525)
// =========================================================================
{ 52501, "internet", "Singtel" },
{ 52503, "internet", "M1" },
{ 52505, "internet", "StarHub" },
// =========================================================================
// Hong Kong (MCC 454)
// =========================================================================
{ 45400, "internet", "CSL" },
{ 45406, "internet", "SmarTone" },
{ 45412, "internet", "CMHK" },
// =========================================================================
// Brazil (MCC 724)
// =========================================================================
{ 72405, "claro.com.br", "Claro BR" },
{ 72406, "wap.oi.com.br", "Vivo" },
{ 72410, "wap.oi.com.br", "Vivo" },
{ 72411, "wap.oi.com.br", "Vivo" },
{ 72415, "internet.tim.br", "TIM BR" },
{ 72431, "gprs.oi.com.br", "Oi" },
// =========================================================================
// Mexico (MCC 334)
// =========================================================================
{ 33402, "internet.itelcel.com","Telcel" },
{ 33403, "internet.movistar.mx","Movistar MX" },
{ 33404, "internet.att.net.mx", "AT&T MX" },
// =========================================================================
// South Africa (MCC 655)
// =========================================================================
{ 65501, "internet", "Vodacom" },
{ 65502, "internet", "Telkom ZA" },
{ 65507, "internet", "Cell C" },
{ 65510, "internet", "MTN ZA" },
// =========================================================================
// Philippines (MCC 515)
// =========================================================================
{ 51502, "internet.globe.com.ph","Globe" },
{ 51503, "internet", "Smart" },
{ 51505, "internet", "Sun Cellular" },
// =========================================================================
// Thailand (MCC 520)
// =========================================================================
{ 52001, "internet", "AIS" },
{ 52004, "internet", "TrueMove" },
{ 52005, "internet", "dtac" },
// =========================================================================
// Indonesia (MCC 510)
// =========================================================================
{ 51001, "internet", "Telkomsel" },
{ 51010, "internet", "Telkomsel" },
{ 51011, "3gprs", "XL Axiata" },
{ 51028, "3gprs", "XL Axiata (Axis)" },
// =========================================================================
// Malaysia (MCC 502)
// =========================================================================
{ 50212, "celcom3g", "Celcom" },
{ 50213, "celcom3g", "Celcom" },
{ 50216, "internet", "Digi" },
{ 50219, "celcom3g", "Celcom" },
// =========================================================================
// Czech Republic (MCC 230)
// =========================================================================
{ 23001, "internet.t-mobile.cz","T-Mobile CZ" },
{ 23002, "internet", "O2 CZ" },
{ 23003, "internet.vodafone.cz","Vodafone CZ" },
// =========================================================================
// Poland (MCC 260)
// =========================================================================
{ 26001, "internet", "Plus PL" },
{ 26002, "internet", "T-Mobile PL" },
{ 26003, "internet", "Orange PL" },
{ 26006, "internet", "Play" },
// =========================================================================
// Portugal (MCC 268)
// =========================================================================
{ 26801, "internet", "Vodafone PT" },
{ 26803, "internet", "NOS" },
{ 26806, "internet", "MEO" },
// =========================================================================
// Ireland (MCC 272)
// =========================================================================
{ 27201, "internet", "Vodafone IE" },
{ 27202, "open.internet", "Three IE" },
{ 27205, "three.ie", "Three IE" },
// =========================================================================
// IoT / Global SIMs
// =========================================================================
{ 901028, "iot.1nce.net", "1NCE (IoT)" },
{ 90143, "hologram", "Hologram" },
};
#define APN_DATABASE_SIZE (sizeof(APN_DATABASE) / sizeof(APN_DATABASE[0]))
// ---------------------------------------------------------------------------
// Lookup function — returns nullptr if not found
// ---------------------------------------------------------------------------
inline const ApnEntry* apnLookup(uint32_t mccmnc) {
for (int i = 0; i < (int)APN_DATABASE_SIZE; i++) {
if (APN_DATABASE[i].mccmnc == mccmnc) {
return &APN_DATABASE[i];
}
}
return nullptr;
}
// Parse IMSI string into MCC+MNC. Tries 3-digit MNC first (6-digit mccmnc),
// falls back to 2-digit MNC (5-digit mccmnc) if not found.
inline const ApnEntry* apnLookupFromIMSI(const char* imsi) {
if (!imsi || strlen(imsi) < 5) return nullptr;
// Extract MCC (always 3 digits)
uint32_t mcc = (imsi[0] - '0') * 100 + (imsi[1] - '0') * 10 + (imsi[2] - '0');
// Try 3-digit MNC first (more specific)
if (strlen(imsi) >= 6) {
uint32_t mnc3 = (imsi[3] - '0') * 100 + (imsi[4] - '0') * 10 + (imsi[5] - '0');
uint32_t mccmnc6 = mcc * 1000 + mnc3;
const ApnEntry* entry = apnLookup(mccmnc6);
if (entry) return entry;
}
// Fall back to 2-digit MNC
uint32_t mnc2 = (imsi[3] - '0') * 10 + (imsi[4] - '0');
uint32_t mccmnc5 = mcc * 100 + mnc2;
return apnLookup(mccmnc5);
}
#endif // APN_DATABASE_H
#endif // HAS_4G_MODEM
+228
View File
@@ -0,0 +1,228 @@
#pragma once
// =============================================================================
// CellularMQTT — A7682E Modem + MQTT via native AT commands
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef CELLULAR_MQTT_H
#define CELLULAR_MQTT_H
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include "variant.h"
#include "ApnDatabase.h"
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
#define MQTT_TOPIC_MAX 80
#define MQTT_PAYLOAD_MAX 512
#define MQTT_CLIENT_ID_MAX 32
#define CMD_QUEUE_SIZE 4
#define RSP_QUEUE_SIZE 4
#define TELEMETRY_INTERVAL 60000
#define CELL_TASK_PRIORITY 1
#define CELL_TASK_STACK_SIZE 8192
#define CELL_TASK_CORE 0
#define MQTT_RECONNECT_MIN 5000
#define MQTT_RECONNECT_MAX 300000
#define MQTT_PUB_FAIL_MAX 5
#define OTA_CHUNK_SIZE 1024
// ---------------------------------------------------------------------------
// State machine
// ---------------------------------------------------------------------------
enum class CellState : uint8_t {
OFF,
POWERING_ON,
INITIALIZING,
REGISTERING,
DATA_ACTIVATING,
MQTT_STARTING,
MQTT_CONNECTING,
CONNECTED,
RECONNECTING,
OTA_IN_PROGRESS,
ERROR
};
// ---------------------------------------------------------------------------
// Queue message types
// ---------------------------------------------------------------------------
struct MQTTCommand {
char cmd[MQTT_PAYLOAD_MAX];
};
struct MQTTResponse {
char topic[MQTT_TOPIC_MAX];
char payload[MQTT_PAYLOAD_MAX];
};
// ---------------------------------------------------------------------------
// MQTT config (loaded from SD: /remote/mqtt.cfg)
// ---------------------------------------------------------------------------
struct MQTTConfig {
char broker[80];
uint16_t port;
char username[40];
char password[40];
char deviceId[MQTT_CLIENT_ID_MAX];
};
// ---------------------------------------------------------------------------
// Telemetry snapshot
// ---------------------------------------------------------------------------
struct TelemetryData {
uint32_t uptime_secs;
uint16_t battery_mv;
uint8_t battery_pct;
int16_t temperature;
int csq;
uint8_t neighbor_count;
float freq;
float bw;
uint8_t sf;
uint8_t cr;
uint8_t tx_power;
char node_name[32];
char apn[40];
char oper[24];
bool mqtt_connected;
};
// ---------------------------------------------------------------------------
// CellularMQTT class
// ---------------------------------------------------------------------------
class CellularMQTT {
public:
void begin();
void stop();
// --- Queue API (called from main loop) ---
bool recvCommand(MQTTCommand& out);
bool sendResponse(const char* topic, const char* payload);
// --- Telemetry ---
void updateTelemetry(const TelemetryData& data);
// --- OTA ---
void requestOTA(const char* url);
bool isOTAInProgress() const { return _state == CellState::OTA_IN_PROGRESS; }
// --- State queries ---
CellState getState() const { return _state; }
bool isConnected() const { return _state == CellState::CONNECTED; }
int getCSQ() const { return _csq; }
int getSignalBars() const;
const char* getOperator() const { return _operator; }
const char* getIPAddress() const { return _ipAddr; }
const char* getBroker() const { return _config.broker; }
const char* getAPN() const { return _apn; }
const char* getRspTopic() const { return _topicRsp; }
const char* stateString() const;
uint32_t getLastCmdTime() const { return _lastCmdTime; }
static bool loadConfig(MQTTConfig& cfg);
private:
volatile CellState _state = CellState::OFF;
volatile int _csq = 99;
volatile uint32_t _lastCmdTime = 0;
char _operator[24] = {0};
char _ipAddr[20] = {0};
char _imei[20] = {0};
char _imsi[20] = {0};
char _apn[64] = {0};
MQTTConfig _config = {};
TelemetryData _telemetry = {};
SemaphoreHandle_t _telemetryMutex = nullptr;
char _topicCmd[MQTT_TOPIC_MAX] = {0};
char _topicRsp[MQTT_TOPIC_MAX] = {0};
char _topicTelem[MQTT_TOPIC_MAX] = {0};
char _topicOta[MQTT_TOPIC_MAX] = {0};
TaskHandle_t _taskHandle = nullptr;
QueueHandle_t _cmdQueue = nullptr;
QueueHandle_t _rspQueue = nullptr;
SemaphoreHandle_t _uartMutex = nullptr;
uint8_t _pubFailCount = 0;
static const int AT_BUF_SIZE = 512;
char _atBuf[AT_BUF_SIZE];
static const int URC_BUF_SIZE = 600;
char _urcBuf[URC_BUF_SIZE];
int _urcPos = 0;
enum MqttRxState { RX_IDLE, RX_WAIT_TOPIC, RX_WAIT_PAYLOAD };
MqttRxState _rxState = RX_IDLE;
int _rxTopicLen = 0;
int _rxPayloadLen = 0;
char _rxTopic[MQTT_TOPIC_MAX];
char _rxPayload[MQTT_PAYLOAD_MAX];
uint32_t _reconnectDelay = MQTT_RECONNECT_MIN;
// OTA state
volatile bool _otaPending = false;
char _otaUrl[256] = {0};
// --- Modem UART helpers ---
bool modemPowerOn();
bool sendAT(const char* cmd, const char* expect, uint32_t timeout_ms = 2000);
bool waitResponse(const char* expect, uint32_t timeout_ms, char* buf = nullptr, size_t bufLen = 0);
bool waitPrompt(uint32_t timeout_ms = 5000);
void drainURCs();
void processURCLine(const char* line);
// --- Data connection ---
void resolveAPN();
bool activateData();
// --- MQTT operations ---
bool mqttStart();
bool mqttConnect();
bool mqttSubscribe(const char* topic);
bool mqttPublish(const char* topic, const char* payload);
void mqttDisconnect();
// --- URC handlers ---
void handleMqttRxStart(const char* line);
void handleMqttRxTopic(const char* data, int len);
void handleMqttRxPayload(const char* data, int len);
void handleMqttRxEnd();
void handleMqttConnLost(const char* line);
// --- OTA operations (modem task only) ---
void performOTA();
int httpGet(const char* url);
bool httpReadChunk(int offset, int len, uint8_t* dest, int* bytesRead);
void httpTerm();
void otaPublish(const char* msg);
int readRawBytes(uint8_t* dest, int count, uint32_t timeout_ms);
// --- Task ---
static void taskEntry(void* param);
void taskLoop();
};
extern CellularMQTT cellularMQTT;
#endif // CELLULAR_MQTT_H
#endif // HAS_4G_MODEM
File diff suppressed because it is too large Load Diff
+72 -25
View File
@@ -2,7 +2,14 @@
#include <Arduino.h>
#include <helpers/CommonCLI.h>
#define AUTO_OFF_MILLIS 20000 // 20 seconds
#ifdef HAS_4G_MODEM
#include "CellularMQTT.h"
#define AUTO_OFF_DISABLED true
#else
#define AUTO_OFF_DISABLED false
#endif
#define AUTO_OFF_MILLIS 20000 // 20 seconds (ignored when AUTO_OFF_DISABLED)
#define BOOT_SCREEN_MILLIS 4000 // 4 seconds
// 'meshcore', 128x13px
@@ -28,55 +35,97 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
_node_prefs = node_prefs;
_display->turnOn();
// strip off dash and commit hash by changing dash to null terminator
// e.g: v1.2.3-abcdef -> v1.2.3
char *version = strdup(firmware_version);
char *dash = strchr(version, '-');
if(dash){
*dash = 0;
}
if (dash) *dash = 0;
// v1.2.3 (1 Jan 2025)
sprintf(_version_info, "%s (%s)", version, build_date);
snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date);
free(version);
}
void UITask::renderCurrScreen() {
char tmp[80];
if (millis() < BOOT_SCREEN_MILLIS) { // boot screen
// meshcore logo
if (millis() < BOOT_SCREEN_MILLIS) {
// Boot screen — logo + version
_display->setColor(DisplayDriver::BLUE);
int logoWidth = 128;
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
// version info
_display->setColor(DisplayDriver::LIGHT);
_display->setTextSize(1);
uint16_t versionWidth = _display->getTextWidth(_version_info);
_display->setCursor((_display->width() - versionWidth) / 2, 22);
_display->print(_version_info);
// node type
#ifdef HAS_4G_MODEM
const char* node_type = "< Remote Repeater >";
#else
const char* node_type = "< Repeater >";
#endif
uint16_t typeWidth = _display->getTextWidth(node_type);
_display->setCursor((_display->width() - typeWidth) / 2, 35);
_display->print(node_type);
} else { // home screen
// node name
} else {
// Home screen — node info + cellular status
_display->setCursor(0, 0);
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
_display->print(_node_prefs->node_name);
// freq / sf
_display->setCursor(0, 20);
_display->setColor(DisplayDriver::YELLOW);
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
_display->print(tmp);
// bw / cr
_display->setCursor(0, 30);
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
_display->print(tmp);
#ifdef HAS_4G_MODEM
int y = 44;
_display->setCursor(0, y);
_display->setColor(DisplayDriver::LIGHT);
sprintf(tmp, "4G: %s", cellularMQTT.stateString());
_display->print(tmp);
y += 10;
_display->setCursor(0, y);
sprintf(tmp, "CSQ: %d (%d bars)", cellularMQTT.getCSQ(), cellularMQTT.getSignalBars());
_display->print(tmp);
y += 10;
const char* oper = cellularMQTT.getOperator();
if (oper[0]) {
_display->setCursor(0, y);
sprintf(tmp, "Op: %.16s", oper);
_display->print(tmp);
y += 10;
}
_display->setCursor(0, y);
_display->setColor(cellularMQTT.isConnected() ? DisplayDriver::GREEN : DisplayDriver::YELLOW);
sprintf(tmp, "MQTT: %s", cellularMQTT.isConnected() ? "Connected" : "---");
_display->print(tmp);
y += 10;
const char* ip = cellularMQTT.getIPAddress();
if (ip[0]) {
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, y);
sprintf(tmp, "IP: %s", ip);
_display->print(tmp);
y += 10;
}
uint32_t upSec = millis() / 1000;
uint32_t upH = upSec / 3600;
uint32_t upM = (upSec % 3600) / 60;
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, y);
sprintf(tmp, "Up: %luh %lum Heap:%dk", upH, upM, ESP.getFreeHeap() / 1024);
_display->print(tmp);
#endif
}
}
@@ -85,17 +134,15 @@ void UITask::loop() {
if (millis() >= _next_read) {
int btnState = digitalRead(PIN_USER_BTN);
if (btnState != _prevBtnState) {
if (btnState == LOW) { // pressed?
if (_display->isOn()) {
// TODO: any action ?
} else {
if (btnState == LOW) {
if (!_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_auto_off = millis() + AUTO_OFF_MILLIS;
}
_prevBtnState = btnState;
}
_next_read = millis() + 200; // 5 reads per second
_next_read = millis() + 200;
}
#endif
@@ -105,10 +152,10 @@ void UITask::loop() {
renderCurrScreen();
_display->endFrame();
_next_refresh = millis() + 1000; // refresh every second
_next_refresh = millis() + 10000;
}
if (millis() > _auto_off) {
if (!AUTO_OFF_DISABLED && millis() > _auto_off) {
_display->turnOff();
}
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ class UITask {
unsigned long _next_read, _next_refresh, _auto_off;
int _prevBtnState;
NodePrefs* _node_prefs;
char _version_info[32];
char _version_info[48];
void renderCurrScreen();
public:
File diff suppressed because it is too large Load Diff
+110 -8
View File
@@ -1,8 +1,13 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include <time.h>
#include "MyMesh.h"
#ifdef HAS_4G_MODEM
#include <SD.h>
#include "CellularMQTT.h"
#endif
#ifdef DISPLAY_CLASS
#include "UITask.h"
static UITask ui_task(display);
@@ -23,6 +28,10 @@ static char command[160];
unsigned long lastActive = 0; // mark last active time
unsigned long nextSleepinSecs = 120; // next sleep in seconds. The first sleep (if enabled) is after 2 minutes from boot
#ifdef HAS_4G_MODEM
static bool sdCardReady = false;
#endif
void setup() {
Serial.begin(115200);
delay(1000);
@@ -83,6 +92,48 @@ void setup() {
the_mesh.begin(fs);
// ---------------------------------------------------------------------------
// SD card init — needed for CellularMQTT config (/remote/mqtt.cfg)
// SD, LoRa, and e-ink share the same SPI bus on T-Deck Pro.
// ---------------------------------------------------------------------------
#ifdef HAS_4G_MODEM
{
// Deselect all SPI devices before SD init to prevent bus contention
#ifdef SDCARD_CS
pinMode(SDCARD_CS, OUTPUT);
digitalWrite(SDCARD_CS, HIGH);
#endif
#ifdef PIN_DISPLAY_CS
pinMode(PIN_DISPLAY_CS, OUTPUT);
digitalWrite(PIN_DISPLAY_CS, HIGH);
#endif
#ifdef P_LORA_NSS
pinMode(P_LORA_NSS, OUTPUT);
digitalWrite(P_LORA_NSS, HIGH);
#endif
delay(100);
for (int i = 0; i < 3; i++) {
#ifdef SDCARD_CS
extern SPIClass displaySpi;
if (SD.begin(SDCARD_CS, displaySpi)) { sdCardReady = true; break; }
#else
if (SD.begin(SPI_CS)) { sdCardReady = true; break; }
#endif
delay(200);
}
Serial.printf("SD card: %s\n", sdCardReady ? "ready" : "FAILED");
}
// Start cellular MQTT
if (sdCardReady) {
cellularMQTT.begin();
Serial.println("Cellular MQTT starting...");
} else {
Serial.println("Cellular MQTT skipped — no SD card for config");
}
#endif
#ifdef DISPLAY_CLASS
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
#endif
@@ -118,6 +169,55 @@ void loop() {
command[0] = 0; // reset command buffer
}
// ---------------------------------------------------------------------------
// MQTT → CLI bridge: process incoming commands from MQTT
// ---------------------------------------------------------------------------
#ifdef HAS_4G_MODEM
{
MQTTCommand mqttCmd;
while (cellularMQTT.recvCommand(mqttCmd)) {
// CLI command — process through the same handler as serial/LoRa admin
Serial.printf("[MQTT] CLI: %s\n", mqttCmd.cmd);
char reply[512];
reply[0] = '\0';
the_mesh.handleCommand((uint32_t)time(nullptr), mqttCmd.cmd, reply);
if (reply[0] == '\0') strcpy(reply, "OK");
cellularMQTT.sendResponse(cellularMQTT.getRspTopic(), reply);
Serial.printf("[MQTT] Reply: %.80s\n", reply);
}
}
// Periodic telemetry snapshot for MQTT publishing
{
static unsigned long lastTelemUpdate = 0;
if (millis() - lastTelemUpdate > 10000) {
NodePrefs* p = the_mesh.getNodePrefs();
TelemetryData td;
memset(&td, 0, sizeof(td));
td.uptime_secs = millis() / 1000;
td.battery_mv = board.getBattMilliVolts();
td.battery_pct = board.getBatteryPercent();
td.temperature = board.getBattTemperature();
td.csq = cellularMQTT.getCSQ();
td.freq = p->freq;
td.bw = p->bw;
td.sf = p->sf;
td.cr = p->cr;
td.tx_power = p->tx_power_dbm;
strncpy(td.node_name, p->node_name, sizeof(td.node_name) - 1);
strncpy(td.apn, cellularMQTT.getAPN(), sizeof(td.apn) - 1);
strncpy(td.oper, cellularMQTT.getOperator(), sizeof(td.oper) - 1);
td.mqtt_connected = cellularMQTT.isConnected();
td.neighbor_count = 0; // TODO: expose from MyMesh
cellularMQTT.updateTelemetry(td);
lastTelemUpdate = millis();
}
}
#endif
the_mesh.loop();
sensors.loop();
#ifdef DISPLAY_CLASS
@@ -125,14 +225,16 @@ void loop() {
#endif
rtc_clock.tick();
if (the_mesh.getNodePrefs()->powersaving_enabled && // To check if power saving is enabled
the_mesh.millisHasNowPassed(lastActive + nextSleepinSecs * 1000)) { // To check if it is time to sleep
if (!the_mesh.hasPendingWork()) { // No pending work. Safe to sleep
board.sleep(1800); // To sleep. Wake up after 30 minutes or when receiving a LoRa packet
#ifndef HAS_4G_MODEM
if (the_mesh.getNodePrefs()->powersaving_enabled &&
the_mesh.millisHasNowPassed(lastActive + nextSleepinSecs * 1000)) {
if (!the_mesh.hasPendingWork()) {
board.sleep(1800);
lastActive = millis();
nextSleepinSecs = 5; // Default: To work for 5s and sleep again
nextSleepinSecs = 5;
} else {
nextSleepinSecs += 5; // When there is pending work, to work another 5s
nextSleepinSecs += 5;
}
}
}
#endif
}
@@ -111,7 +111,7 @@ extends = LilyGo_T5S3_EPaper_Pro
build_flags =
${LilyGo_T5S3_EPaper_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_CONTACTS=510
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
+47 -7
View File
@@ -21,7 +21,9 @@
#define KB_KEY_BACKSPACE '\b'
#define KB_KEY_ENTER '\r'
#define KB_KEY_SPACE ' '
#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker)
#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker)
#define KB_KEY_MIC 0x02 // Mic key press (PTT start / voice screen open)
#define KB_KEY_MIC_RELEASE 0x03 // Mic key release (PTT stop)
class TCA8418Keyboard {
private:
@@ -34,7 +36,10 @@ private:
bool _shiftUsedWhileHeld; // Was shift consumed by any key while held
bool _altActive; // Sticky alt (one-shot)
bool _symActive; // Sticky sym (one-shot)
bool _micHeld; // Mic key physically held down (for PTT release detection)
unsigned long _lastShiftTime; // For Shift+key combos
bool _enterHeld; // Enter key physically held down
unsigned long _enterPressTime; // millis() when Enter was pressed
uint8_t readReg(uint8_t reg) {
_wire->beginTransmission(_addr);
@@ -151,7 +156,8 @@ private:
public:
TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire)
: _addr(addr), _wire(wire), _initialized(false),
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _micHeld(false), _lastShiftTime(0),
_enterHeld(false), _enterPressTime(0) {}
bool begin() {
// Check if device responds
@@ -242,7 +248,22 @@ public:
return 0;
}
// Track mic key release — return KB_KEY_MIC_RELEASE for PTT stop
if (!pressed && keyCode == 34) {
if (_micHeld) {
_micHeld = false;
Serial.println("KB: Mic released -> KB_KEY_MIC_RELEASE");
return KB_KEY_MIC_RELEASE;
}
return 0;
}
// Only act on key press, not release
// (Enter release tracked for long-press detection)
if (!pressed && keyCode == 21) {
_enterHeld = false;
return 0;
}
if (!pressed || keyCode == 0) {
return 0;
}
@@ -266,6 +287,13 @@ public:
Serial.println("KB: Sym activated");
return 0;
}
// Track Enter press for long-press detection
if (keyCode == 21) {
_enterHeld = true;
_enterPressTime = millis();
// Fall through to normal processing — '\r' is returned below
}
// Handle dedicated $ key (key code 22, next to M)
// Bare press = emoji picker, Sym+$ = literal '$'
@@ -279,12 +307,17 @@ public:
return KB_KEY_EMOJI;
}
// Handle Mic key - always produces '0' (silk-screened on key)
// Sym+Mic also produces '0' (consumes sym so it doesn't leak)
// Handle Mic key — bare press returns KB_KEY_MIC for PTT / voice screen
// Sym+Mic produces '0' (silk-screened on key) for text input
if (keyCode == 34) {
_symActive = false;
Serial.println("KB: Mic -> '0'");
return '0';
if (_symActive) {
_symActive = false;
Serial.println("KB: Sym+Mic -> '0'");
return '0';
}
_micHeld = true;
Serial.println("KB: Mic -> KB_KEY_MIC");
return KB_KEY_MIC;
}
// Get the character
@@ -338,6 +371,7 @@ public:
}
bool isReady() const { return _initialized; }
bool isMicHeld() const { return _micHeld; }
// Check if shift was pressed within the last N milliseconds
bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const {
@@ -349,4 +383,10 @@ public:
bool wasShiftConsumed() const {
return _shiftConsumed;
}
// Enter long-press detection
bool isEnterHeld() const { return _enterHeld; }
unsigned long enterHeldMs() const {
return _enterHeld ? (millis() - _enterPressTime) : 0;
}
};
+43 -7
View File
@@ -111,7 +111,7 @@ extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_CONTACTS=510
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
@@ -129,6 +129,7 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
https://github.com/sh123/esp32_codec2_arduino.git
; Audio + WiFi companion (audio-player hardware with WiFi app bridging)
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
@@ -151,7 +152,7 @@ build_flags =
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.5.WiFi"'
-D FIRMWARE_VERSION='"Meck v1.6.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -163,6 +164,7 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
https://github.com/sh123/esp32_codec2_arduino.git
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
@@ -189,6 +191,7 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
https://github.com/sh123/esp32_codec2_arduino.git
; 4G + BLE companion (4G modem hardware, no audio — GPIO conflict with PCM5102A)
; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510)
@@ -197,14 +200,14 @@ extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_CONTACTS=510
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.5.4G"'
-D FIRMWARE_VERSION='"Meck v1.6.4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -235,7 +238,7 @@ build_flags =
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.5.4G.WiFi"'
-D FIRMWARE_VERSION='"Meck v1.6.4G.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -262,7 +265,7 @@ build_flags =
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.5.4G.SA"'
-D FIRMWARE_VERSION='"Meck v1.6.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -271,4 +274,37 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
densaugeo/base64 @ ~1.4.0
; ---------------------------------------------------------------------------
; Remote Repeater (T-Deck Pro 4G, cellular MQTT remote management)
;
; MeshCore repeater firmware + A7682E cellular MQTT for remote admin.
; No BLE, no SMS/calls, no companion protocol. All management via MQTT
; or USB serial CLI.
;
; SD card config required: /remote/mqtt.cfg (broker, port, user, pass)
; Optional: /remote/apn.cfg (APN override)
;
; Add this block to the bottom of platformio.ini
; Flash with: pio run -e meck_remote_repeater
; ---------------------------------------------------------------------------
[env:meck_remote_repeater]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/simple_repeater
-D ADMIN_PASSWORD='"admin"'
-D HAS_4G_MODEM=1
-D DISABLE_WIFI_OTA=1
-D MECK_REMOTE_REPEATER=1
-D MAX_NEIGHBOURS=16
-D FIRMWARE_VERSION='"Meck RemRptr v0.1"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
+<../examples/simple_repeater/*.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}