diff --git a/examples/simple_repeater/Cellularmqtt.h b/examples/simple_repeater/Cellularmqtt.h index 1a24a012..58c1031d 100644 --- a/examples/simple_repeater/Cellularmqtt.h +++ b/examples/simple_repeater/Cellularmqtt.h @@ -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,13 @@ 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(); + int readRawBytes(uint8_t* dest, int count, uint32_t timeout_ms); + // --- Task --- static void taskEntry(void* param); void taskLoop(); diff --git a/examples/simple_repeater/cellularmqtt.cpp b/examples/simple_repeater/cellularmqtt.cpp index 2636d91f..c3aaee82 100644 --- a/examples/simple_repeater/cellularmqtt.cpp +++ b/examples/simple_repeater/cellularmqtt.cpp @@ -5,7 +5,8 @@ #include #include #include -#include +#include +#include CellularMQTT cellularMQTT; @@ -97,11 +98,23 @@ 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 "???"; } } +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); +} + // --------------------------------------------------------------------------- // Config file: /remote/mqtt.cfg // Format (one value per line): @@ -366,11 +379,7 @@ 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); + requestOTA(data); } } @@ -642,6 +651,235 @@ void CellularMQTT::mqttDisconnect() { sendAT("AT+CMQTTSTOP", "OK", 5000); } +// --------------------------------------------------------------------------- +// OTA — HTTP download via A7682E + ESP32 flash +// --------------------------------------------------------------------------- + +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) { + sendAT("AT+HTTPTERM", "OK", 2000); + vTaskDelay(pdMS_TO_TICKS(500)); + + if (!sendAT("AT+HTTPINIT", "OK", 5000)) { + Serial.println("[OTA] HTTPINIT failed"); + return -1; + } + + 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; + } + + if (strncmp(url, "https://", 8) == 0) { + sendAT("AT+HTTPPARA=\"SSLCFG\",0", "OK", 3000); + } + + sendAT("AT+HTTPPARA=\"REDIR\",1", "OK", 2000); + + MODEM_SERIAL.println("AT+HTTPACTION=0"); + + unsigned long start = millis(); + int pos = 0; + _atBuf[0] = '\0'; + + while (millis() - start < 180000) { + 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; + + char cmd[40]; + snprintf(cmd, sizeof(cmd), "AT+HTTPREAD=%d,%d", offset, len); + MODEM_SERIAL.println(cmd); + + 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; + } + + 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; + 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; + + Serial.printf("[OTA] URL: %s\n", _otaUrl); + + // Disconnect MQTT — modem can only do one thing at a time + mqttDisconnect(); + vTaskDelay(pdMS_TO_TICKS(1000)); + + 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); + + if (!Update.begin(fileSize)) { + Serial.printf("[OTA] Update.begin failed: %s\n", Update.errorString()); + httpTerm(); + _state = CellState::RECONNECTING; + return; + } + + 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)); + } + + 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 // --------------------------------------------------------------------------- @@ -818,6 +1056,12 @@ restart: unsigned long lastTelem = 0; while (true) { + // Check for pending OTA request + if (_otaPending && _state == CellState::CONNECTED) { + performOTA(); + continue; + } + drainURCs(); // Health check: too many consecutive publish failures = silent disconnect diff --git a/examples/simple_repeater/wifimqtt.cpp b/examples/simple_repeater/wifimqtt.cpp index a8212235..b37ca267 100644 --- a/examples/simple_repeater/wifimqtt.cpp +++ b/examples/simple_repeater/wifimqtt.cpp @@ -389,37 +389,36 @@ void WiFiMQTT::performOTA() { Serial.printf("[OTA] URL: %s\n", _otaUrl); - _mqttClient.publish(_topicRsp, "OTA: Starting download..."); - _mqttClient.loop(); + // Disconnect MQTT cleanly — we need TLS resources for HTTP + _mqttClient.disconnect(); + + // Use a separate TLS client — don't reuse the MQTT one + WiFiClientSecure otaClient; + otaClient.setInsecure(); HTTPClient http; http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.setTimeout(180000); - if (!http.begin(_wifiClient, _otaUrl)) { + if (!http.begin(otaClient, _otaUrl)) { Serial.println("[OTA] HTTP begin failed"); - _mqttClient.publish(_topicRsp, "OTA: HTTP begin failed"); - _state = WiFiMQTTState::CONNECTED; + _state = WiFiMQTTState::MQTT_CONNECTING; return; } int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[OTA] HTTP error: %d\n", httpCode); - char msg[60]; - snprintf(msg, sizeof(msg), "OTA: HTTP error %d", httpCode); - _mqttClient.publish(_topicRsp, msg); http.end(); - _state = WiFiMQTTState::CONNECTED; + _state = WiFiMQTTState::MQTT_CONNECTING; return; } int fileSize = http.getSize(); if (fileSize <= 0) { Serial.println("[OTA] Unknown content length"); - _mqttClient.publish(_topicRsp, "OTA: Unknown file size"); http.end(); - _state = WiFiMQTTState::CONNECTED; + _state = WiFiMQTTState::MQTT_CONNECTING; return; } @@ -427,9 +426,8 @@ void WiFiMQTT::performOTA() { if (!Update.begin(fileSize)) { Serial.printf("[OTA] Update.begin failed: %s\n", Update.errorString()); - _mqttClient.publish(_topicRsp, "OTA: Flash init failed"); http.end(); - _state = WiFiMQTTState::CONNECTED; + _state = WiFiMQTTState::MQTT_CONNECTING; return; } @@ -461,10 +459,6 @@ void WiFiMQTT::performOTA() { int pct = (offset * 100) / fileSize; if (pct / 10 != lastPct / 10) { Serial.printf("[OTA] Progress: %d%% (%d/%d)\n", pct, offset, fileSize); - char msg[60]; - snprintf(msg, sizeof(msg), "OTA: Flashing %d%%", pct); - _mqttClient.publish(_topicRsp, msg); - _mqttClient.loop(); lastPct = pct; } @@ -476,21 +470,17 @@ void WiFiMQTT::performOTA() { if (offset < fileSize) { Serial.printf("[OTA] Incomplete: %d of %d\n", offset, fileSize); Update.abort(); - _mqttClient.publish(_topicRsp, "OTA: Download incomplete"); - _state = WiFiMQTTState::CONNECTED; + _state = WiFiMQTTState::MQTT_CONNECTING; return; } if (!Update.end(true)) { Serial.printf("[OTA] Update.end failed: %s\n", Update.errorString()); - _mqttClient.publish(_topicRsp, "OTA: Verification failed"); - _state = WiFiMQTTState::CONNECTED; + _state = WiFiMQTTState::MQTT_CONNECTING; return; } Serial.println("[OTA] SUCCESS — rebooting in 3 seconds"); - _mqttClient.publish(_topicRsp, "OTA: Success! Rebooting..."); - _mqttClient.loop(); delay(3000); ESP.restart(); } diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index e74dbbfc..6532044f 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -299,8 +299,8 @@ build_flags = -D DISABLE_WIFI_OTA=1 -D MECK_REMOTE_REPEATER=1 -D MAX_NEIGHBOURS=50 - -D FIRMWARE_VERSION='"Meck RemRptr v0.2"' - -D FIRMWARE_BUILD_DATE='"3 April 2026"' + -D FIRMWARE_VERSION='"Meck RemRptr v0.3"' + -D FIRMWARE_BUILD_DATE='"4 April 2026"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + + @@ -318,10 +318,12 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} +<../examples/simple_repeater/*.cpp> build_flags = ${LilyGo_TDeck_Pro.build_flags} - -D FIRMWARE_VERSION='"Meck WiFi Rptr v0.2"' - -D FIRMWARE_BUILD_DATE='"3 April 2026"' + -D FIRMWARE_VERSION='"Meck WiFi Rptr v0.3"' + -D FIRMWARE_BUILD_DATE='"4 April 2026"' -D MAX_NEIGHBOURS=50 -D MECK_WIFI_REMOTE + -D MECK_REMOTE_REPEATER=1 + -D DISABLE_WIFI_OTA=1 lib_deps = ${LilyGo_TDeck_Pro.lib_deps} knolleary/PubSubClient@^2.8