Compare commits

...

2 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
4 changed files with 491 additions and 117 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
+39 -46
View File
@@ -2,18 +2,6 @@
// =============================================================================
// CellularMQTT — A7682E Modem + MQTT via native AT commands
//
// Stripped-down modem driver for the remote repeater use case. No SMS, no
// voice calls. Just: power on → register → activate data → MQTT connect.
//
// Uses the A7682E's built-in MQTT client with TLS (AT+CMQTT* commands).
// The UART stays in AT command mode permanently — no PPP needed.
//
// Runs on a FreeRTOS task (Core 0) to avoid blocking the mesh radio loop.
// Commands from the MQTT dashboard arrive as queued strings for the main
// loop to process via CommonCLI; responses are queued back for publishing.
//
// Guard: HAS_4G_MODEM (variant.h provides pin definitions)
// =============================================================================
#ifdef HAS_4G_MODEM
@@ -36,25 +24,22 @@
#define MQTT_PAYLOAD_MAX 512
#define MQTT_CLIENT_ID_MAX 32
// Queue sizes
#define CMD_QUEUE_SIZE 4 // MQTT → main loop (CLI commands)
#define RSP_QUEUE_SIZE 4 // main loop → MQTT (CLI responses)
#define CMD_QUEUE_SIZE 4
#define RSP_QUEUE_SIZE 4
// Telemetry interval (ms)
#define TELEMETRY_INTERVAL 60000 // 60 seconds
#define TELEMETRY_INTERVAL 60000
// Task configuration
#define CELL_TASK_PRIORITY 1
#define CELL_TASK_STACK_SIZE 8192 // MQTT + TLS AT commands need headroom
#define CELL_TASK_STACK_SIZE 8192
#define CELL_TASK_CORE 0
// Reconnect timing
#define MQTT_RECONNECT_MIN 5000 // 5 seconds
#define MQTT_RECONNECT_MAX 300000 // 5 minutes (exponential backoff cap)
#define MQTT_RECONNECT_MIN 5000
#define MQTT_RECONNECT_MAX 300000
// Consecutive publish failures before forced reconnect
#define MQTT_PUB_FAIL_MAX 5
#define OTA_CHUNK_SIZE 1024
// ---------------------------------------------------------------------------
// State machine
// ---------------------------------------------------------------------------
@@ -63,11 +48,12 @@ enum class CellState : uint8_t {
POWERING_ON,
INITIALIZING,
REGISTERING,
DATA_ACTIVATING, // PDP context
MQTT_STARTING, // AT+CMQTTSTART
MQTT_CONNECTING, // AT+CMQTTCONNECT
CONNECTED, // MQTT up, subscribed, publishing telemetry
RECONNECTING, // Link lost, attempting reconnect
DATA_ACTIVATING,
MQTT_STARTING,
MQTT_CONNECTING,
CONNECTED,
RECONNECTING,
OTA_IN_PROGRESS,
ERROR
};
@@ -75,12 +61,10 @@ enum class CellState : uint8_t {
// Queue message types
// ---------------------------------------------------------------------------
// Incoming CLI command (from MQTT subscription → main loop)
struct MQTTCommand {
char cmd[MQTT_PAYLOAD_MAX];
};
// Outgoing CLI response (from main loop → MQTT publish)
struct MQTTResponse {
char topic[MQTT_TOPIC_MAX];
char payload[MQTT_PAYLOAD_MAX];
@@ -90,21 +74,21 @@ struct MQTTResponse {
// MQTT config (loaded from SD: /remote/mqtt.cfg)
// ---------------------------------------------------------------------------
struct MQTTConfig {
char broker[80]; // e.g. "broker.hivemq.cloud"
uint16_t port; // e.g. 8883
char broker[80];
uint16_t port;
char username[40];
char password[40];
char deviceId[MQTT_CLIENT_ID_MAX]; // Auto-generated from MAC if empty
char deviceId[MQTT_CLIENT_ID_MAX];
};
// ---------------------------------------------------------------------------
// Telemetry snapshot (filled by main loop, published by modem task)
// Telemetry snapshot
// ---------------------------------------------------------------------------
struct TelemetryData {
uint32_t uptime_secs;
uint16_t battery_mv;
uint8_t battery_pct;
int16_t temperature; // 0.1°C from BQ27220
int16_t temperature;
int csq;
uint8_t neighbor_count;
float freq;
@@ -130,10 +114,14 @@ public:
bool recvCommand(MQTTCommand& out);
bool sendResponse(const char* topic, const char* payload);
// --- Telemetry (set by main loop, published by modem task) ---
// --- Telemetry ---
void updateTelemetry(const TelemetryData& data);
// --- State queries (lock-free reads from main loop) ---
// --- 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; }
@@ -146,7 +134,6 @@ public:
const char* stateString() const;
uint32_t getLastCmdTime() const { return _lastCmdTime; }
// Load config from SD card
static bool loadConfig(MQTTConfig& cfg);
private:
@@ -164,7 +151,6 @@ private:
TelemetryData _telemetry = {};
SemaphoreHandle_t _telemetryMutex = nullptr;
// Topic strings (built from device ID)
char _topicCmd[MQTT_TOPIC_MAX] = {0};
char _topicRsp[MQTT_TOPIC_MAX] = {0};
char _topicTelem[MQTT_TOPIC_MAX] = {0};
@@ -175,19 +161,15 @@ private:
QueueHandle_t _rspQueue = nullptr;
SemaphoreHandle_t _uartMutex = nullptr;
// Publish failure counter for health monitoring
uint8_t _pubFailCount = 0;
// AT response buffer
static const int AT_BUF_SIZE = 512;
char _atBuf[AT_BUF_SIZE];
// URC accumulation
static const int URC_BUF_SIZE = 600;
char _urcBuf[URC_BUF_SIZE];
int _urcPos = 0;
// MQTT receive state machine (multi-line URC parsing)
enum MqttRxState { RX_IDLE, RX_WAIT_TOPIC, RX_WAIT_PAYLOAD };
MqttRxState _rxState = RX_IDLE;
int _rxTopicLen = 0;
@@ -195,10 +177,13 @@ private:
char _rxTopic[MQTT_TOPIC_MAX];
char _rxPayload[MQTT_PAYLOAD_MAX];
// Reconnect backoff
uint32_t _reconnectDelay = MQTT_RECONNECT_MIN;
// --- Modem UART helpers (modem task only) ---
// 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);
@@ -210,7 +195,7 @@ private:
void resolveAPN();
bool activateData();
// --- MQTT operations (modem task only) ---
// --- MQTT operations ---
bool mqttStart();
bool mqttConnect();
bool mqttSubscribe(const char* topic);
@@ -224,6 +209,14 @@ private:
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();
+281 -47
View File
@@ -5,7 +5,8 @@
#include <SD.h>
#include <esp_mac.h>
#include <time.h>
#include <sys/time.h>
#include <sys/time.h>
#include <Update.h>
CellularMQTT cellularMQTT;
@@ -77,6 +78,17 @@ void CellularMQTT::updateTelemetry(const TelemetryData& data) {
}
}
void CellularMQTT::requestOTA(const char* url) {
if (_state == CellState::OTA_IN_PROGRESS) {
Serial.println("[OTA] Already in progress");
return;
}
strncpy(_otaUrl, url, sizeof(_otaUrl) - 1);
_otaUrl[sizeof(_otaUrl) - 1] = '\0';
_otaPending = true;
Serial.printf("[OTA] Requested: %s\n", url);
}
int CellularMQTT::getSignalBars() const {
if (_csq == 99 || _csq == 0) return 0;
if (_csq <= 5) return 1;
@@ -97,6 +109,7 @@ const char* CellularMQTT::stateString() const {
case CellState::MQTT_CONNECTING: return "MQTT CONN";
case CellState::CONNECTED: return "CONNECTED";
case CellState::RECONNECTING: return "RECONN";
case CellState::OTA_IN_PROGRESS: return "OTA";
case CellState::ERROR: return "ERROR";
default: return "???";
}
@@ -104,12 +117,6 @@ const char* CellularMQTT::stateString() const {
// ---------------------------------------------------------------------------
// Config file: /remote/mqtt.cfg
// Format (one value per line):
// broker.hivemq.cloud
// 8883
// myusername
// mypassword
// mydeviceid (optional — auto-generated from MAC if omitted)
// ---------------------------------------------------------------------------
bool CellularMQTT::loadConfig(MQTTConfig& cfg) {
@@ -133,7 +140,6 @@ bool CellularMQTT::loadConfig(MQTTConfig& cfg) {
line = f.readStringUntil('\n'); line.trim();
strncpy(cfg.password, line.c_str(), sizeof(cfg.password) - 1);
// Optional device ID
if (f.available()) {
line = f.readStringUntil('\n'); line.trim();
if (line.length() > 0) {
@@ -143,7 +149,6 @@ bool CellularMQTT::loadConfig(MQTTConfig& cfg) {
f.close();
// Auto-generate device ID from ESP32 MAC if not provided
if (cfg.deviceId[0] == '\0') {
uint8_t mac[6];
esp_efuse_mac_get_default(mac);
@@ -155,7 +160,7 @@ bool CellularMQTT::loadConfig(MQTTConfig& cfg) {
}
// ---------------------------------------------------------------------------
// Modem power-on (same sequence as ModemManager)
// Modem power-on
// ---------------------------------------------------------------------------
bool CellularMQTT::modemPowerOn() {
@@ -366,11 +371,8 @@ void CellularMQTT::handleMqttRxPayload(const char* data, int len) {
Serial.println("[Cell] Command queue full, dropping");
}
} else if (strstr(_rxTopic, "/ota")) {
MQTTCommand cmd;
memset(&cmd, 0, sizeof(cmd));
snprintf(cmd.cmd, sizeof(cmd.cmd), "ota:%s", data);
xQueueSend(_cmdQueue, &cmd, 0);
Serial.printf("[Cell] Queued OTA URL: %s\n", data);
// Handle OTA directly in the modem task (not queued through main loop)
requestOTA(data);
}
}
@@ -386,11 +388,10 @@ void CellularMQTT::handleMqttConnLost(const char* line) {
}
// ---------------------------------------------------------------------------
// APN resolution (reuses Meck's ApnDatabase)
// APN resolution
// ---------------------------------------------------------------------------
void CellularMQTT::resolveAPN() {
// 1. Check SD config
File f = SD.open("/remote/apn.cfg", FILE_READ);
if (f) {
String line = f.readStringUntil('\n');
@@ -406,7 +407,6 @@ void CellularMQTT::resolveAPN() {
}
}
// 2. Check modem's current APN
if (sendAT("AT+CGDCONT?", "OK", 3000)) {
char* p = strstr(_atBuf, "+CGDCONT:");
if (p) {
@@ -429,7 +429,6 @@ void CellularMQTT::resolveAPN() {
}
}
// 3. Auto-detect from IMSI
if (_imsi[0]) {
const ApnEntry* entry = apnLookupFromIMSI(_imsi);
if (entry) {
@@ -447,7 +446,7 @@ void CellularMQTT::resolveAPN() {
}
// ---------------------------------------------------------------------------
// Data connection — activate PDP context
// Data connection
// ---------------------------------------------------------------------------
bool CellularMQTT::activateData() {
@@ -462,7 +461,6 @@ bool CellularMQTT::activateData() {
}
}
// Query IP address
if (sendAT("AT+CGPADDR=1", "OK", 5000)) {
char* p = strstr(_atBuf, "+CGPADDR:");
if (p) {
@@ -497,7 +495,6 @@ bool CellularMQTT::mqttStart() {
}
}
// Acquire client with SSL enabled (third param = 1 for SSL)
char cmd[120];
snprintf(cmd, sizeof(cmd), "AT+CMQTTACCQ=0,\"%s\",1", _config.deviceId);
if (!sendAT(cmd, "OK", 5000)) {
@@ -505,16 +502,9 @@ bool CellularMQTT::mqttStart() {
return false;
}
// Configure TLS 1.2 (sslversion 4 = TLS 1.2)
sendAT("AT+CSSLCFG=\"sslversion\",0,4", "OK", 3000);
// Skip certificate verification (no CA cert loaded on device)
sendAT("AT+CSSLCFG=\"authmode\",0,0", "OK", 3000);
// Enable SNI — required for HiveMQ Cloud (shared IP, multiple clusters)
sendAT("AT+CSSLCFG=\"enableSNI\",0,1", "OK", 3000);
// Bind SSL config to MQTT session
sendAT("AT+CMQTTSSLCFG=0,0", "OK", 3000);
return true;
@@ -529,11 +519,9 @@ bool CellularMQTT::mqttConnect() {
Serial.printf("[Cell] TX: AT+CMQTTCONNECT=0,\"ssl://%s:%d\",...\n",
_config.broker, _config.port);
Serial.printf("[Cell] Full cmd (%d chars): %s\n", strlen(cmd), cmd);
Serial.printf("[Cell] Full cmd (%d chars): %s\n", strlen(cmd), cmd);
MODEM_SERIAL.println(cmd);
// Wait for +CMQTTCONNECT URC (any result code, not just success)
// Don't use waitResponse — it bails on "ERROR" before we see the code
unsigned long start = millis();
int pos = 0;
_atBuf[0] = '\0';
@@ -545,10 +533,8 @@ bool CellularMQTT::mqttConnect() {
_atBuf[pos++] = c;
_atBuf[pos] = '\0';
}
// Check for the URC regardless of what else is in the buffer
char* p = strstr(_atBuf, "+CMQTTCONNECT:");
if (p) {
// Give it a moment to complete the line
vTaskDelay(pdMS_TO_TICKS(100));
while (MODEM_SERIAL.available() && pos < AT_BUF_SIZE - 1) {
_atBuf[pos++] = MODEM_SERIAL.read();
@@ -570,7 +556,6 @@ bool CellularMQTT::mqttConnect() {
vTaskDelay(pdMS_TO_TICKS(50));
}
// Timeout — dump what we got
int len = strlen(_atBuf);
while (len > 0 && (_atBuf[len-1] == '\r' || _atBuf[len-1] == '\n')) _atBuf[--len] = '\0';
Serial.printf("[Cell] MQTT connect timeout. Buffer: %.200s\n", _atBuf);
@@ -596,7 +581,6 @@ bool CellularMQTT::mqttPublish(const char* topic, const char* payload) {
int tlen = strlen(topic);
int plen = strlen(payload);
// Step 1: Set topic
char cmd[80];
snprintf(cmd, sizeof(cmd), "AT+CMQTTTOPIC=0,%d", tlen);
MODEM_SERIAL.println(cmd);
@@ -611,7 +595,6 @@ bool CellularMQTT::mqttPublish(const char* topic, const char* payload) {
return false;
}
// Step 2: Set payload
snprintf(cmd, sizeof(cmd), "AT+CMQTTPAYLOAD=0,%d", plen);
MODEM_SERIAL.println(cmd);
if (!waitPrompt(5000)) {
@@ -624,14 +607,12 @@ bool CellularMQTT::mqttPublish(const char* topic, const char* payload) {
return false;
}
// Step 3: Publish QoS 1, 60s timeout
if (!sendAT("AT+CMQTTPUB=0,1,60", "OK", 15000)) {
_pubFailCount++;
Serial.printf("[Cell] Publish failed (%d consecutive)\n", _pubFailCount);
return false;
}
// Success — reset failure counter
_pubFailCount = 0;
return true;
}
@@ -642,6 +623,256 @@ void CellularMQTT::mqttDisconnect() {
sendAT("AT+CMQTTSTOP", "OK", 5000);
}
// ---------------------------------------------------------------------------
// OTA — HTTP download + ESP32 flash
// ---------------------------------------------------------------------------
void CellularMQTT::otaPublish(const char* msg) {
Serial.printf("[OTA] %s\n", msg);
mqttPublish(_topicRsp, msg);
}
int CellularMQTT::readRawBytes(uint8_t* dest, int count, uint32_t timeout_ms) {
unsigned long start = millis();
int received = 0;
while (received < count && millis() - start < timeout_ms) {
while (MODEM_SERIAL.available() && received < count) {
dest[received++] = MODEM_SERIAL.read();
}
if (received < count) vTaskDelay(pdMS_TO_TICKS(5));
}
return received;
}
int CellularMQTT::httpGet(const char* url) {
// Terminate any previous HTTP session
sendAT("AT+HTTPTERM", "OK", 2000);
vTaskDelay(pdMS_TO_TICKS(500));
if (!sendAT("AT+HTTPINIT", "OK", 5000)) {
Serial.println("[OTA] HTTPINIT failed");
return -1;
}
// Set URL via prompt pattern
int urlLen = strlen(url);
char cmd[40];
snprintf(cmd, sizeof(cmd), "AT+HTTPPARA=\"URL\",%d", urlLen);
MODEM_SERIAL.println(cmd);
if (!waitPrompt(5000)) {
Serial.println("[OTA] No prompt for HTTPPARA URL");
httpTerm();
return -1;
}
MODEM_SERIAL.write((const uint8_t*)url, urlLen);
if (!waitResponse("OK", 10000, _atBuf, AT_BUF_SIZE)) {
Serial.println("[OTA] HTTPPARA URL failed");
httpTerm();
return -1;
}
// SSL config for HTTPS
if (strncmp(url, "https://", 8) == 0) {
sendAT("AT+HTTPPARA=\"SSLCFG\",0", "OK", 3000);
}
// Follow redirects (GitHub releases use 302)
sendAT("AT+HTTPPARA=\"REDIR\",1", "OK", 2000);
// Execute GET — response: +HTTPACTION: 0,<status>,<content_length>
MODEM_SERIAL.println("AT+HTTPACTION=0");
// Wait for +HTTPACTION URC — download can take minutes over Cat-1
unsigned long start = millis();
int pos = 0;
_atBuf[0] = '\0';
while (millis() - start < 180000) { // 3 minute timeout
while (MODEM_SERIAL.available()) {
char c = MODEM_SERIAL.read();
if (pos < AT_BUF_SIZE - 1) {
_atBuf[pos++] = c;
_atBuf[pos] = '\0';
}
char* p = strstr(_atBuf, "+HTTPACTION:");
if (p) {
vTaskDelay(pdMS_TO_TICKS(100));
while (MODEM_SERIAL.available() && pos < AT_BUF_SIZE - 1) {
_atBuf[pos++] = MODEM_SERIAL.read();
_atBuf[pos] = '\0';
}
int method, status, contentLen;
if (sscanf(p, "+HTTPACTION: %d,%d,%d", &method, &status, &contentLen) == 3) {
Serial.printf("[OTA] HTTP status=%d content_length=%d\n", status, contentLen);
if (status == 200 && contentLen > 0) {
return contentLen;
}
Serial.printf("[OTA] HTTP download failed (status %d)\n", status);
httpTerm();
return -1;
}
}
}
vTaskDelay(pdMS_TO_TICKS(100));
}
Serial.println("[OTA] HTTP download timeout");
httpTerm();
return -1;
}
bool CellularMQTT::httpReadChunk(int offset, int len, uint8_t* dest, int* bytesRead) {
*bytesRead = 0;
// AT+HTTPREAD=<offset>,<length>
// Response: +HTTPREAD: <actual_len>\r\n<binary data>\r\nOK
char cmd[40];
snprintf(cmd, sizeof(cmd), "AT+HTTPREAD=%d,%d", offset, len);
MODEM_SERIAL.println(cmd);
// Wait for +HTTPREAD: <len> header
unsigned long start = millis();
int pos = 0;
_atBuf[0] = '\0';
while (millis() - start < 10000) {
while (MODEM_SERIAL.available()) {
char c = MODEM_SERIAL.read();
if (pos < AT_BUF_SIZE - 1) {
_atBuf[pos++] = c;
_atBuf[pos] = '\0';
}
char* p = strstr(_atBuf, "+HTTPREAD:");
if (p) {
char* nl = strchr(p, '\n');
if (nl) {
int actualLen = 0;
sscanf(p, "+HTTPREAD: %d", &actualLen);
if (actualLen <= 0 || actualLen > len) {
Serial.printf("[OTA] Bad HTTPREAD len: %d\n", actualLen);
return false;
}
// Read exactly actualLen binary bytes
int got = readRawBytes(dest, actualLen, 15000);
if (got != actualLen) {
Serial.printf("[OTA] Short read: got %d expected %d\n", got, actualLen);
return false;
}
*bytesRead = actualLen;
// Drain trailing \r\nOK\r\n
waitResponse("OK", 3000, _atBuf, AT_BUF_SIZE);
return true;
}
}
}
vTaskDelay(pdMS_TO_TICKS(10));
}
Serial.println("[OTA] HTTPREAD timeout");
return false;
}
void CellularMQTT::httpTerm() {
sendAT("AT+HTTPTERM", "OK", 3000);
}
void CellularMQTT::performOTA() {
_otaPending = false;
_state = CellState::OTA_IN_PROGRESS;
otaPublish("OTA: Starting download...");
Serial.printf("[OTA] URL: %s\n", _otaUrl);
// Disconnect MQTT — modem can only do one thing at a time
mqttDisconnect();
vTaskDelay(pdMS_TO_TICKS(1000));
// Download firmware via HTTP
int fileSize = httpGet(_otaUrl);
if (fileSize <= 0) {
Serial.println("[OTA] Download failed");
httpTerm();
_state = CellState::RECONNECTING;
return;
}
Serial.printf("[OTA] Downloaded %d bytes, flashing...\n", fileSize);
// Begin ESP32 OTA
if (!Update.begin(fileSize)) {
Serial.printf("[OTA] Update.begin failed: %s\n", Update.errorString());
httpTerm();
_state = CellState::RECONNECTING;
return;
}
// Allocate chunk buffer
uint8_t* chunk = (uint8_t*)malloc(OTA_CHUNK_SIZE);
if (!chunk) {
Serial.println("[OTA] malloc failed");
Update.abort();
httpTerm();
_state = CellState::RECONNECTING;
return;
}
int offset = 0;
int lastPct = -1;
while (offset < fileSize) {
int remaining = fileSize - offset;
int toRead = (remaining < OTA_CHUNK_SIZE) ? remaining : OTA_CHUNK_SIZE;
int bytesRead = 0;
if (!httpReadChunk(offset, toRead, chunk, &bytesRead) || bytesRead == 0) {
Serial.printf("[OTA] Read failed at offset %d\n", offset);
free(chunk);
Update.abort();
httpTerm();
_state = CellState::RECONNECTING;
return;
}
size_t written = Update.write(chunk, bytesRead);
if (written != (size_t)bytesRead) {
Serial.printf("[OTA] Write failed: wrote %d of %d\n", written, bytesRead);
free(chunk);
Update.abort();
httpTerm();
_state = CellState::RECONNECTING;
return;
}
offset += bytesRead;
int pct = (offset * 100) / fileSize;
if (pct / 10 != lastPct / 10) {
Serial.printf("[OTA] Flash progress: %d%% (%d/%d)\n", pct, offset, fileSize);
lastPct = pct;
}
vTaskDelay(pdMS_TO_TICKS(10)); // Yield to watchdog
}
free(chunk);
httpTerm();
if (!Update.end(true)) {
Serial.printf("[OTA] Update.end failed: %s\n", Update.errorString());
_state = CellState::RECONNECTING;
return;
}
Serial.println("[OTA] SUCCESS — rebooting in 3 seconds");
vTaskDelay(pdMS_TO_TICKS(3000));
ESP.restart();
}
// ---------------------------------------------------------------------------
// FreeRTOS Task
// ---------------------------------------------------------------------------
@@ -710,7 +941,6 @@ restart:
if (!registered) Serial.println("[Cell] Registration timeout — continuing");
}
// Operator name
sendAT("AT+COPS=3,0", "OK", 2000);
if (sendAT("AT+COPS?", "OK", 5000)) {
char* p = strchr(_atBuf, '"');
@@ -736,7 +966,7 @@ restart:
Serial.printf("[Cell] Registered: oper=%s CSQ=%d APN=%s IMEI=%s\n",
_operator, _csq, _apn[0] ? _apn : "(none)", _imei);
// Sync ESP32 system clock from modem network time
// Sync ESP32 system clock from modem network time
for (int attempt = 0; attempt < 5; attempt++) {
if (attempt > 0) vTaskDelay(pdMS_TO_TICKS(2000));
if (sendAT("AT+CCLK?", "OK", 3000)) {
@@ -744,7 +974,7 @@ restart:
if (p) {
int yy=0, mo=0, dd=0, hh=0, mm=0, ss=0, tz=0;
if (sscanf(p, "+CCLK: \"%d/%d/%d,%d:%d:%d", &yy, &mo, &dd, &hh, &mm, &ss) >= 6) {
if (yy < 24 || yy > 50) continue; // Not synced yet
if (yy < 24 || yy > 50) continue;
char* tzp = p + 7;
while (*tzp && *tzp != '+' && *tzp != '-') tzp++;
if (*tzp) tz = atoi(tzp);
@@ -792,10 +1022,9 @@ restart:
goto restart;
}
// Allow MQTT session to stabilise before subscribing
// Allow MQTT session to stabilise before subscribing
vTaskDelay(pdMS_TO_TICKS(2000));
// Subscribe with retry — the modem sometimes misses the first prompt
for (int i = 0; i < 3; i++) {
if (mqttSubscribe(_topicCmd)) break;
Serial.printf("[Cell] Subscribe retry %d for cmd topic\n", i + 1);
@@ -818,9 +1047,15 @@ restart:
unsigned long lastTelem = 0;
while (true) {
// Check for pending OTA request
if (_otaPending && _state == CellState::CONNECTED) {
performOTA();
continue; // After OTA failure, reconnect loop handles recovery
}
drainURCs();
// Health check: too many consecutive publish failures = silent disconnect
// Health check
if (_pubFailCount >= MQTT_PUB_FAIL_MAX && _state == CellState::CONNECTED) {
Serial.printf("[Cell] %d consecutive publish failures — forcing reconnect\n", _pubFailCount);
_state = CellState::RECONNECTING;
@@ -835,7 +1070,6 @@ restart:
mqttDisconnect();
vTaskDelay(pdMS_TO_TICKS(2000));
// Check data is still active
if (!sendAT("AT+CGACT?", "OK", 5000) || !strstr(_atBuf, ",1")) {
if (!activateData()) {
vTaskDelay(pdMS_TO_TICKS(10000));
@@ -844,7 +1078,7 @@ restart:
}
if (!mqttStart() || !mqttConnect()) {
continue; // Retry with backoff
continue;
}
mqttSubscribe(_topicCmd);
+1 -21
View File
@@ -43,12 +43,10 @@ void setup() {
#ifdef DISPLAY_CLASS
if (display.begin()) {
#ifndef HAS_4G_MODEM
display.startFrame();
display.setCursor(0, 0);
display.print("Please wait...");
display.endFrame();
#endif
}
#endif
@@ -124,16 +122,7 @@ void setup() {
#endif
delay(200);
}
Serial.printf("SD card: %s\n", sdCardReady ? "ready" : "FAILED");
// Re-claim SPI bus for display — SD.begin() steals the shared
// GPIO pins (36/47/33) from the display's HSPI peripheral
extern SPIClass displaySpi;
displaySpi.begin(PIN_DISPLAY_SCLK, 47, PIN_DISPLAY_MOSI, PIN_DISPLAY_CS);
// Re-claim shared HSPI bus — SD.begin() steals GPIO 36/47/33
extern SPIClass displaySpi;
displaySpi.begin(PIN_DISPLAY_SCLK, 47, PIN_DISPLAY_MOSI, PIN_DISPLAY_CS);
Serial.printf("SD card: %s\n", sdCardReady ? "ready" : "FAILED");
}
// Start cellular MQTT
@@ -187,15 +176,6 @@ void loop() {
{
MQTTCommand mqttCmd;
while (cellularMQTT.recvCommand(mqttCmd)) {
// Check for OTA command
if (strncmp(mqttCmd.cmd, "ota:", 4) == 0) {
const char* url = &mqttCmd.cmd[4];
Serial.printf("[MQTT] OTA request: %s\n", url);
// TODO: RemoteOTA — download firmware from URL and flash
cellularMQTT.sendResponse(cellularMQTT.getRspTopic(), "{\"ota\":\"not yet implemented\"}");
continue;
}
// CLI command — process through the same handler as serial/LoRa admin
Serial.printf("[MQTT] CLI: %s\n", mqttCmd.cmd);
char reply[512];