From b43e9618db5ff9536049cb7f6febe4953175cf04 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 20 Mar 2026 22:41:41 -0700 Subject: [PATCH] Refactor MQTT bridge implementation to support up to 3 configurable connection slots with built-in presets for LetsMesh Analyzer (US/EU) and MeshMapper. Update CLI commands for slot management and enhance configuration migration from legacy settings. Adjust related documentation and code structure for improved clarity and functionality. --- MQTT_IMPLEMENTATION.md | 445 ++- examples/simple_repeater/MyMesh.cpp | 7 +- examples/simple_room_server/MyMesh.cpp | 7 +- src/helpers/CommonCLI.cpp | 215 +- src/helpers/CommonCLI.h | 60 +- src/helpers/MQTTPresets.h | 105 + src/helpers/bridges/MQTTBridge.cpp | 3778 +++++++++--------------- src/helpers/bridges/MQTTBridge.h | 394 +-- 8 files changed, 2005 insertions(+), 3006 deletions(-) create mode 100644 src/helpers/MQTTPresets.h diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 115e5cb7..5271e0c5 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -7,9 +7,6 @@ This document describes the MQTT bridge implementation that allows MeshCore repe ### Essential Commands to Get MQTT Repeater Running **1. Connect to device console via repeater login or serial console (115200 baud)** -```bash -# Connect to device via serial -``` **2. Configure WiFi Credentials** ```bash @@ -17,7 +14,7 @@ set wifi.ssid YourWiFiNetwork set wifi.pwd YourWiFiPassword ``` -If you wish to upload to the MeshCore Analyzer, also `set mqtt.iata XXX` to a valid IATA airport code +If you wish to upload to the MeshCore Analyzer, also `set mqtt.iata XXX` to a valid IATA airport code. **3. Reboot to Connect to WiFi** ```bash @@ -36,6 +33,9 @@ get bridge.enabled get bridge.source get mqtt.origin get mqtt.iata +get mqtt1.preset +get mqtt2.preset +get mqtt3.preset ``` **6. Restart Bridge (if needed)** @@ -50,8 +50,8 @@ reboot **That's it!** The device will now: - Connect to WiFi automatically -- Start uplinking mesh packets to Let's Mesh Analyzer -- Publish to both custom MQTT broker and Let's Mesh Analyzer servers +- Start uplinking mesh packets to configured MQTT brokers +- By default, publish to Let's Mesh Analyzer US (slot 1) and EU (slot 2) - Use device name as MQTT origin (set automatically) --- @@ -59,131 +59,167 @@ reboot ## Overview The MQTT bridge implementation provides: -- Multiple MQTT broker support (up to 3 brokers) +- Up to 3 concurrent MQTT connection slots with built-in presets +- Built-in presets for LetsMesh Analyzer (US/EU) and MeshMapper +- Custom broker support with username/password authentication +- JWT (Ed25519 device signing) authentication for preset brokers +- WSS (WebSocket Secure) and direct MQTT transport - Automatic reconnection with exponential backoff - JSON message formatting for status, packets, and raw data -- Configurable topics and QoS levels - Packet queuing during connection issues +- Automatic migration from old configuration format -## Files Added +## Architecture -### Core Implementation +### Slot-Based Preset System + +The MQTT bridge uses a slot-based architecture with up to 3 concurrent connections. Each slot can be configured with a built-in preset or custom broker settings. + +**Built-in Presets:** + +| Preset | Server | Auth | Transport | +|--------|--------|------|-----------| +| `analyzer-us` | mqtt-us-v1.letsmesh.net:443 | JWT (Ed25519) | WSS | +| `analyzer-eu` | mqtt-eu-v1.letsmesh.net:443 | JWT (Ed25519) | WSS | +| `meshmapper` | mqtt.meshmapper.cc:443 | JWT (Ed25519) | WSS | +| `custom` | User-configured | Username/Password | MQTT or WSS | +| `none` | (disabled) | — | — | + +**Default Configuration:** +- Slot 1: `analyzer-us` +- Slot 2: `analyzer-eu` +- Slot 3: `none` + +### Files + +#### Core Implementation - `src/helpers/bridges/MQTTBridge.h` - MQTT bridge class definition - `src/helpers/bridges/MQTTBridge.cpp` - MQTT bridge implementation +- `src/helpers/MQTTPresets.h` - Preset definitions, CA certificates, and lookup functions - `src/helpers/MQTTMessageBuilder.h` - JSON message formatting utilities - `src/helpers/MQTTMessageBuilder.cpp` - JSON message formatting implementation +- `src/helpers/JWTHelper.h` - JWT token generation for Ed25519-based authentication -### Integration +#### Integration - Updated `examples/simple_repeater/MyMesh.h` - Added MQTT bridge support - Updated `examples/simple_repeater/MyMesh.cpp` - Added MQTT bridge integration and raw radio data capture -- Updated `src/helpers/CommonCLI.h` - Added MQTT, WiFi, and timezone configuration fields -- Updated `src/helpers/CommonCLI.cpp` - Added MQTT, WiFi, and timezone CLI commands -- Updated `variants/heltec_v3/platformio.ini` - Added MQTT build configuration -- Updated `variants/station_g2/platformio.ini` - Added MQTT build configuration for Station G2 +- Updated `src/helpers/CommonCLI.h` - MQTT slot preferences, WiFi, and timezone fields +- Updated `src/helpers/CommonCLI.cpp` - MQTT slot CLI commands, migration logic ## Build Configuration To build the MQTT bridge firmware: -### Heltec V3 ```bash +# Heltec V3 pio run -e Heltec_v3_repeater_observer_mqtt -``` -### Station G2 -```bash +# Heltec V4 +pio run -e heltec_v4_repeater_observer_mqtt + +# Station G2 pio run -e Station_G2_repeater_observer_mqtt ``` -### Custom MQTT Server Configuration - -You can configure a custom MQTT server using build flags in `platformio.ini`: - -```ini -[env:Heltec_v3_repeater_observer_mqtt] -build_flags = - ${Heltec_lora32_v3.build_flags} - -D WITH_MQTT_BRIDGE=1 - -D MQTT_SERVER='"your-mqtt-broker.com"' - -D MQTT_PORT=1883 - -D MQTT_USERNAME='"your-username"' - -D MQTT_PASSWORD='"your-password"' -``` - -**Build Flags:** -- `MQTT_SERVER` - MQTT broker hostname -- `MQTT_PORT` - MQTT broker port (default: 1883) -- `MQTT_USERNAME` - MQTT username -- `MQTT_PASSWORD` - MQTT password +### Build Flags +- `WITH_MQTT_BRIDGE=1` - Enable MQTT bridge (required) +- `MQTT_DEBUG=1` - Enable debug logging (optional) - `MQTT_WIFI_TX_POWER` - WiFi TX power level (default: `WIFI_POWER_11dBm`) - - Available values: `WIFI_POWER_19_5dBm`, `WIFI_POWER_19dBm`, `WIFI_POWER_18_5dBm`, `WIFI_POWER_17_5dBm`, `WIFI_POWER_15dBm`, `WIFI_POWER_13dBm`, `WIFI_POWER_11dBm`, `WIFI_POWER_8_5dBm`, `WIFI_POWER_7dBm`, `WIFI_POWER_5dBm`, `WIFI_POWER_2dBm`, `WIFI_POWER_MINUS_1dBm` - - Example: `-D MQTT_WIFI_TX_POWER=WIFI_POWER_19_5dBm` for maximum power - - **Note**: These power levels are appropriate for ESP32 and ESP32-S3. ESP32-C3 and ESP32-C6 may have different maximum power capabilities. If an invalid constant is used for your chip, the compiler will report an error. Check your specific ESP32 variant's datasheet for maximum supported TX power. +- `MQTT_WIFI_POWER_SAVE_DEFAULT` - Default WiFi power save mode (0=min, 1=none, 2=max) ## Default Configuration The MQTT bridge comes with the following defaults: -- **Origin**: "MeshCore-Repeater" -- **IATA**: "SEA" +- **Origin**: Device name (set automatically) +- **IATA**: (must be configured) - **Status Messages**: Enabled - **Packet Messages**: Enabled - **Raw Messages**: Disabled - **TX Messages**: Disabled (RX only by default) - **Status Interval**: 5 minutes (300000 ms) -- **Default Broker**: meshtastic.pugetmesh.org:1883 (username: meshdev, password: large4cats) +- **Slot 1**: `analyzer-us` (mqtt-us-v1.letsmesh.net:443) +- **Slot 2**: `analyzer-eu` (mqtt-eu-v1.letsmesh.net:443) +- **Slot 3**: `none` (disabled) - **WiFi SSID**: "ssid_here" (must be configured) - **WiFi Password**: "password_here" (must be configured) - **WiFi Power Save**: "min" (minimum power saving, balanced performance and power) - **Timezone**: "America/Los_Angeles" (Pacific Time with DST support) - **Timezone Offset**: -8 hours (fallback) -- **Let's Mesh Analyzer US**: Enabled (mqtt-us-v1.letsmesh.net:443) -- **Let's Mesh Analyzer EU**: Enabled (mqtt-eu-v1.letsmesh.net:443) ## CLI Commands -### MQTT Commands +### MQTT Slot Commands + +Each slot (1-3) supports the following commands: + +#### Get Commands +- `get mqtt1.preset` - Get slot 1 preset name +- `get mqtt2.preset` - Get slot 2 preset name +- `get mqtt3.preset` - Get slot 3 preset name +- `get mqttN.server` - Get custom server hostname for slot N +- `get mqttN.port` - Get custom server port for slot N +- `get mqttN.username` - Get custom username for slot N +- `get mqttN.password` - Get custom password for slot N + +#### Set Commands +- `set mqtt1.preset analyzer-us` - Set slot 1 to LetsMesh Analyzer US +- `set mqtt1.preset analyzer-eu` - Set slot 1 to LetsMesh Analyzer EU +- `set mqtt1.preset meshmapper` - Set slot 1 to MeshMapper +- `set mqtt1.preset custom` - Set slot 1 to custom broker (configure server/port/username/password) +- `set mqtt1.preset none` - Disable slot 1 +- `set mqttN.server ` - Set custom server hostname for slot N +- `set mqttN.port ` - Set custom server port for slot N (1-65535) +- `set mqttN.username ` - Set custom username for slot N +- `set mqttN.password ` - Set custom password for slot N + +**Note:** Custom server/port/username/password settings only apply when the slot's preset is `custom`. + +#### Example: Configure MeshMapper on Slot 3 +```bash +set mqtt3.preset meshmapper +``` + +#### Example: Configure Custom Broker on Slot 3 +```bash +set mqtt3.preset custom +set mqtt3.server your-broker.example.com +set mqtt3.port 1883 +set mqtt3.username your-username +set mqtt3.password your-password +``` + +### MQTT Shared Commands + +These settings apply across all MQTT slots: #### Get Commands - `get mqtt.origin` - Get device origin name - `get mqtt.iata` - Get IATA code -- `get mqtt.status` - Get status message setting (on/off) +- `get mqtt.status` - Get MQTT status summary (connection info per slot) - `get mqtt.packets` - Get packet message setting (on/off) - `get mqtt.raw` - Get raw message setting (on/off) - `get mqtt.tx` - Get TX message setting (on/off) -- `get mqtt.interval` - Get status publish interval (ms) -- `get mqtt.server` - Get MQTT server hostname -- `get mqtt.port` - Get MQTT server port -- `get mqtt.username` - Get MQTT username -- `get mqtt.password` - Get MQTT password -- `get mqtt.analyzer.us` - Get US Let's Mesh Analyzer server setting (on/off) -- `get mqtt.analyzer.eu` - Get EU Let's Mesh Analyzer server setting (on/off) -- `get mqtt.owner` - Get owner public key (64 hex characters) - - **Note**: Available via serial console only (not via LoRa repeater console) -- `get mqtt.email` - Get owner email address - - **Note**: Available via serial console only (not via LoRa repeater console) +- `get mqtt.interval` - Get status publish interval +- `get mqtt.owner` - Get owner public key (serial console only) +- `get mqtt.email` - Get owner email address (serial console only) #### Set Commands - `set mqtt.origin ` - Set device origin name -- `set mqtt.iata ` - Set IATA code +- `set mqtt.iata ` - Set IATA code (auto-uppercased) - `set mqtt.status on|off` - Enable/disable status messages - `set mqtt.packets on|off` - Enable/disable packet messages - `set mqtt.raw on|off` - Enable/disable raw messages - `set mqtt.tx on|off` - Enable/disable TX packet messages -- `set mqtt.interval ` - Set status publish interval (1000-3600000 ms) -- `set mqtt.server ` - Set MQTT server hostname -- `set mqtt.port ` - Set MQTT server port (1-65535) -- `set mqtt.username ` - Set MQTT username -- `set mqtt.password ` - Set MQTT password -- `set mqtt.analyzer.us on|off` - Enable/disable US Let's Mesh Analyzer server -- `set mqtt.analyzer.eu on|off` - Enable/disable EU Let's Mesh Analyzer server -- `set mqtt.owner <64-hex-char-public-key>` - Set owner public key (64 hex characters, 32 bytes) -- `set mqtt.email ` - Set owner email address for matching nodes with owners +- `set mqtt.interval ` - Set status publish interval (1-60 minutes) +- `set mqtt.owner <64-hex-char-public-key>` - Set owner public key +- `set mqtt.email ` - Set owner email address ### WiFi Commands #### Get Commands - `get wifi.ssid` - Get WiFi SSID - `get wifi.pwd` - Get WiFi password +- `get wifi.status` - Get WiFi connection status, IP, RSSI, and uptime - `get wifi.powersave` - Get WiFi power save mode (none/min/max) #### Set Commands @@ -228,14 +264,13 @@ The CLI commands are organized into two levels: - `bridge.enabled` - Master switch for the entire bridge system - `bridge.source` - Controls which packet events to capture (RX vs TX) -### Bridge-Specific Commands (`mqtt.*`, `wifi.*`, `timezone.*`) +### Bridge-Specific Commands (`mqtt.*`, `mqttN.*`, `wifi.*`, `timezone.*`) **Implementation-specific settings** - These only apply to the MQTT bridge: -- `mqtt.*` - MQTT broker configuration, message types, and formatting +- `mqttN.*` - Per-slot MQTT broker configuration (N = 1, 2, or 3) +- `mqtt.*` - Shared MQTT settings (message types, origin, IATA, etc.) - `wifi.*` - WiFi connection settings for MQTT connectivity - `timezone.*` - Timezone configuration for accurate timestamps -This design allows MeshCore to support multiple bridge types simultaneously while keeping configuration clean and logical. - ## MQTT Topics The bridge publishes to three main topics with the following structure: @@ -302,6 +337,14 @@ Minimal raw packet data for map integration. ## Key Features +### Slot-Based Preset System +- Up to 3 concurrent MQTT connections +- Built-in presets for LetsMesh Analyzer (US/EU) and MeshMapper +- Custom broker support with username/password auth +- JWT (Ed25519) authentication for preset brokers +- Automatic reconnection with exponential backoff per slot +- JWT token buffers only allocated for JWT-auth slots (memory efficient) + ### Raw Radio Data Capture - Captures actual raw radio transmission data (including radio headers) - Uses proper MeshCore packet hashing (SHA256-based) @@ -324,259 +367,105 @@ Minimal raw packet data for map integration. - Periodic time updates (every hour) - Proper UTC system time handling -### Let's Mesh Analyzer Integration -- **JWT Authentication**: Ed25519-signed tokens for secure MQTT authentication -- **WebSocket MQTT**: Support for MQTT over WebSocket connections (TLS/SSL) -- **Dual Server Support**: Both US and EU servers enabled by default -- **Automatic Token Generation**: Creates authentication tokens using device's Ed25519 keys -- **Username Format**: `v1_{UPPERCASE_PUBLIC_KEY}` (e.g., `v1_7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C9400`) -- **Server Configuration**: - - US Server: `mqtt-us-v1.letsmesh.net:443` (WebSocket with TLS) - - EU Server: `mqtt-eu-v1.letsmesh.net:443` (WebSocket with TLS) +### Authentication +- **JWT Authentication**: Ed25519-signed tokens for secure MQTT authentication (used by all built-in presets) +- **Username/Password**: Standard MQTT authentication for custom brokers +- **Username Format** (JWT): `v1_{UPPERCASE_PUBLIC_KEY}` +- **Automatic Token Renewal**: Tokens are renewed before expiration + +## Migration from Old Configuration + +When upgrading from a firmware version that used the old MQTT configuration format (`mqtt.analyzer.us`, `mqtt.analyzer.eu`, `mqtt.server`, `mqtt.port`, `mqtt.username`, `mqtt.password`), the device automatically migrates settings: + +- `mqtt.analyzer.us = on` → Slot 1 preset: `analyzer-us` +- `mqtt.analyzer.eu = on` → Slot 2 preset: `analyzer-eu` +- Custom server configured → Slot 3 preset: `custom` with host/port/username/password preserved +- All other settings (origin, IATA, message types, WiFi, timezone) are preserved as-is + +The migration happens automatically on first boot after firmware update. No manual intervention is needed. ## First-Time Setup ### Prerequisites - MeshCore device with MQTT bridge firmware flashed - WiFi network credentials -- MQTT broker (optional - default broker is provided) - LoRa-capable device for configuration (repeater console) -- MeshCore network access -### Step 1: Initial Boot and Network Connection -1. **Flash the firmware** to your device using PlatformIO or the build script -2. **Deploy the device** in your mesh network location -3. **Ensure WiFi connectivity** - the device will automatically connect to WiFi if credentials are pre-configured -4. **Verify mesh network access** - device should be discoverable by other mesh nodes - -### Step 2: Connect via LoRa Repeater Console -Use a MeshCore companion device to configure the Repeater's MQTT bridge. - -1. **Connect to the mesh** using your companion -2. **Locate the MQTT bridge device** in your contacts -3. **Log into your Repeater** using the default password (password) or whatever you configured via serial console -3. **Tap on the repeater console** on your repeater's settings -4. **Send configuration commands** via LoRa to the MQTT bridge device - -### Step 3: Configure WiFi Connection -The device needs internet connectivity to publish to MQTT brokers. - -**Via LoRa Repeater Console:** +### Step 1: Configure WiFi ``` -# Set your WiFi credentials -set wifi.ssid "YourWiFiNetwork" -set wifi.pwd "YourWiFiPassword" - -# Optionally configure WiFi power saving (default: min) -# Use "none" for best performance, "min" for balanced (default), "max" for lowest power -set wifi.powersave min - -# Verify WiFi settings -get wifi.ssid -get wifi.pwd -get wifi.powersave +set wifi.ssid YourWiFiNetwork +set wifi.pwd YourWiFiPassword +reboot ``` -### Step 4: Configure Device Identity -Set up your device's identity for MQTT topics and status messages. - -**Via LoRa Repeater Console:** +### Step 2: Configure Device Identity ``` -# Set IATA code for topic structure (e.g., airport code) -set mqtt.iata "SEA" - -# Verify settings (origin is set automatically to device name) +set mqtt.iata SEA get mqtt.origin -get mqtt.iata ``` -**Via Serial Console (Optional - Owner Configuration):** +### Step 3: Verify Slot Configuration ``` -# Set owner public key (64 hex characters, 32 bytes) -# This is used for matching nodes with owners in MQTT messages -set mqtt.owner A1B2C3D4E5F6789012345678901234567890123456789012345678901234567890 - -# Set owner email address -set mqtt.email owner@example.com - -# Verify owner settings -get mqtt.owner -get mqtt.email +get mqtt1.preset # Should show: analyzer-us +get mqtt2.preset # Should show: analyzer-eu +get mqtt3.preset # Should show: none ``` -### Step 5: Configure Timezone -Set your local timezone for accurate timestamps. - -**Via LoRa Repeater Console:** +### Step 4: (Optional) Add MeshMapper ``` -# Set timezone (choose one method) -set timezone "America/Los_Angeles" # IANA format -set timezone "PDT" # Abbreviation -set timezone "UTC-8" # UTC offset - -# Verify timezone -get timezone +set mqtt3.preset meshmapper ``` -### Step 6: Configure MQTT Settings -Customize which messages to publish and how often. - -**Via LoRa Repeater Console:** +### Step 5: (Optional) Configure Custom Broker ``` -# Configure MQTT server (optional - uses defaults if not set) -set mqtt.server "your-mqtt-broker.com" -set mqtt.port 1883 -set mqtt.username "your-username" -set mqtt.password "your-password" - -# Enable/disable message types -set mqtt.status on # Device status messages -set mqtt.packets on # Packet data messages -set mqtt.raw off # Raw packet data (optional) -set mqtt.tx off # Transmitted packets (optional) - -# Set status publish interval (default: 5 minutes) -set mqtt.interval 300000 - -# Verify settings -get mqtt.server -get mqtt.port -get mqtt.username -get mqtt.status -get mqtt.packets -get mqtt.interval +set mqtt3.preset custom +set mqtt3.server your-broker.example.com +set mqtt3.port 1883 +set mqtt3.username your-username +set mqtt3.password your-password ``` -### Step 7: Verify MQTT Broker Connection -Check that the device can connect to MQTT brokers. - -**Via LoRa Repeater Console:** +### Step 6: Verify Connection ``` -# Check bridge status +set bridge.source rx get bridge.enabled - -# If disabled, enable it -set bridge.enabled on - -# Check MQTT analyzer servers (optional) -get mqtt.analyzer.us -get mqtt.analyzer.eu +get mqtt.status +get wifi.status ``` -### Step 8: Monitor MQTT Messages -Once configured, the device will automatically publish messages to MQTT brokers. - -**Default MQTT Broker**: `meshtastic.pugetmesh.org:1883` -- Username: `meshdev` -- Password: `large4cats` - -**Topic Structure**: -- Status: `meshcore/{IATA}/{DEVICE_PUBLIC_KEY}/status` -- Packets: `meshcore/{IATA}/{DEVICE_PUBLIC_KEY}/packets` -- Raw: `meshcore/{IATA}/{DEVICE_PUBLIC_KEY}/raw` - -**Example Topics**: -- `meshcore/SEA/7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C9400/status` -- `meshcore/SEA/7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C9400/packets` - -### Step 9: Troubleshooting +### Troubleshooting #### Device Won't Connect to WiFi -**Via LoRa Repeater Console:** ``` -# Check WiFi settings get wifi.ssid get wifi.pwd -get wifi.powersave - -# Reset WiFi settings -set wifi.ssid "" -set wifi.pwd "" - -# Reconfigure with correct credentials -set wifi.ssid "YourWiFiNetwork" -set wifi.pwd "YourWiFiPassword" - -# If connection issues persist, try disabling power saving for better reliability -set wifi.powersave none +set wifi.powersave none # Try disabling power saving +reboot ``` #### No MQTT Messages Appearing -**Via LoRa Repeater Console:** ``` -# Check bridge status get bridge.enabled - -# Check message types -get mqtt.status -get mqtt.packets - -# Check device identity (origin is set automatically) -get mqtt.origin -get mqtt.iata - -# Enable bridge if needed set bridge.enabled on +get mqtt.status # Check per-slot connection status +get mqtt1.preset # Verify slots are configured +get mqtt.iata # IATA must be set for Analyzer presets ``` #### Timezone Issues -**Via LoRa Repeater Console:** ``` -# Check current timezone get timezone - -# Try different timezone formats -set timezone "America/New_York" # IANA format -set timezone "EST" # Abbreviation -set timezone "UTC-5" # UTC offset +set timezone America/New_York # IANA format +set timezone EST # Abbreviation +set timezone UTC-5 # UTC offset ``` -#### LoRa Configuration Issues -- **Device not responding**: Ensure both devices are on the same mesh network -- **Commands not working**: Check that the target device is reachable via LoRa -- **No response to get commands**: Verify the device is powered and in range - -### Step 10: Advanced Configuration (Optional) - -#### Custom MQTT Broker -If you want to use your own MQTT broker instead of the default: - -``` -# Note: Custom broker configuration requires code modification -# The default broker is: meshtastic.pugetmesh.org:1883 -# Username: meshdev, Password: large4cats -``` - -#### Let's Mesh Analyzer Servers -The device automatically connects to Let's Mesh Analyzer servers for additional monitoring: - -- **US Server**: `mqtt-us-v1.letsmesh.net:443` (WebSocket with TLS) -- **EU Server**: `mqtt-eu-v1.letsmesh.net:443` (WebSocket with TLS) - -These are enabled by default and use JWT authentication with your device's Ed25519 keys. - -## Testing - -1. Flash the MQTT bridge firmware to your device -2. Follow the first-time setup instructions above -3. Monitor MQTT broker for incoming messages -4. Verify message formats match the JSON schemas in this document - ## Dependencies -- **PubSubClient**: MQTT client library -- **ArduinoJson**: JSON message formatting (v6.17.3) +- **PsychicMqttClient**: MQTT client library (supports WSS and direct MQTT) +- **ArduinoJson**: JSON message formatting - **NTPClient**: Network time protocol client - **Timezone**: Timezone conversion library (JChristensen/Timezone) - **WiFi**: ESP32 WiFi functionality - **Ed25519**: Cryptographic library for JWT token signing -- **JWTHelper**: Custom JWT token generation for Let's Mesh Analyzer authentication - -## Future Enhancements - -- Full WebSocket MQTT implementation (currently JWT tokens are generated but WebSocket publishing is pending) -- Multiple broker configuration via CLI -- Advanced packet filtering -- Custom topic templates -- TLS/SSL support for secure connections -- Real-time WebSocket MQTT publishing to Let's Mesh Analyzer servers +- **JWTHelper**: Custom JWT token generation for device authentication diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index abd01a28..fe3b2b0d 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -949,9 +949,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc StrHelper::strncpy(_prefs.timezone_string, "America/Los_Angeles", sizeof(_prefs.timezone_string)); _prefs.timezone_offset = -8; // fallback - // Let's Mesh Analyzer defaults (both enabled by default) - _prefs.mqtt_analyzer_us_enabled = 1; // enabled - _prefs.mqtt_analyzer_eu_enabled = 1; // enabled + // MQTT slot presets (analyzer-us and analyzer-eu enabled by default) + StrHelper::strncpy(_prefs.mqtt_slot_preset[0], "analyzer-us", sizeof(_prefs.mqtt_slot_preset[0])); + StrHelper::strncpy(_prefs.mqtt_slot_preset[1], "analyzer-eu", sizeof(_prefs.mqtt_slot_preset[1])); + StrHelper::strncpy(_prefs.mqtt_slot_preset[2], "none", sizeof(_prefs.mqtt_slot_preset[2])); _prefs.adc_multiplier = 0.0f; // 0.0f means use default board multiplier diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index ea9a0527..3cbf4a6b 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -681,9 +681,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc StrHelper::strncpy(_prefs.timezone_string, "America/Los_Angeles", sizeof(_prefs.timezone_string)); _prefs.timezone_offset = -8; // fallback - // Let's Mesh Analyzer defaults (same as repeater - both enabled by default) - _prefs.mqtt_analyzer_us_enabled = 1; // enabled - _prefs.mqtt_analyzer_eu_enabled = 1; // enabled + // MQTT slot presets (analyzer-us and analyzer-eu enabled by default) + StrHelper::strncpy(_prefs.mqtt_slot_preset[0], "analyzer-us", sizeof(_prefs.mqtt_slot_preset[0])); + StrHelper::strncpy(_prefs.mqtt_slot_preset[1], "analyzer-eu", sizeof(_prefs.mqtt_slot_preset[1])); + StrHelper::strncpy(_prefs.mqtt_slot_preset[2], "none", sizeof(_prefs.mqtt_slot_preset[2])); next_post_idx = 0; next_client_idx = 0; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 54ab0766..4c578359 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -22,11 +22,10 @@ static size_t getMQTTFieldsSize(const NodePrefs* prefs) { sizeof(prefs->mqtt_raw_enabled) + sizeof(prefs->mqtt_tx_enabled) + sizeof(prefs->mqtt_status_interval) + sizeof(prefs->wifi_ssid) + sizeof(prefs->wifi_password) + sizeof(prefs->timezone_string) + - sizeof(prefs->timezone_offset) + sizeof(prefs->mqtt_server) + - sizeof(prefs->mqtt_port) + sizeof(prefs->mqtt_username) + - sizeof(prefs->mqtt_password) + sizeof(prefs->mqtt_analyzer_us_enabled) + - sizeof(prefs->mqtt_analyzer_eu_enabled) + sizeof(prefs->mqtt_owner_public_key) + - sizeof(prefs->mqtt_email); + sizeof(prefs->timezone_offset) + sizeof(prefs->mqtt_slot_preset) + + sizeof(prefs->mqtt_slot_host) + sizeof(prefs->mqtt_slot_port) + + sizeof(prefs->mqtt_slot_username) + sizeof(prefs->mqtt_slot_password) + + sizeof(prefs->mqtt_owner_public_key) + sizeof(prefs->mqtt_email); } #endif @@ -141,17 +140,16 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { size_t mqtt_fields_size = getMQTTFieldsSize(_prefs); #else // If MQTT bridge not enabled, still skip these fields for file format compatibility - size_t mqtt_fields_size = + size_t mqtt_fields_size = sizeof(_prefs->mqtt_origin) + sizeof(_prefs->mqtt_iata) + sizeof(_prefs->mqtt_status_enabled) + sizeof(_prefs->mqtt_packets_enabled) + sizeof(_prefs->mqtt_raw_enabled) + sizeof(_prefs->mqtt_tx_enabled) + sizeof(_prefs->mqtt_status_interval) + sizeof(_prefs->wifi_ssid) + sizeof(_prefs->wifi_password) + sizeof(_prefs->timezone_string) + - sizeof(_prefs->timezone_offset) + sizeof(_prefs->mqtt_server) + - sizeof(_prefs->mqtt_port) + sizeof(_prefs->mqtt_username) + - sizeof(_prefs->mqtt_password) + sizeof(_prefs->mqtt_analyzer_us_enabled) + - sizeof(_prefs->mqtt_analyzer_eu_enabled) + sizeof(_prefs->mqtt_owner_public_key) + - sizeof(_prefs->mqtt_email); + sizeof(_prefs->timezone_offset) + sizeof(_prefs->mqtt_slot_preset) + + sizeof(_prefs->mqtt_slot_host) + sizeof(_prefs->mqtt_slot_port) + + sizeof(_prefs->mqtt_slot_username) + sizeof(_prefs->mqtt_slot_password) + + sizeof(_prefs->mqtt_owner_public_key) + sizeof(_prefs->mqtt_email); #endif uint8_t skip_buffer[512]; // Large enough buffer size_t remaining = mqtt_fields_size; @@ -255,17 +253,16 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { size_t mqtt_fields_size = getMQTTFieldsSize(_prefs); #else // If MQTT bridge not enabled, still write zeros for file format compatibility - size_t mqtt_fields_size = + size_t mqtt_fields_size = sizeof(_prefs->mqtt_origin) + sizeof(_prefs->mqtt_iata) + sizeof(_prefs->mqtt_status_enabled) + sizeof(_prefs->mqtt_packets_enabled) + sizeof(_prefs->mqtt_raw_enabled) + sizeof(_prefs->mqtt_tx_enabled) + sizeof(_prefs->mqtt_status_interval) + sizeof(_prefs->wifi_ssid) + sizeof(_prefs->wifi_password) + sizeof(_prefs->timezone_string) + - sizeof(_prefs->timezone_offset) + sizeof(_prefs->mqtt_server) + - sizeof(_prefs->mqtt_port) + sizeof(_prefs->mqtt_username) + - sizeof(_prefs->mqtt_password) + sizeof(_prefs->mqtt_analyzer_us_enabled) + - sizeof(_prefs->mqtt_analyzer_eu_enabled) + sizeof(_prefs->mqtt_owner_public_key) + - sizeof(_prefs->mqtt_email); + sizeof(_prefs->timezone_offset) + sizeof(_prefs->mqtt_slot_preset) + + sizeof(_prefs->mqtt_slot_host) + sizeof(_prefs->mqtt_slot_port) + + sizeof(_prefs->mqtt_slot_username) + sizeof(_prefs->mqtt_slot_password) + + sizeof(_prefs->mqtt_owner_public_key) + sizeof(_prefs->mqtt_email); #endif memset(pad, 0, sizeof(pad)); size_t remaining = mqtt_fields_size; @@ -294,8 +291,13 @@ static void setMQTTPrefsDefaults(MQTTPrefs* prefs) { prefs->mqtt_raw_enabled = 0; // disabled by default prefs->mqtt_tx_enabled = 0; // disabled by default (RX only) prefs->mqtt_status_interval = 300000; // 5 minutes default - prefs->mqtt_analyzer_us_enabled = 1; // enabled by default - prefs->mqtt_analyzer_eu_enabled = 1; // enabled by default + // Slot presets: analyzer-us and analyzer-eu enabled by default, slot 3 = none + strncpy(prefs->mqtt_slot_preset[0], "analyzer-us", sizeof(prefs->mqtt_slot_preset[0]) - 1); + prefs->mqtt_slot_preset[0][sizeof(prefs->mqtt_slot_preset[0]) - 1] = '\0'; + strncpy(prefs->mqtt_slot_preset[1], "analyzer-eu", sizeof(prefs->mqtt_slot_preset[1]) - 1); + prefs->mqtt_slot_preset[1][sizeof(prefs->mqtt_slot_preset[1]) - 1] = '\0'; + strncpy(prefs->mqtt_slot_preset[2], "none", sizeof(prefs->mqtt_slot_preset[2]) - 1); + prefs->mqtt_slot_preset[2][sizeof(prefs->mqtt_slot_preset[2]) - 1] = '\0'; #ifdef MQTT_WIFI_POWER_SAVE_DEFAULT prefs->wifi_power_save = MQTT_WIFI_POWER_SAVE_DEFAULT; // 0=min, 1=none, 2=max #else @@ -329,6 +331,57 @@ void CommonCLI::loadMQTTPrefs(FILESYSTEM* fs) { setMQTTPrefsDefaults(&_mqtt_prefs); } file.close(); + + // Migration: check if legacy fields have data but new slot fields are empty + bool slots_empty = (_mqtt_prefs.mqtt_slot_preset[0][0] == '\0' && + _mqtt_prefs.mqtt_slot_preset[1][0] == '\0' && + _mqtt_prefs.mqtt_slot_preset[2][0] == '\0'); + bool has_legacy = (_mqtt_prefs._legacy_analyzer_us_enabled != 0 || + _mqtt_prefs._legacy_analyzer_eu_enabled != 0 || + _mqtt_prefs._legacy_mqtt_server[0] != '\0'); + if (slots_empty && has_legacy) { + MESH_DEBUG_PRINTLN("MQTT: Migrating legacy prefs to slot-based presets"); + // Migrate analyzer US + if (_mqtt_prefs._legacy_analyzer_us_enabled == 1) { + strncpy(_mqtt_prefs.mqtt_slot_preset[0], "analyzer-us", sizeof(_mqtt_prefs.mqtt_slot_preset[0]) - 1); + _mqtt_prefs.mqtt_slot_preset[0][sizeof(_mqtt_prefs.mqtt_slot_preset[0]) - 1] = '\0'; + } else { + strncpy(_mqtt_prefs.mqtt_slot_preset[0], "none", sizeof(_mqtt_prefs.mqtt_slot_preset[0]) - 1); + _mqtt_prefs.mqtt_slot_preset[0][sizeof(_mqtt_prefs.mqtt_slot_preset[0]) - 1] = '\0'; + } + // Migrate analyzer EU + if (_mqtt_prefs._legacy_analyzer_eu_enabled == 1) { + strncpy(_mqtt_prefs.mqtt_slot_preset[1], "analyzer-eu", sizeof(_mqtt_prefs.mqtt_slot_preset[1]) - 1); + _mqtt_prefs.mqtt_slot_preset[1][sizeof(_mqtt_prefs.mqtt_slot_preset[1]) - 1] = '\0'; + } else { + strncpy(_mqtt_prefs.mqtt_slot_preset[1], "none", sizeof(_mqtt_prefs.mqtt_slot_preset[1]) - 1); + _mqtt_prefs.mqtt_slot_preset[1][sizeof(_mqtt_prefs.mqtt_slot_preset[1]) - 1] = '\0'; + } + // Migrate custom server to slot 3 + if (_mqtt_prefs._legacy_mqtt_server[0] != '\0' && _mqtt_prefs._legacy_mqtt_port > 0) { + strncpy(_mqtt_prefs.mqtt_slot_preset[2], "custom", sizeof(_mqtt_prefs.mqtt_slot_preset[2]) - 1); + _mqtt_prefs.mqtt_slot_preset[2][sizeof(_mqtt_prefs.mqtt_slot_preset[2]) - 1] = '\0'; + strncpy(_mqtt_prefs.mqtt_slot_host[2], _mqtt_prefs._legacy_mqtt_server, sizeof(_mqtt_prefs.mqtt_slot_host[2]) - 1); + _mqtt_prefs.mqtt_slot_host[2][sizeof(_mqtt_prefs.mqtt_slot_host[2]) - 1] = '\0'; + _mqtt_prefs.mqtt_slot_port[2] = _mqtt_prefs._legacy_mqtt_port; + strncpy(_mqtt_prefs.mqtt_slot_username[2], _mqtt_prefs._legacy_mqtt_username, sizeof(_mqtt_prefs.mqtt_slot_username[2]) - 1); + _mqtt_prefs.mqtt_slot_username[2][sizeof(_mqtt_prefs.mqtt_slot_username[2]) - 1] = '\0'; + strncpy(_mqtt_prefs.mqtt_slot_password[2], _mqtt_prefs._legacy_mqtt_password, sizeof(_mqtt_prefs.mqtt_slot_password[2]) - 1); + _mqtt_prefs.mqtt_slot_password[2][sizeof(_mqtt_prefs.mqtt_slot_password[2]) - 1] = '\0'; + } else { + strncpy(_mqtt_prefs.mqtt_slot_preset[2], "none", sizeof(_mqtt_prefs.mqtt_slot_preset[2]) - 1); + _mqtt_prefs.mqtt_slot_preset[2][sizeof(_mqtt_prefs.mqtt_slot_preset[2]) - 1] = '\0'; + } + // Clear legacy fields + _mqtt_prefs._legacy_analyzer_us_enabled = 0; + _mqtt_prefs._legacy_analyzer_eu_enabled = 0; + memset(_mqtt_prefs._legacy_mqtt_server, 0, sizeof(_mqtt_prefs._legacy_mqtt_server)); + _mqtt_prefs._legacy_mqtt_port = 0; + memset(_mqtt_prefs._legacy_mqtt_username, 0, sizeof(_mqtt_prefs._legacy_mqtt_username)); + memset(_mqtt_prefs._legacy_mqtt_password, 0, sizeof(_mqtt_prefs._legacy_mqtt_password)); + // Save migrated prefs + saveMQTTPrefs(fs); + } } } else { // Migration: Try to read from old /com_prefs file if it exists @@ -412,12 +465,14 @@ void CommonCLI::syncMQTTPrefsToNodePrefs() { _prefs->wifi_power_save = _mqtt_prefs.wifi_power_save; StrHelper::strncpy(_prefs->timezone_string, _mqtt_prefs.timezone_string, sizeof(_prefs->timezone_string)); _prefs->timezone_offset = _mqtt_prefs.timezone_offset; - StrHelper::strncpy(_prefs->mqtt_server, _mqtt_prefs.mqtt_server, sizeof(_prefs->mqtt_server)); - _prefs->mqtt_port = _mqtt_prefs.mqtt_port; - StrHelper::strncpy(_prefs->mqtt_username, _mqtt_prefs.mqtt_username, sizeof(_prefs->mqtt_username)); - StrHelper::strncpy(_prefs->mqtt_password, _mqtt_prefs.mqtt_password, sizeof(_prefs->mqtt_password)); - _prefs->mqtt_analyzer_us_enabled = _mqtt_prefs.mqtt_analyzer_us_enabled; - _prefs->mqtt_analyzer_eu_enabled = _mqtt_prefs.mqtt_analyzer_eu_enabled; + // Slot-based fields + for (int i = 0; i < 3; i++) { + StrHelper::strncpy(_prefs->mqtt_slot_preset[i], _mqtt_prefs.mqtt_slot_preset[i], sizeof(_prefs->mqtt_slot_preset[i])); + StrHelper::strncpy(_prefs->mqtt_slot_host[i], _mqtt_prefs.mqtt_slot_host[i], sizeof(_prefs->mqtt_slot_host[i])); + _prefs->mqtt_slot_port[i] = _mqtt_prefs.mqtt_slot_port[i]; + StrHelper::strncpy(_prefs->mqtt_slot_username[i], _mqtt_prefs.mqtt_slot_username[i], sizeof(_prefs->mqtt_slot_username[i])); + StrHelper::strncpy(_prefs->mqtt_slot_password[i], _mqtt_prefs.mqtt_slot_password[i], sizeof(_prefs->mqtt_slot_password[i])); + } StrHelper::strncpy(_prefs->mqtt_owner_public_key, _mqtt_prefs.mqtt_owner_public_key, sizeof(_prefs->mqtt_owner_public_key)); StrHelper::strncpy(_prefs->mqtt_email, _mqtt_prefs.mqtt_email, sizeof(_prefs->mqtt_email)); } @@ -437,12 +492,14 @@ void CommonCLI::syncNodePrefsToMQTTPrefs() { _mqtt_prefs.wifi_power_save = _prefs->wifi_power_save; StrHelper::strncpy(_mqtt_prefs.timezone_string, _prefs->timezone_string, sizeof(_mqtt_prefs.timezone_string)); _mqtt_prefs.timezone_offset = _prefs->timezone_offset; - StrHelper::strncpy(_mqtt_prefs.mqtt_server, _prefs->mqtt_server, sizeof(_mqtt_prefs.mqtt_server)); - _mqtt_prefs.mqtt_port = _prefs->mqtt_port; - StrHelper::strncpy(_mqtt_prefs.mqtt_username, _prefs->mqtt_username, sizeof(_mqtt_prefs.mqtt_username)); - StrHelper::strncpy(_mqtt_prefs.mqtt_password, _prefs->mqtt_password, sizeof(_mqtt_prefs.mqtt_password)); - _mqtt_prefs.mqtt_analyzer_us_enabled = _prefs->mqtt_analyzer_us_enabled; - _mqtt_prefs.mqtt_analyzer_eu_enabled = _prefs->mqtt_analyzer_eu_enabled; + // Slot-based fields + for (int i = 0; i < 3; i++) { + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_preset[i], _prefs->mqtt_slot_preset[i], sizeof(_mqtt_prefs.mqtt_slot_preset[i])); + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_host[i], _prefs->mqtt_slot_host[i], sizeof(_mqtt_prefs.mqtt_slot_host[i])); + _mqtt_prefs.mqtt_slot_port[i] = _prefs->mqtt_slot_port[i]; + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_username[i], _prefs->mqtt_slot_username[i], sizeof(_mqtt_prefs.mqtt_slot_username[i])); + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_password[i], _prefs->mqtt_slot_password[i], sizeof(_mqtt_prefs.mqtt_slot_password[i])); + } StrHelper::strncpy(_mqtt_prefs.mqtt_owner_public_key, _prefs->mqtt_owner_public_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); StrHelper::strncpy(_mqtt_prefs.mqtt_email, _prefs->mqtt_email, sizeof(_mqtt_prefs.mqtt_email)); } @@ -687,14 +744,24 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch // Display interval in minutes (rounded) uint32_t minutes = (_prefs->mqtt_status_interval + 29999) / 60000; // Round up sprintf(reply, "> %u minutes (%lu ms)", minutes, _prefs->mqtt_status_interval); - } else if (memcmp(config, "mqtt.server", 11) == 0) { - sprintf(reply, "> %s", _prefs->mqtt_server); - } else if (memcmp(config, "mqtt.port", 9) == 0) { - sprintf(reply, "> %d", _prefs->mqtt_port); - } else if (memcmp(config, "mqtt.username", 13) == 0) { - sprintf(reply, "> %s", _prefs->mqtt_username); - } else if (memcmp(config, "mqtt.password", 13) == 0) { - sprintf(reply, "> %s", _prefs->mqtt_password); + } else if (config[0] == 'm' && config[1] == 'q' && config[2] == 't' && config[3] == 't' && + config[4] >= '1' && config[4] <= '3' && config[5] == '.') { + // Slot-based commands: get mqtt1.preset, get mqtt1.server, etc. + int slot = config[4] - '1'; // 0-2 + const char* subcmd = &config[6]; + if (memcmp(subcmd, "preset", 6) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_slot_preset[slot]); + } else if (memcmp(subcmd, "server", 6) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_slot_host[slot]); + } else if (memcmp(subcmd, "port", 4) == 0) { + sprintf(reply, "> %d", _prefs->mqtt_slot_port[slot]); + } else if (memcmp(subcmd, "username", 8) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_slot_username[slot]); + } else if (memcmp(subcmd, "password", 8) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_slot_password[slot]); + } else { + sprintf(reply, "??: %s", config); + } } else if (memcmp(config, "wifi.ssid", 9) == 0) { sprintf(reply, "> %s", _prefs->wifi_ssid); } else if (memcmp(config, "wifi.pwd", 8) == 0) { @@ -745,10 +812,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch sprintf(reply, "> %s", _prefs->timezone_string); } else if (memcmp(config, "timezone.offset", 15) == 0) { sprintf(reply, "> %d", _prefs->timezone_offset); - } else if (memcmp(config, "mqtt.analyzer.us", 17) == 0) { - sprintf(reply, "> %s", _prefs->mqtt_analyzer_us_enabled ? "on" : "off"); - } else if (memcmp(config, "mqtt.analyzer.eu", 17) == 0) { - sprintf(reply, "> %s", _prefs->mqtt_analyzer_eu_enabled ? "on" : "off"); } else if (sender_timestamp == 0 && memcmp(config, "mqtt.owner", 10) == 0) { // from serial command line only if (_prefs->mqtt_owner_public_key[0] != '\0') { sprintf(reply, "> %s", _prefs->mqtt_owner_public_key); @@ -761,9 +824,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { strcpy(reply, "> (not set)"); } - } else if (memcmp(config, "mqtt.config.valid", 17) == 0) { - bool valid = MQTTBridge::isConfigValid(_prefs); - sprintf(reply, "> %s", valid ? "valid" : "invalid"); #endif } else if (memcmp(config, "bootloader.ver", 14) == 0) { #ifdef NRF52_PLATFORM @@ -1157,35 +1217,48 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { strcpy(reply, "Error: timezone offset must be between -12 and +14"); } - } else if (memcmp(config, "mqtt.server ", 12) == 0) { - StrHelper::strncpy(_prefs->mqtt_server, &config[12], sizeof(_prefs->mqtt_server)); - savePrefs(); - strcpy(reply, "OK"); - } else if (memcmp(config, "mqtt.port ", 10) == 0) { - int port = atoi(&config[10]); - if (port > 0 && port <= 65535) { - _prefs->mqtt_port = port; + } else if (config[0] == 'm' && config[1] == 'q' && config[2] == 't' && config[3] == 't' && + config[4] >= '1' && config[4] <= '3' && config[5] == '.') { + // Slot-based commands: set mqtt1.preset , set mqtt1.server , etc. + int slot = config[4] - '1'; // 0-2 + const char* subcmd = &config[6]; + if (memcmp(subcmd, "preset ", 7) == 0) { + const char* preset_name = &subcmd[7]; + // Validate preset name + if (findMQTTPreset(preset_name) != nullptr || + strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0 || + strcmp(preset_name, MQTT_PRESET_NONE) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_preset[slot], preset_name, sizeof(_prefs->mqtt_slot_preset[slot])); + savePrefs(); + _callbacks->restartBridge(); + sprintf(reply, "OK - slot %d preset: %s", slot + 1, preset_name); + } else { + strcpy(reply, "Error: valid presets are: analyzer-us, analyzer-eu, meshmapper, custom, none"); + } + } else if (memcmp(subcmd, "server ", 7) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_host[slot], &subcmd[7], sizeof(_prefs->mqtt_slot_host[slot])); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(subcmd, "port ", 5) == 0) { + int port = atoi(&subcmd[5]); + if (port > 0 && port <= 65535) { + _prefs->mqtt_slot_port[slot] = port; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: port must be between 1 and 65535"); + } + } else if (memcmp(subcmd, "username ", 9) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_username[slot], &subcmd[9], sizeof(_prefs->mqtt_slot_username[slot])); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(subcmd, "password ", 9) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_password[slot], &subcmd[9], sizeof(_prefs->mqtt_slot_password[slot])); savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error: port must be between 1 and 65535"); + sprintf(reply, "unknown config: %s", config); } - } else if (memcmp(config, "mqtt.username ", 14) == 0) { - StrHelper::strncpy(_prefs->mqtt_username, &config[14], sizeof(_prefs->mqtt_username)); - savePrefs(); - strcpy(reply, "OK"); - } else if (memcmp(config, "mqtt.password ", 14) == 0) { - StrHelper::strncpy(_prefs->mqtt_password, &config[14], sizeof(_prefs->mqtt_password)); - savePrefs(); - strcpy(reply, "OK"); - } else if (memcmp(config, "mqtt.analyzer.us ", 17) == 0) { - _prefs->mqtt_analyzer_us_enabled = memcmp(&config[17], "on", 2) == 0; - savePrefs(); - strcpy(reply, "OK"); - } else if (memcmp(config, "mqtt.analyzer.eu ", 17) == 0) { - _prefs->mqtt_analyzer_eu_enabled = memcmp(&config[17], "on", 2) == 0; - savePrefs(); - strcpy(reply, "OK"); } else if (memcmp(config, "mqtt.owner ", 11) == 0) { // Validate that it's a valid hex string of the correct length (64 hex chars = 32 bytes) const char* owner_key = &config[11]; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 71524795..6edeb3ef 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -77,18 +77,19 @@ struct NodePrefs { // persisted to file char timezone_string[32]; // Timezone string (e.g., "America/Los_Angeles") int8_t timezone_offset; // Timezone offset in hours (-12 to +14) - fallback - // MQTT server settings - char mqtt_server[64]; // MQTT server hostname - uint16_t mqtt_port; // MQTT server port - char mqtt_username[32]; // MQTT username - char mqtt_password[64]; // MQTT password - - // Let's Mesh Analyzer settings - uint8_t mqtt_analyzer_us_enabled; // Enable US analyzer server - uint8_t mqtt_analyzer_eu_enabled; // Enable EU analyzer server + // MQTT slot presets (3 slots, each can be a preset name or "custom"/"none") + char mqtt_slot_preset[3][24]; // e.g. "analyzer-us", "meshmapper", "custom", "none" + + // Per-slot custom broker settings (only used when slot preset is "custom") + char mqtt_slot_host[3][64]; + uint16_t mqtt_slot_port[3]; + char mqtt_slot_username[3][32]; + char mqtt_slot_password[3][64]; + + // Shared MQTT authentication char mqtt_owner_public_key[65]; // Owner public key (hex string, same length as repeater public key) char mqtt_email[64]; // Owner email address for matching nodes with owners - + uint8_t loop_detect; }; @@ -103,27 +104,38 @@ struct MQTTPrefs { uint8_t mqtt_raw_enabled; // Enable raw messages uint8_t mqtt_tx_enabled; // Enable TX packet uplinking uint32_t mqtt_status_interval; // Status publish interval (ms) - + // WiFi settings char wifi_ssid[32]; // WiFi SSID char wifi_password[64]; // WiFi password uint8_t wifi_power_save; // WiFi power save mode: 0=min, 1=none, 2=max (default: 0=min) - + // Timezone settings char timezone_string[32]; // Timezone string (e.g., "America/Los_Angeles") int8_t timezone_offset; // Timezone offset in hours (-12 to +14) - fallback - - // MQTT server settings - char mqtt_server[64]; // MQTT server hostname - uint16_t mqtt_port; // MQTT server port - char mqtt_username[32]; // MQTT username - char mqtt_password[64]; // MQTT password - - // Let's Mesh Analyzer settings - uint8_t mqtt_analyzer_us_enabled; // Enable US analyzer server - uint8_t mqtt_analyzer_eu_enabled; // Enable EU analyzer server - char mqtt_owner_public_key[65]; // Owner public key (hex string, same length as repeater public key) - char mqtt_email[64]; // Owner email address for matching nodes with owners + + // Slot presets (3 slots) + char mqtt_slot_preset[3][24]; // e.g. "analyzer-us", "meshmapper", "custom", "none" + + // Per-slot custom broker settings (only used when preset is "custom") + char mqtt_slot_host[3][64]; + uint16_t mqtt_slot_port[3]; + char mqtt_slot_username[3][32]; + char mqtt_slot_password[3][64]; + + // Shared authentication + char mqtt_owner_public_key[65]; // Owner public key (hex string) + char mqtt_email[64]; // Owner email address + + // --- Legacy fields for migration detection --- + // These are read during loadMQTTPrefs to detect old-format prefs and auto-migrate. + // After migration they are zeroed out and not used again. + uint8_t _legacy_analyzer_us_enabled; + uint8_t _legacy_analyzer_eu_enabled; + char _legacy_mqtt_server[64]; + uint16_t _legacy_mqtt_port; + char _legacy_mqtt_username[32]; + char _legacy_mqtt_password[64]; }; #endif diff --git a/src/helpers/MQTTPresets.h b/src/helpers/MQTTPresets.h new file mode 100644 index 00000000..46b0c4c3 --- /dev/null +++ b/src/helpers/MQTTPresets.h @@ -0,0 +1,105 @@ +#pragma once + +#ifdef WITH_MQTT_BRIDGE + +enum MQTTAuthType : uint8_t { + MQTT_AUTH_NONE, // No authentication + MQTT_AUTH_USERPASS, // Username/password + MQTT_AUTH_JWT // Ed25519-signed JWT (device identity) +}; + +struct MQTTPresetDef { + const char* name; // Preset identifier: "analyzer-us", "analyzer-eu", "meshmapper" + const char* server_url; // Full URL including scheme: "wss://host:port/path" or "mqtt://host:port" + const char* jwt_audience; // JWT audience field (only for MQTT_AUTH_JWT) + const char* ca_cert; // PEM CA certificate (nullptr to skip cert pinning) + MQTTAuthType auth_type; +}; + +// Google Trust Services - GTS Root R4 (used by LetsMesh Analyzer) +static const char GTS_ROOT_R4[] PROGMEM = + "-----BEGIN CERTIFICATE-----\n" + "MIIDejCCAmKgAwIBAgIQf+UwvzMTQ77dghYQST2KGzANBgkqhkiG9w0BAQsFADBX\n" + "MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEQMA4GA1UE\n" + "CxMHUm9vdCBDQTEbMBkGA1UEAxMSR2xvYmFsU2lnbiBSb290IENBMB4XDTIzMTEx\n" + "NTAzNDMyMVoXDTI4MDEyODAwMDA0MlowRzELMAkGA1UEBhMCVVMxIjAgBgNVBAoT\n" + "GUdvb2dsZSBUcnVzdCBTZXJ2aWNlcyBMTEMxFDASBgNVBAMTC0dUUyBSb290IFI0\n" + "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE83Rzp2iLYK5DuDXFgTB7S0md+8Fhzube\n" + "Rr1r1WEYNa5A3XP3iZEwWus87oV8okB2O6nGuEfYKueSkWpz6bFyOZ8pn6KY019e\n" + "WIZlD6GEZQbR3IvJx3PIjGov5cSr0R2Ko4H/MIH8MA4GA1UdDwEB/wQEAwIBhjAd\n" + "BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zAd\n" + "BgNVHQ4EFgQUgEzW63T/STaj1dj8tT7FavCUHYwwHwYDVR0jBBgwFoAUYHtmGkUN\n" + "l8qJUC99BM00qP/8/UswNgYIKwYBBQUHAQEEKjAoMCYGCCsGAQUFBzAChhpodHRw\n" + "Oi8vaS5wa2kuZ29vZy9nc3IxLmNydDAtBgNVHR8EJjAkMCKgIKAehhxodHRwOi8v\n" + "Yy5wa2kuZ29vZy9yL2dzcjEuY3JsMBMGA1UdIAQMMAowCAYGZ4EMAQIBMA0GCSqG\n" + "SIb3DQEBCwUAA4IBAQAYQrsPBtYDh5bjP2OBDwmkoWhIDDkic574y04tfzHpn+cJ\n" + "odI2D4SseesQ6bDrarZ7C30ddLibZatoKiws3UL9xnELz4ct92vID24FfVbiI1hY\n" + "+SW6FoVHkNeWIP0GCbaM4C6uVdF5dTUsMVs/ZbzNnIdCp5Gxmx5ejvEau8otR/Cs\n" + "kGN+hr/W5GvT1tMBjgWKZ1i4//emhA1JG1BbPzoLJQvyEotc03lXjTaCzv8mEbep\n" + "8RqZ7a2CPsgRbuvTPBwcOMBBmuFeU88+FSBX6+7iP0il8b4Z0QFqIwwMHfs/L6K1\n" + "vepuoxtGzi4CZ68zJpiq1UvSqTbFJjtbD4seiMHl\n" + "-----END CERTIFICATE-----\n"; + +// ISRG Root X1 (used by MeshMapper - Let's Encrypt root CA) +static const char ISRG_ROOT_X1[] PROGMEM = + "-----BEGIN CERTIFICATE-----\n" + "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" + "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" + "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" + "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" + "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" + "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" + "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" + "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" + "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" + "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" + "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" + "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" + "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" + "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" + "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" + "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" + "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" + "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" + "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" + "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" + "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" + "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" + "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" + "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" + "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" + "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" + "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" + "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" + "-----END CERTIFICATE-----\n"; + +// Number of built-in presets +static const int MQTT_PRESET_COUNT = 3; + +// Built-in preset definitions (stored in flash) +static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = { + { "analyzer-us", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", "mqtt-us-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT }, + { "analyzer-eu", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", "mqtt-eu-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT }, + { "meshmapper", "wss://mqtt.meshmapper.cc:443/mqtt", "mqtt.meshmapper.cc", ISRG_ROOT_X1, MQTT_AUTH_JWT }, +}; + +// Find a preset by name, returns nullptr if not found +static const MQTTPresetDef* findMQTTPreset(const char* name) { + if (!name || name[0] == '\0') return nullptr; + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + if (strcmp(name, MQTT_PRESETS[i].name) == 0) { + return &MQTT_PRESETS[i]; + } + } + return nullptr; +} + +// Maximum number of concurrent MQTT connection slots +static const int MAX_MQTT_SLOTS = 3; + +// Slot preset name constants +static const char MQTT_PRESET_NONE[] = "none"; +static const char MQTT_PRESET_CUSTOM[] = "custom"; + +#endif diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 287bc5f0..02f15db1 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -16,16 +16,16 @@ // Helper function to strip quotes from strings (both single and double quotes) static void stripQuotes(char* str, size_t max_len) { if (!str || max_len == 0) return; - + size_t len = strlen(str); if (len == 0) return; - + // Remove leading quote (single or double) if (str[0] == '"' || str[0] == '\'') { memmove(str, str + 1, len); len--; } - + // Remove trailing quote (single or double) if (len > 0 && (str[len-1] == '"' || str[len-1] == '\'')) { str[len-1] = '\0'; @@ -38,9 +38,9 @@ static bool isWiFiConfigValid(const NodePrefs* prefs) { if (strlen(prefs->wifi_ssid) == 0) { return false; } - + // WiFi password can be empty for open networks, so we don't check it - + return true; } @@ -90,10 +90,6 @@ static void agentLogHeap(const char* location, const char* message, const char* // Singleton for formatMqttStatusReply (set in begin(), cleared in end()) static MQTTBridge* s_mqtt_bridge_instance = nullptr; -// Only force-disconnect main broker after this many consecutive publish failures (reduces heap fragmentation from disconnect/reconnect storms) -static const int MAIN_CLIENT_DISCONNECT_FAILURE_THRESHOLD = 3; -static int s_consecutive_main_publish_failures = 0; - unsigned long MQTTBridge::getWifiConnectedAtMillis() { return s_wifi_connected_at; } @@ -106,18 +102,24 @@ void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const NodePref return; } MQTTBridge* b = s_mqtt_bridge_instance; - const char* broker = "n/a"; - if (b->_config_valid) { - broker = b->_cached_has_brokers ? "connected" : "disconnected"; - } - const char* us = "off"; - if (prefs->mqtt_analyzer_us_enabled) { - us = (b->_analyzer_us_client && b->_analyzer_us_client->connected()) ? "connected" : "disconnected"; - } - const char* eu = "off"; - if (prefs->mqtt_analyzer_eu_enabled) { - eu = (b->_analyzer_eu_client && b->_analyzer_eu_client->connected()) ? "connected" : "disconnected"; + + // Build per-slot status strings + char slot_info[3][48]; + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + const MQTTSlot& slot = b->_slots[i]; + if (!slot.enabled) { + snprintf(slot_info[i], sizeof(slot_info[i]), "slot%d: none", i + 1); + } else if (slot.preset) { + snprintf(slot_info[i], sizeof(slot_info[i]), "slot%d: %s (%s)", i + 1, + slot.preset->name, + slot.connected ? "connected" : "disconnected"); + } else { + // Custom broker + snprintf(slot_info[i], sizeof(slot_info[i]), "slot%d: custom (%s)", i + 1, + slot.connected ? "connected" : "disconnected"); + } } + int q = 0; #ifdef ESP_PLATFORM if (b->_packet_queue_handle != nullptr) { @@ -126,32 +128,34 @@ void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const NodePref #else q = b->_queue_count; #endif - snprintf(buf, bufsize, "> msgs: %s, broker: %s, us: %s, eu: %s, queue: %d", - msgs, broker, us, eu, q); + snprintf(buf, bufsize, "> msgs: %s, %s, %s, %s, queue: %d", + msgs, slot_info[0], slot_info[1], slot_info[2], q); } +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- MQTTBridge::MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity) - : BridgeBase(prefs, mgr, rtc), _mqtt_client(nullptr), - _active_brokers(0), _queue_count(0), - _last_status_publish(0), _last_status_retry(0), _status_interval(300000), // 5 minutes default - _ntp_client(_ntp_udp, "pool.ntp.org", 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), - _timezone(nullptr), _last_raw_len(0), _last_snr(0), _last_rssi(0), _last_raw_timestamp(0), - _analyzer_us_enabled(false), _analyzer_eu_enabled(false), _identity(identity), - _analyzer_us_client(nullptr), _analyzer_eu_client(nullptr), _config_valid(false), - _cached_has_brokers(false), _cached_has_analyzer_servers(false), - _last_memory_check(0), _skipped_publishes(0), _last_fragmentation_recovery(0), - _fragmentation_pressure_since(0), _last_critical_check_run(0), - _last_no_broker_log(0), _last_config_warning(0), _dispatcher(nullptr), _radio(nullptr), _board(nullptr), _ms(nullptr), - _last_wifi_check(0), _last_wifi_status(WL_DISCONNECTED), _wifi_status_initialized(false), - _wifi_disconnected_time(0), _last_wifi_reconnect_attempt(0), _wifi_reconnect_backoff_attempt(0), - _main_broker_reconnect_backoff_attempt(0), _analyzer_us_reconnect_backoff_attempt(0), _analyzer_eu_reconnect_backoff_attempt(0) + : BridgeBase(prefs, mgr, rtc), + _queue_count(0), + _last_status_publish(0), _last_status_retry(0), _status_interval(300000), + _ntp_client(_ntp_udp, "pool.ntp.org", 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), + _timezone(nullptr), _last_raw_len(0), _last_snr(0), _last_rssi(0), _last_raw_timestamp(0), + _identity(identity), + _cached_has_connected_slots(false), + _last_memory_check(0), _skipped_publishes(0), _last_fragmentation_recovery(0), + _fragmentation_pressure_since(0), _last_critical_check_run(0), + _last_no_broker_log(0), _last_config_warning(0), + _dispatcher(nullptr), _radio(nullptr), _board(nullptr), _ms(nullptr), + _last_wifi_check(0), _last_wifi_status(WL_DISCONNECTED), _wifi_status_initialized(false), + _wifi_disconnected_time(0), _last_wifi_reconnect_attempt(0), _wifi_reconnect_backoff_attempt(0) #ifdef ESP_PLATFORM - , _packet_queue_handle(nullptr), _mqtt_task_handle(nullptr), _raw_data_mutex(nullptr), _mqtt_task_stack(nullptr), _packet_queue_storage(nullptr) + , _packet_queue_handle(nullptr), _mqtt_task_handle(nullptr), _raw_data_mutex(nullptr), + _mqtt_task_stack(nullptr), _packet_queue_storage(nullptr) #else - , _queue_head(0), _queue_tail(0) + , _queue_head(0), _queue_tail(0) #endif { - // Initialize default values strncpy(_origin, "MeshCore-Repeater", sizeof(_origin) - 1); strncpy(_iata, "XXX", sizeof(_iata) - 1); @@ -162,28 +166,28 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCCloc _status_enabled = true; _packets_enabled = true; _raw_enabled = false; - _tx_enabled = false; // Disable TX packets by default - - // Initialize MQTT server settings with defaults (empty/null values) - _prefs->mqtt_server[0] = '\0'; // Empty string - _prefs->mqtt_port = 0; // Invalid port - _prefs->mqtt_username[0] = '\0'; // Empty string - _prefs->mqtt_password[0] = '\0'; // Empty string - - // Override with build flags if defined -#ifdef MQTT_SERVER - strncpy(_prefs->mqtt_server, MQTT_SERVER, sizeof(_prefs->mqtt_server) - 1); -#endif -#ifdef MQTT_PORT - _prefs->mqtt_port = MQTT_PORT; -#endif -#ifdef MQTT_USERNAME - strncpy(_prefs->mqtt_username, MQTT_USERNAME, sizeof(_prefs->mqtt_username) - 1); -#endif -#ifdef MQTT_PASSWORD - strncpy(_prefs->mqtt_password, MQTT_PASSWORD, sizeof(_prefs->mqtt_password) - 1); -#endif - + _tx_enabled = false; + + // Initialize all slots to empty/disabled state + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + memset(&_slots[i], 0, sizeof(MQTTSlot)); + _slots[i].enabled = false; + _slots[i].client = nullptr; + _slots[i].preset = nullptr; + _slots[i].auth_token = nullptr; + _slots[i].connected = false; + _slots[i].initial_connect_done = false; + _slots[i].token_expires_at = 0; + _slots[i].last_token_renewal = 0; + _slots[i].reconnect_backoff = 0; + _slots[i].last_reconnect_attempt = 0; + _slots[i].last_log_time = 0; + _slots[i].port = 1883; + } + + // Initialize JWT username + _jwt_username[0] = '\0'; + // Initialize packet queue (FreeRTOS queue will be created in begin()) #ifdef ESP_PLATFORM // Queue and mutex will be created in begin() @@ -194,25 +198,14 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCCloc _packet_queue[i].has_raw_data = false; } #endif - - // Initialize throttle log timers - _last_no_broker_log = 0; - _last_analyzer_us_log = 0; - _last_analyzer_eu_log = 0; - - // JWT token buffers: allocate in PSRAM when available (plan §2) - _auth_token_us = (char*)psram_malloc(AUTH_TOKEN_SIZE); - _auth_token_eu = (char*)psram_malloc(AUTH_TOKEN_SIZE); - if (_auth_token_us) _auth_token_us[0] = '\0'; - if (_auth_token_eu) _auth_token_eu[0] = '\0'; - - // Raw radio buffer in PSRAM when available (plan §6) + + // Raw radio buffer in PSRAM when available _last_raw_data = (uint8_t*)psram_malloc(LAST_RAW_DATA_SIZE); - - // Set default broker configuration - setBrokerDefaults(); } +// --------------------------------------------------------------------------- +// begin() +// --------------------------------------------------------------------------- void MQTTBridge::begin() { MQTT_DEBUG_PRINTLN("Initializing MQTT Bridge..."); @@ -253,35 +246,22 @@ void MQTTBridge::begin() { MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - WiFi credentials not configured"); return; } - - // Validate custom MQTT broker configuration (optional) - _config_valid = isMQTTConfigValid(); - if (!_config_valid) { - MQTT_DEBUG_PRINTLN("No valid custom MQTT server configured - analyzer servers will still work"); - } else { - MQTT_DEBUG_PRINTLN("Custom MQTT server configuration is valid"); - } - + // Update origin and IATA from preferences strncpy(_origin, _prefs->mqtt_origin, sizeof(_origin) - 1); _origin[sizeof(_origin) - 1] = '\0'; strncpy(_iata, _prefs->mqtt_iata, sizeof(_iata) - 1); _iata[sizeof(_iata) - 1] = '\0'; - - // Strip quotes from MQTT server configuration if present - stripQuotes(_prefs->mqtt_server, sizeof(_prefs->mqtt_server)); - stripQuotes(_prefs->mqtt_username, sizeof(_prefs->mqtt_username)); - stripQuotes(_prefs->mqtt_password, sizeof(_prefs->mqtt_password)); - + // Strip quotes from origin and IATA if present stripQuotes(_origin, sizeof(_origin)); stripQuotes(_iata, sizeof(_iata)); - + // Convert IATA code to uppercase (IATA codes are conventionally uppercase) for (int i = 0; _iata[i]; i++) { _iata[i] = toupper(_iata[i]); } - + // Update enabled flags from preferences _status_enabled = _prefs->mqtt_status_enabled; _packets_enabled = _prefs->mqtt_packets_enabled; @@ -295,14 +275,55 @@ void MQTTBridge::begin() { _prefs->mqtt_status_interval = 300000; // Fix the preference value _status_interval = 300000; // 5 minutes default } - + // Check for configuration mismatch: bridge.source=tx but mqtt.tx=off checkConfigurationMismatch(); - + MQTT_DEBUG_PRINTLN("Config: Origin=%s, IATA=%s, Device=%s", _origin, _iata, _device_id); - + + // Apply slot presets from preferences + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + const char* preset_name = _prefs->mqtt_slot_preset[i]; + if (preset_name[0] != '\0' && strcmp(preset_name, MQTT_PRESET_NONE) != 0) { + if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) { + // Custom broker: copy host/port/username/password from prefs + _slots[i].enabled = true; + _slots[i].preset = nullptr; + strncpy(_slots[i].host, _prefs->mqtt_slot_host[i], sizeof(_slots[i].host) - 1); + _slots[i].host[sizeof(_slots[i].host) - 1] = '\0'; + _slots[i].port = _prefs->mqtt_slot_port[i]; + strncpy(_slots[i].username, _prefs->mqtt_slot_username[i], sizeof(_slots[i].username) - 1); + _slots[i].username[sizeof(_slots[i].username) - 1] = '\0'; + strncpy(_slots[i].password, _prefs->mqtt_slot_password[i], sizeof(_slots[i].password) - 1); + _slots[i].password[sizeof(_slots[i].password) - 1] = '\0'; + } else { + const MQTTPresetDef* preset = findMQTTPreset(preset_name); + if (preset) { + _slots[i].enabled = true; + _slots[i].preset = preset; + } else { + MQTT_DEBUG_PRINTLN("Slot %d: unknown preset '%s', disabling", i, preset_name); + _slots[i].enabled = false; + } + } + } + } + + // Log slot configuration + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].enabled) { + if (_slots[i].preset) { + MQTT_DEBUG_PRINTLN("Slot %d: preset=%s", i, _slots[i].preset->name); + } else { + MQTT_DEBUG_PRINTLN("Slot %d: custom=%s:%d", i, _slots[i].host, _slots[i].port); + } + } else { + MQTT_DEBUG_PRINTLN("Slot %d: none", i); + } + } + #ifdef ESP_PLATFORM - // Create FreeRTOS queue; use PSRAM storage when available (plan §5) + // Create FreeRTOS queue; use PSRAM storage when available #ifdef BOARD_HAS_PSRAM _packet_queue_storage = (uint8_t*)psram_malloc(MAX_QUEUE_SIZE * sizeof(QueuedPacket)); if (_packet_queue_storage != nullptr) { @@ -323,7 +344,7 @@ void MQTTBridge::begin() { _packet_queue_storage = nullptr; return; } - + // Create mutex for raw radio data protection _raw_data_mutex = xSemaphoreCreateMutex(); if (_raw_data_mutex == nullptr) { @@ -332,57 +353,18 @@ void MQTTBridge::begin() { _packet_queue_handle = nullptr; return; } - - // Create main MQTT client only when a custom broker is configured (saves RAM when using analyzer-only) - if (_config_valid) { - _mqtt_client = new PsychicMqttClient(); - optimizeMqttClientConfig(_mqtt_client, false); - _mqtt_client->onConnect([this](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("MQTT broker connected"); - _main_broker_reconnect_backoff_attempt = 0; - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].enabled && !_brokers[i].connected) { - _brokers[i].connected = true; - _active_brokers++; - _cached_has_brokers = isAnyBrokerConnected(); - break; - } - } - }); - _mqtt_client->onDisconnect([this](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("MQTT broker disconnected"); - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].connected) { - _brokers[i].connected = false; - _active_brokers--; - _cached_has_brokers = isAnyBrokerConnected(); - break; - } - } - }); - } - - // Set default broker from preferences or build flags - setBroker(0, _prefs->mqtt_server, _prefs->mqtt_port, _prefs->mqtt_username, _prefs->mqtt_password, true); - - // Setup Let's Mesh Analyzer servers configuration - _analyzer_us_enabled = _prefs->mqtt_analyzer_us_enabled; - _analyzer_eu_enabled = _prefs->mqtt_analyzer_eu_enabled; - MQTT_DEBUG_PRINTLN("Analyzer servers - US: %s, EU: %s", - _analyzer_us_enabled ? "enabled" : "disabled", - _analyzer_eu_enabled ? "enabled" : "disabled"); - + // Create FreeRTOS task for MQTT/WiFi processing on Core 0 #ifndef MQTT_TASK_CORE #define MQTT_TASK_CORE 0 #endif #ifndef MQTT_TASK_STACK_SIZE - #define MQTT_TASK_STACK_SIZE 8192 // Reverted: 6144 was too small, caused boot loop after NTP sync + #define MQTT_TASK_STACK_SIZE 8192 #endif #ifndef MQTT_TASK_PRIORITY #define MQTT_TASK_PRIORITY 1 #endif - + // Task stack: use dynamic allocation (internal RAM). PSRAM stack was disabled because it // causes resets on some boards (e.g. Heltec V4) when the task runs from PSRAM stack. _mqtt_task_stack = nullptr; @@ -407,13 +389,9 @@ void MQTTBridge::begin() { _packet_queue_storage = nullptr; vSemaphoreDelete(_raw_data_mutex); _raw_data_mutex = nullptr; - if (_mqtt_client) { - delete _mqtt_client; - _mqtt_client = nullptr; - } return; } - + MQTT_DEBUG_PRINTLN("MQTT task created on Core %d", MQTT_TASK_CORE); #else // Non-ESP32: Initialize WiFi directly (no task) @@ -421,52 +399,22 @@ void MQTTBridge::begin() { WiFi.setAutoReconnect(true); WiFi.setAutoConnect(true); WiFi.begin(_prefs->wifi_ssid, _prefs->wifi_password); - - // Create main MQTT client only when a custom broker is configured (saves RAM when using analyzer-only) - if (_config_valid) { - _mqtt_client = new PsychicMqttClient(); - optimizeMqttClientConfig(_mqtt_client, false); - _mqtt_client->onConnect([this](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("MQTT broker connected"); - _main_broker_reconnect_backoff_attempt = 0; - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].enabled && !_brokers[i].connected) { - _brokers[i].connected = true; - _active_brokers++; - _cached_has_brokers = isAnyBrokerConnected(); - break; - } - } - }); - _mqtt_client->onDisconnect([this](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("MQTT broker disconnected"); - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].connected) { - _brokers[i].connected = false; - _active_brokers--; - _cached_has_brokers = isAnyBrokerConnected(); - break; - } - } - }); - } - - setBroker(0, _prefs->mqtt_server, _prefs->mqtt_port, _prefs->mqtt_username, _prefs->mqtt_password, true); - _analyzer_us_enabled = _prefs->mqtt_analyzer_us_enabled; - _analyzer_eu_enabled = _prefs->mqtt_analyzer_eu_enabled; - setupAnalyzerClients(); - connectToBrokers(); + + // NOTE: Slot setup deferred until after NTP sync in loop() #endif - + _initialized = true; s_mqtt_bridge_instance = this; MQTT_DEBUG_PRINTLN("MQTT Bridge initialized"); } +// --------------------------------------------------------------------------- +// end() +// --------------------------------------------------------------------------- void MQTTBridge::end() { MQTT_DEBUG_PRINTLN("Stopping MQTT Bridge..."); s_mqtt_bridge_instance = nullptr; - + #ifdef ESP_PLATFORM // Delete FreeRTOS task first (it will clean up WiFi/MQTT connections) if (_mqtt_task_handle != nullptr) { @@ -475,10 +423,10 @@ void MQTTBridge::end() { // Give task time to clean up vTaskDelay(pdMS_TO_TICKS(100)); } - // Free PSRAM task stack (plan §3) + // Free PSRAM task stack psram_free(_mqtt_task_stack); _mqtt_task_stack = nullptr; - + // Clean up queued packets from FreeRTOS queue // NOTE: Do NOT free queued.packet - the Dispatcher owns those packets. // We just discard our references to them. @@ -493,35 +441,13 @@ void MQTTBridge::end() { } psram_free(_packet_queue_storage); _packet_queue_storage = nullptr; - + // Delete mutex if (_raw_data_mutex != nullptr) { vSemaphoreDelete(_raw_data_mutex); _raw_data_mutex = nullptr; } #else - // Disconnect from all brokers (main client only exists when _config_valid) - if (_mqtt_client) { - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].enabled && _brokers[i].connected) { - _mqtt_client->disconnect(); - _brokers[i].connected = false; - } - } - } - - // Disconnect analyzer clients - if (_analyzer_us_client) { - _analyzer_us_client->disconnect(); - delete _analyzer_us_client; - _analyzer_us_client = nullptr; - } - if (_analyzer_eu_client) { - _analyzer_eu_client->disconnect(); - delete _analyzer_eu_client; - _analyzer_eu_client = nullptr; - } - // Clean up queued packet references // NOTE: Do NOT free the packets - the Dispatcher owns those packets. // We just discard our references to them. @@ -530,37 +456,36 @@ void MQTTBridge::end() { _packet_queue[index].packet = nullptr; memset(&_packet_queue[index], 0, sizeof(QueuedPacket)); } - + _queue_count = 0; _queue_head = 0; _queue_tail = 0; memset(_packet_queue, 0, sizeof(_packet_queue)); #endif - + + // Teardown all slots + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + teardownSlot(i); + } + // Clean up timezone object to prevent memory leak if (_timezone) { delete _timezone; _timezone = nullptr; } - - // Clean up resources - if (_mqtt_client) { - delete _mqtt_client; - _mqtt_client = nullptr; - } - - // Free PSRAM-backed JWT token buffers (plan §2) - psram_free(_auth_token_us); - _auth_token_us = nullptr; - psram_free(_auth_token_eu); - _auth_token_eu = nullptr; + + // Free PSRAM-backed raw data buffer psram_free(_last_raw_data); _last_raw_data = nullptr; - + _initialized = false; + _slots_setup_done = false; // Reset so deferred setup runs again on next begin() MQTT_DEBUG_PRINTLN("MQTT Bridge stopped"); } +// --------------------------------------------------------------------------- +// FreeRTOS task entry point +// --------------------------------------------------------------------------- #ifdef ESP_PLATFORM void MQTTBridge::mqttTask(void* parameter) { MQTTBridge* bridge = static_cast(parameter); @@ -573,14 +498,14 @@ void MQTTBridge::mqttTask(void* parameter) { void MQTTBridge::initializeWiFiInTask() { MQTT_DEBUG_PRINTLN("Initializing WiFi in MQTT task..."); - + // Initialize WiFi WiFi.mode(WIFI_STA); - + // Enable automatic reconnection - ESP32 will handle reconnection automatically WiFi.setAutoReconnect(true); WiFi.setAutoConnect(true); - + // Set up WiFi event handlers for better diagnostics and immediate disconnection detection WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) { switch(event) { @@ -595,33 +520,31 @@ void MQTTBridge::initializeWiFiInTask() { break; } }); - + WiFi.begin(_prefs->wifi_ssid, _prefs->wifi_password); - - // WiFi connection is asynchronous - don't block here - // Auto-reconnect will handle connection in the background - - // Setup PsychicMqttClient WebSocket clients for analyzer servers - setupAnalyzerClients(); - + + // NOTE: Slot setup is deferred until after NTP sync in mqttTaskLoop(). + // JWT-auth slots need valid timestamps for token creation, and connecting + // before NTP sync just wastes heap on TLS handshakes that will be rejected. + MQTT_DEBUG_PRINTLN("WiFi initialization started in task"); } +// --------------------------------------------------------------------------- +// mqttTaskLoop() - main loop running on Core 0 +// --------------------------------------------------------------------------- void MQTTBridge::mqttTaskLoop() { // Initialize WiFi first initializeWiFiInTask(); - + // Wait a bit for WiFi to start connecting vTaskDelay(pdMS_TO_TICKS(1000)); - + // Main task loop #ifdef MQTT_MEMORY_DEBUG static unsigned long last_agent_log = 0; #endif while (true) { - // Run the main MQTT bridge loop logic - // This replaces the original loop() method but runs in the task - #ifdef MQTT_MEMORY_DEBUG // #region agent log unsigned long now_loop = millis(); @@ -634,11 +557,11 @@ void MQTTBridge::mqttTaskLoop() { #ifdef BOARD_HAS_PSRAM spiram_f = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); #endif - agentLogHeap("MQTTBridge.cpp:505", "mqtt_loop_60s", "H5", free_h, max_alloc, internal_f, spiram_f); + agentLogHeap("MQTTBridge.cpp:mqttTaskLoop", "mqtt_loop_60s", "H5", free_h, max_alloc, internal_f, spiram_f); } // #endregion #endif - + unsigned long now = millis(); handleWiFiConnection(now); @@ -647,42 +570,42 @@ void MQTTBridge::mqttTaskLoop() { _ntp_sync_pending = false; syncTimeWithNTP(); } - - // Check if analyzer server settings have changed in preferences - static unsigned long last_analyzer_check = 0; - if (now - last_analyzer_check > 5000) { - last_analyzer_check = now; - if (_analyzer_us_enabled != _prefs->mqtt_analyzer_us_enabled || - _analyzer_eu_enabled != _prefs->mqtt_analyzer_eu_enabled) { - MQTT_DEBUG_PRINTLN("Analyzer settings changed - updating..."); - setupAnalyzerServers(); + + // Deferred slot setup: wait until NTP is synced so JWT tokens get valid timestamps. + // This avoids wasted TLS handshakes that get rejected due to bad token times. + if (_ntp_synced && !_slots_setup_done) { + _slots_setup_done = true; + MQTT_DEBUG_PRINTLN("NTP synced, setting up MQTT slots..."); + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].enabled) { + setupSlot(i); + // Stagger connections: 5s between slots to avoid simultaneous TLS handshakes + // which compete for ~40KB internal heap each + if (i < MAX_MQTT_SLOTS - 1) { + vTaskDelay(pdMS_TO_TICKS(5000)); + } + } } } - - // Maintain broker connections - connectToBrokers(); - - // Maintain analyzer server connections - maintainAnalyzerConnections(); - + + // Maintain slot connections (token renewal, reconnect with backoff) + maintainSlotConnections(); + // Process packet queue processPacketQueue(); - + // Periodic configuration check (throttled to avoid spam) checkConfigurationMismatch(); - + // Periodic NTP sync (every hour) - only when connected if (WiFi.status() == WL_CONNECTED && now - _last_ntp_sync > 3600000) { syncTimeWithNTP(); } - + // Publish status updates (handle millis() overflow correctly) if (_status_enabled) { - // Use cached destination status (updated in connection callbacks) - early exit if no destinations - // Only refresh cache if status publish is enabled to avoid unnecessary checks - bool has_custom_brokers = _cached_has_brokers && _config_valid; - bool has_destinations = has_custom_brokers || _cached_has_analyzer_servers; - + bool has_destinations = _cached_has_connected_slots; + // Early exit if no destinations - skip all the expensive logic below if (!has_destinations) { if (_last_status_retry != 0) { @@ -690,45 +613,38 @@ void MQTTBridge::mqttTaskLoop() { } } else { bool should_publish = false; - + // First, check if we need to respect retry interval (prevents spam when publish keeps failing) if (_last_status_retry != 0) { unsigned long retry_elapsed = (now >= _last_status_retry) ? (now - _last_status_retry) : (ULONG_MAX - _last_status_retry + now + 1); if (retry_elapsed < STATUS_RETRY_INTERVAL) { - // Too soon to retry - wait longer should_publish = false; } else { - // Retry interval has passed - allow retry should_publish = true; } } else { - // No pending retry - check if normal interval has passed - // Handle case where _last_status_publish is 0 (first publish attempt) if (_last_status_publish == 0) { - // First publish attempt - allow it immediately should_publish = true; } else { - // Calculate elapsed time since last successful publish - unsigned long elapsed = (now >= _last_status_publish) ? - (now - _last_status_publish) : + unsigned long elapsed = (now >= _last_status_publish) ? + (now - _last_status_publish) : (ULONG_MAX - _last_status_publish + now + 1); should_publish = (elapsed >= _status_interval); } } - + if (should_publish) { - // Only log elapsed time if we have a previous successful publish if (_last_status_publish != 0) { - unsigned long elapsed = (now >= _last_status_publish) ? - (now - _last_status_publish) : + unsigned long elapsed = (now >= _last_status_publish) ? + (now - _last_status_publish) : (ULONG_MAX - _last_status_publish + now + 1); MQTT_DEBUG_PRINTLN("Status publish timer expired (elapsed: %lu ms, interval: %lu ms)", elapsed, _status_interval); } else { MQTT_DEBUG_PRINTLN("Status publish attempt (first publish or retry)"); } - + _last_status_retry = now; if (publishStatus()) { _last_status_publish = now; @@ -738,91 +654,499 @@ void MQTTBridge::mqttTaskLoop() { size_t max_alloc = ESP.getMaxAllocHeap(); if (max_alloc < 58000 && (now - _last_fragmentation_recovery) > 300000) { _last_fragmentation_recovery = now; - _fragmentation_pressure_since = 0; // Reset pressure timer so dedicated check doesn't fire again soon + _fragmentation_pressure_since = 0; MQTT_DEBUG_PRINTLN("Fragmentation recovery after status (max_alloc=%d)", (int)max_alloc); recreateMqttClientsForFragmentationRecovery(); } } else { MQTT_DEBUG_PRINTLN("Status publish failed, will retry in %lu ms", STATUS_RETRY_INTERVAL); - // _last_status_retry already set above - will prevent immediate retry } } } } - + runCriticalMemoryCheckAndRecovery(); - // Update cached analyzer server status periodically (every 5 seconds) + // Update cached connection status periodically (every 5 seconds) // This ensures cache stays accurate even if callbacks miss updates - static unsigned long last_analyzer_status_update = 0; - if (now - last_analyzer_status_update > 5000) { - _cached_has_analyzer_servers = (_analyzer_us_enabled && _analyzer_us_client && _analyzer_us_client->connected()) || - (_analyzer_eu_enabled && _analyzer_eu_client && _analyzer_eu_client->connected()); - last_analyzer_status_update = now; + static unsigned long last_slot_status_update = 0; + if (now - last_slot_status_update > 5000) { + updateCachedConnectionStatus(); + last_slot_status_update = now; } - + // Adaptive task delay based on work done - // Check if we have work to do (queue has packets or status needs publishing) bool has_work = (_queue_count > 0); if (!has_work && _status_enabled) { - // Check if status publish is needed soon - if (_last_status_publish == 0 || - (now - _last_status_publish >= (_status_interval - 10000))) { // Within 10s of next publish + if (_last_status_publish == 0 || + (now - _last_status_publish >= (_status_interval - 10000))) { has_work = true; } } - + // Adaptive delay: shorter when work pending, longer when idle if (has_work) { - vTaskDelay(pdMS_TO_TICKS(5)); // 5ms delay when work pending - process faster + vTaskDelay(pdMS_TO_TICKS(5)); } else { - vTaskDelay(pdMS_TO_TICKS(50)); // 50ms delay when idle - save CPU + vTaskDelay(pdMS_TO_TICKS(50)); } } } #endif -bool MQTTBridge::isConfigValid() const { - return _config_valid; +// --------------------------------------------------------------------------- +// Slot management +// --------------------------------------------------------------------------- + +void MQTTBridge::setupSlot(int index) { + if (index < 0 || index >= MAX_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[index]; + + if (!slot.enabled) { + teardownSlot(index); + return; + } + + // Don't recreate if already exists + if (slot.client) return; + + slot.client = new PsychicMqttClient(); + optimizeMqttClientConfig(slot.client, slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT); + + // Callbacks (capture index by value) + slot.client->onConnect([this, index](bool sessionPresent) { + MQTT_DEBUG_PRINTLN("Slot %d connected", index); + _slots[index].connected = true; + _slots[index].reconnect_backoff = 0; + updateCachedConnectionStatus(); + publishStatusToSlot(index); + }); + slot.client->onDisconnect([this, index](bool sessionPresent) { + MQTT_DEBUG_PRINTLN("Slot %d disconnected", index); + _slots[index].connected = false; + updateCachedConnectionStatus(); + }); + slot.client->onError([this, index](esp_mqtt_error_codes error) { + MQTT_DEBUG_PRINTLN("Slot %d MQTT error: %d", index, error.esp_tls_last_esp_err); + }); + + if (slot.preset) { + // Preset-based slot + slot.client->setServer(slot.preset->server_url); + if (slot.preset->ca_cert) { + slot.client->setCACert(slot.preset->ca_cert); + } + + // Allocate JWT token buffer if needed + if (slot.preset->auth_type == MQTT_AUTH_JWT && !slot.auth_token) { + slot.auth_token = (char*)psram_malloc(AUTH_TOKEN_SIZE); + if (slot.auth_token) slot.auth_token[0] = '\0'; + } + + // Try to create token and connect (will succeed only if NTP synced) + if (slot.preset->auth_type == MQTT_AUTH_JWT) { + createSlotAuthToken(index); + if (slot.auth_token && strlen(slot.auth_token) > 0) { + slot.client->setCredentials(_jwt_username, slot.auth_token); + } + } + } else { + // Custom broker slot + char broker_uri[128]; + snprintf(broker_uri, sizeof(broker_uri), "mqtt://%s:%d", slot.host, slot.port); + slot.client->setServer(broker_uri); + if (strlen(slot.username) > 0) { + slot.client->setCredentials(slot.username, slot.password); + } + } + + slot.client->connect(); + slot.initial_connect_done = true; } -bool MQTTBridge::isConfigValid(const NodePrefs* prefs) { - // Check if MQTT server is configured (not default placeholder) - if (strlen(prefs->mqtt_server) == 0 || - strcmp(prefs->mqtt_server, "your-mqtt-broker.com") == 0) { +void MQTTBridge::teardownSlot(int index) { + if (index < 0 || index >= MAX_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[index]; + + if (slot.client) { + if (slot.client->connected()) { + slot.client->disconnect(); + } + #ifdef ESP_PLATFORM + vTaskDelay(pdMS_TO_TICKS(50)); + #else + delay(50); + #endif + delete slot.client; + slot.client = nullptr; + } + + // Free auth token buffer + if (slot.auth_token) { + psram_free(slot.auth_token); + slot.auth_token = nullptr; + } + + slot.connected = false; + slot.initial_connect_done = false; + slot.token_expires_at = 0; + slot.last_token_renewal = 0; + slot.reconnect_backoff = 0; + slot.last_reconnect_attempt = 0; + slot.last_log_time = 0; +} + +void MQTTBridge::maintainSlotConnections() { + if (!_identity) return; + + // Check WiFi status first + if (WiFi.status() != WL_CONNECTED) return; + + unsigned long now_millis = millis(); + unsigned long current_time = time(nullptr); + bool time_synced = (current_time >= 1000000000); // After year 2001 + + // JWT tokens require valid timestamps + unsigned long clock_sec = current_time; + bool clock_looks_set = (clock_sec >= 1735689600); // 2025-01-01 00:00:00 UTC + bool can_do_jwt = _ntp_synced || clock_looks_set; + + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (!_slots[i].enabled || !_slots[i].client) continue; + + // JWT slots need time sync before we can manage tokens + if (_slots[i].preset && _slots[i].preset->auth_type == MQTT_AUTH_JWT && !can_do_jwt) { + continue; + } + + maintainSlotConnection(i, now_millis, current_time, time_synced); + } +} + +void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced) { + MQTTSlot& slot = _slots[index]; + + if (slot.connected) { + slot.reconnect_backoff = 0; + } + + // JWT token renewal (only for preset slots with JWT auth) + if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) { + bool token_needs_renewal = false; + if (!time_synced) { + token_needs_renewal = (slot.token_expires_at == 0); + } else { + const unsigned long RENEWAL_BUFFER = 60; + token_needs_renewal = (slot.token_expires_at == 0) || + !(slot.token_expires_at >= 1000000000) || + (current_time >= slot.token_expires_at) || + (current_time >= (slot.token_expires_at - RENEWAL_BUFFER)); + } + + // Throttle renewal attempts to once per minute + const unsigned long RENEWAL_THROTTLE_MS = 60000; + bool can_attempt_renewal = (now_millis - slot.last_token_renewal) >= RENEWAL_THROTTLE_MS; + + if (token_needs_renewal && can_attempt_renewal) { + slot.last_token_renewal = now_millis; + + unsigned long old_token_expires_at = slot.token_expires_at; + + if (createSlotAuthToken(index)) { + MQTT_DEBUG_PRINTLN("Slot %d token renewed", index); + slot.client->setCredentials(_jwt_username, slot.auth_token); + + const unsigned long DISCONNECT_THRESHOLD = 60; + bool old_token_expired_or_imminent = !time_synced || + (old_token_expires_at == 0) || + (current_time >= old_token_expires_at) || + (time_synced && old_token_expires_at >= 1000000000 && + current_time >= (old_token_expires_at - DISCONNECT_THRESHOLD)); + + if (old_token_expired_or_imminent && slot.client->connected()) { + slot.client->disconnect(); + #ifdef ESP_PLATFORM + vTaskDelay(pdMS_TO_TICKS(1000 + index * 2000)); // stagger reconnects after token renewal + #endif + slot.last_reconnect_attempt = millis(); + slot.client->connect(); + } else if (!slot.client->connected()) { + slot.last_reconnect_attempt = now_millis; + slot.client->connect(); + } + } else { + MQTT_DEBUG_PRINTLN("Slot %d token renewal failed", index); + slot.token_expires_at = 0; + } + return; // Token renewal handled connect; skip backoff logic below + } + } + + // Reconnect with exponential backoff (for disconnected slots that already have valid config) + // Stagger reconnections: each slot offsets by 3s * slot_index to avoid simultaneous TLS handshakes + if (!slot.connected && slot.initial_connect_done) { + static const unsigned long SLOT_BACKOFF_MS[] = { 10000, 30000, 60000, 120000, 300000 }; + unsigned long reconnect_elapsed = (now_millis >= slot.last_reconnect_attempt) ? + (now_millis - slot.last_reconnect_attempt) : + (ULONG_MAX - slot.last_reconnect_attempt + now_millis + 1); + unsigned int idx = (slot.reconnect_backoff < 5) ? slot.reconnect_backoff : 4; + unsigned long delay_ms = SLOT_BACKOFF_MS[idx] + (index * 3000UL); // stagger by slot index + if (reconnect_elapsed >= delay_ms) { + slot.last_reconnect_attempt = now_millis; + if (slot.reconnect_backoff < 5) { + slot.reconnect_backoff++; + } + MQTT_DEBUG_PRINTLN("Slot %d reconnecting (backoff level %d)", index, slot.reconnect_backoff); + slot.client->connect(); + } + } +} + +bool MQTTBridge::createSlotAuthToken(int index) { + if (index < 0 || index >= MAX_MQTT_SLOTS) return false; + MQTTSlot& slot = _slots[index]; + if (!_identity || !slot.preset || slot.preset->auth_type != MQTT_AUTH_JWT || !slot.auth_token) { return false; } - - // Check if MQTT port is valid - if (prefs->mqtt_port == 0 || prefs->mqtt_port > 65535) { + + // Ensure JWT username is set + if (_jwt_username[0] == '\0') { + char public_key_hex[65]; + mesh::Utils::toHex(public_key_hex, _identity->pub_key, PUB_KEY_SIZE); + snprintf(_jwt_username, sizeof(_jwt_username), "v1_%s", public_key_hex); + } + + // Prepare owner key + const char* owner_key = nullptr; + char owner_key_uppercase[65]; + if (_prefs->mqtt_owner_public_key[0] != '\0') { + strncpy(owner_key_uppercase, _prefs->mqtt_owner_public_key, sizeof(owner_key_uppercase) - 1); + owner_key_uppercase[sizeof(owner_key_uppercase) - 1] = '\0'; + for (int i = 0; owner_key_uppercase[i]; i++) { + owner_key_uppercase[i] = toupper(owner_key_uppercase[i]); + } + owner_key = owner_key_uppercase; + } + + char client_version[64]; + getClientVersion(client_version, sizeof(client_version)); + const char* email = (_prefs->mqtt_email[0] != '\0') ? _prefs->mqtt_email : nullptr; + + unsigned long current_time = time(nullptr); + // Stagger token expiry per slot to avoid simultaneous renewal/reconnect + unsigned long expires_in = 86400 - (index * 300); // slot 0: 24h, slot 1: 23h55m, slot 2: 23h50m + bool time_synced = (current_time >= 1000000000); + + if (JWTHelper::createAuthToken( + *_identity, slot.preset->jwt_audience, + 0, expires_in, slot.auth_token, AUTH_TOKEN_SIZE, + owner_key, client_version, email)) { + slot.token_expires_at = time_synced ? (current_time + expires_in) : 0; + return true; + } + + slot.token_expires_at = 0; + return false; +} + +bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload, bool retained) { + if (index < 0 || index >= MAX_MQTT_SLOTS) return false; + MQTTSlot& slot = _slots[index]; + if (!slot.client || !slot.connected) { + unsigned long now = millis(); + if (now - slot.last_log_time > SLOT_LOG_INTERVAL) { + slot.last_log_time = now; + MQTT_DEBUG_PRINTLN("Slot %d not connected - skipping publish", index); + } return false; } - - // Username and password are optional - anonymous mode is supported - // Only reject if they contain the default placeholder values - if (strcmp(prefs->mqtt_username, "your-username") == 0) { + + int result = slot.client->publish(topic, 1, retained, payload, strlen(payload)); + if (result <= 0) { + static unsigned long last_fail_log = 0; + unsigned long now = millis(); + if (now - last_fail_log > 60000) { + MQTT_DEBUG_PRINTLN("Slot %d publish failed (result=%d)", index, result); + last_fail_log = now; + } return false; } - - if (strcmp(prefs->mqtt_password, "your-password") == 0) { - return false; - } - return true; } +bool MQTTBridge::publishToAllSlots(const char* topic, const char* payload, bool retained) { + bool published = false; + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].client && _slots[i].connected) { + if (publishToSlot(i, topic, payload, retained)) { + published = true; + } + } + } + return published; +} + +void MQTTBridge::publishStatusToSlot(int index) { + if (index < 0 || index >= MAX_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[index]; + if (!slot.client || !slot.connected) return; + + // Check if IATA is configured before attempting to publish + if (!isIATAValid()) { + static unsigned long last_iata_warning = 0; + unsigned long now = millis(); + if (now - last_iata_warning > 300000) { + MQTT_DEBUG_PRINTLN("MQTT: Cannot publish status to slot %d - IATA code not configured (current: '%s')", index, _iata); + last_iata_warning = now; + } + return; + } + + // Create status message + char status_topic[128]; + snprintf(status_topic, sizeof(status_topic), "meshcore/%s/%s/status", _iata, _device_id); + + static const size_t STATUS_JSON_SIZE = 768; + char* json_buffer = (char*)psram_malloc(STATUS_JSON_SIZE); + if (json_buffer == nullptr) return; + + char origin_id[65]; + char timestamp[32]; + char radio_info[64]; + + // Get current timestamp in ISO 8601 format + struct tm timeinfo; + if (getLocalTime(&timeinfo)) { + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S.000000", &timeinfo); + } else { + strcpy(timestamp, "2024-01-01T12:00:00.000000"); + } + + snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d", + _prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr); + + strncpy(origin_id, _device_id, sizeof(origin_id) - 1); + origin_id[sizeof(origin_id) - 1] = '\0'; + + char client_version[64]; + getClientVersion(client_version, sizeof(client_version)); + + // Collect stats on-demand if sources are available + int battery_mv = -1; + int uptime_secs = -1; + int errors = -1; + int noise_floor = -999; + int tx_air_secs = -1; + int rx_air_secs = -1; + int recv_errors = -1; + + if (_board) battery_mv = _board->getBattMilliVolts(); + if (_ms) uptime_secs = _ms->getMillis() / 1000; + if (_dispatcher) { + errors = _dispatcher->getErrFlags(); + tx_air_secs = _dispatcher->getTotalAirTime() / 1000; + rx_air_secs = _dispatcher->getReceiveAirTime() / 1000; + } + if (_radio) { + noise_floor = (int16_t)_radio->getNoiseFloor(); + recv_errors = (int)_radio->getPacketsRecvErrors(); + } + + int len = MQTTMessageBuilder::buildStatusMessage( + _origin, origin_id, _board_model, _firmware_version, radio_info, + client_version, "online", timestamp, json_buffer, STATUS_JSON_SIZE, + battery_mv, uptime_secs, errors, _queue_count, noise_floor, + tx_air_secs, rx_air_secs, recv_errors + ); + + if (len > 0) { + int result = slot.client->publish(status_topic, 1, true, json_buffer, strlen(json_buffer)); + if (result <= 0) { + MQTT_DEBUG_PRINTLN("Slot %d status publish failed", index); + } + } + psram_free(json_buffer); +} + +void MQTTBridge::updateCachedConnectionStatus() { + bool any_connected = false; + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].connected) { + any_connected = true; + break; + } + } + _cached_has_connected_slots = any_connected; +} + +bool MQTTBridge::isAnySlotConnected() { + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].connected) { + return true; + } + } + return false; +} + +void MQTTBridge::setSlotPreset(int slot_index, const char* preset_name) { + if (slot_index < 0 || slot_index >= MAX_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[slot_index]; + + teardownSlot(slot_index); + + if (strcmp(preset_name, MQTT_PRESET_NONE) == 0 || preset_name[0] == '\0') { + slot.enabled = false; + slot.preset = nullptr; + return; + } + + if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) { + slot.enabled = true; + slot.preset = nullptr; + // Custom broker settings should already be set via setSlotCustomBroker + if (_initialized && strlen(slot.host) > 0 && slot.port > 0) { + setupSlot(slot_index); + } + return; + } + + const MQTTPresetDef* preset = findMQTTPreset(preset_name); + if (preset) { + slot.enabled = true; + slot.preset = preset; + if (_initialized) { + setupSlot(slot_index); + } + } +} + +void MQTTBridge::setSlotCustomBroker(int slot_index, const char* host, uint16_t port, + const char* username, const char* password) { + if (slot_index < 0 || slot_index >= MAX_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[slot_index]; + + strncpy(slot.host, host ? host : "", sizeof(slot.host) - 1); + slot.host[sizeof(slot.host) - 1] = '\0'; + slot.port = port; + strncpy(slot.username, username ? username : "", sizeof(slot.username) - 1); + slot.username[sizeof(slot.username) - 1] = '\0'; + strncpy(slot.password, password ? password : "", sizeof(slot.password) - 1); + slot.password[sizeof(slot.password) - 1] = '\0'; +} + +// --------------------------------------------------------------------------- +// WiFi connection handling +// --------------------------------------------------------------------------- + void MQTTBridge::checkConfigurationMismatch() { // Check if bridge.source is set to tx (logTx) but mqtt.tx is disabled - // This would prevent packet publishing since sendPacket() requires both packets_enabled and tx_enabled if (_prefs->bridge_pkt_src == 0 && _packets_enabled && !_tx_enabled) { unsigned long now = millis(); - // Always log on first detection, then throttle to every 5 minutes to avoid spam if (_last_config_warning == 0 || (now - _last_config_warning > CONFIG_WARNING_INTERVAL)) { MQTT_DEBUG_PRINTLN("MQTT: Configuration mismatch detected! bridge.source=tx (logTx) but mqtt.tx=off. Packets will not be published. Run 'set bridge.source rx' or 'set mqtt.tx on' to fix."); _last_config_warning = now; } } else { - // Configuration is correct, reset warning timer so we log immediately if it becomes wrong again _last_config_warning = 0; } } @@ -875,11 +1199,11 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { if (_last_wifi_status == WL_CONNECTED) { _wifi_disconnected_time = now; s_wifi_connected_at = 0; - if (_analyzer_us_client) { - _analyzer_us_client->disconnect(); - } - if (_analyzer_eu_client) { - _analyzer_eu_client->disconnect(); + // Disconnect all slot clients when WiFi drops + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].client && _slots[i].connected) { + _slots[i].client->disconnect(); + } } } else if (_wifi_disconnected_time > 0) { unsigned long disconnected_duration = now - _wifi_disconnected_time; @@ -907,15 +1231,23 @@ bool MQTTBridge::isReady() const { return _initialized && isWiFiConfigValid(_prefs); } +bool MQTTBridge::isIATAValid() const { + if (strlen(_iata) == 0 || strcmp(_iata, "XXX") == 0) { + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// loop() - non-ESP32 main loop (ESP32 uses mqttTaskLoop via FreeRTOS task) +// --------------------------------------------------------------------------- void MQTTBridge::loop() { if (!_initialized) return; - + #ifdef ESP_PLATFORM // On ESP32, loop() is a no-op - all processing happens in the FreeRTOS task - // This method is kept for API compatibility but does nothing return; #else - // Non-ESP32: loop() drives WiFi and MQTT (same logic as mqttTaskLoop via handleWiFiConnection) unsigned long now = millis(); if (handleWiFiConnection(now) && !_ntp_synced) { syncTimeWithNTP(); @@ -924,83 +1256,67 @@ void MQTTBridge::loop() { _ntp_sync_pending = false; syncTimeWithNTP(); } - // Check if analyzer server settings have changed in preferences - static unsigned long last_analyzer_check = 0; - if (millis() - last_analyzer_check > 5000) { - last_analyzer_check = millis(); - if (_analyzer_us_enabled != _prefs->mqtt_analyzer_us_enabled || - _analyzer_eu_enabled != _prefs->mqtt_analyzer_eu_enabled) { - MQTT_DEBUG_PRINTLN("Analyzer settings changed - updating..."); - setupAnalyzerServers(); + + // Deferred slot setup after NTP sync (non-ESP32 path) + if (_ntp_synced && !_slots_setup_done) { + _slots_setup_done = true; + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].enabled) { + setupSlot(i); + } } } - - // Maintain broker connections - connectToBrokers(); - - // Maintain analyzer server connections - maintainAnalyzerConnections(); - + + // Maintain slot connections (token renewal, reconnect with backoff) + maintainSlotConnections(); + // Process packet queue processPacketQueue(); - + // Periodic configuration check (throttled to avoid spam) checkConfigurationMismatch(); - + // Periodic NTP sync (every hour) - only when connected if (WiFi.status() == WL_CONNECTED && millis() - _last_ntp_sync > 3600000) { syncTimeWithNTP(); } - + // Publish status updates (handle millis() overflow correctly) if (_status_enabled) { - // Use cached destination status (updated in connection callbacks) - early exit if no destinations - bool has_custom_brokers = _cached_has_brokers && _config_valid; - bool has_destinations = has_custom_brokers || _cached_has_analyzer_servers; - - // Only attempt to publish if we have destinations available + bool has_destinations = _cached_has_connected_slots; + if (has_destinations) { unsigned long now = millis(); bool should_publish = false; - - // First, check if we need to respect retry interval (prevents spam when publish keeps failing) + if (_last_status_retry != 0) { unsigned long retry_elapsed = (now >= _last_status_retry) ? (now - _last_status_retry) : (ULONG_MAX - _last_status_retry + now + 1); - if (retry_elapsed < STATUS_RETRY_INTERVAL) { - // Too soon to retry - wait longer - should_publish = false; - } else { - // Retry interval has passed - allow retry + if (retry_elapsed >= STATUS_RETRY_INTERVAL) { should_publish = true; } } else { - // No pending retry - check if normal interval has passed - // Handle case where _last_status_publish is 0 (first publish attempt) if (_last_status_publish == 0) { - // First publish attempt - allow it immediately should_publish = true; } else { - // Calculate elapsed time since last successful publish - unsigned long elapsed = (now >= _last_status_publish) ? - (now - _last_status_publish) : + unsigned long elapsed = (now >= _last_status_publish) ? + (now - _last_status_publish) : (ULONG_MAX - _last_status_publish + now + 1); should_publish = (elapsed >= _status_interval); } } - + if (should_publish) { - // Only log elapsed time if we have a previous successful publish if (_last_status_publish != 0) { - unsigned long elapsed = (now >= _last_status_publish) ? - (now - _last_status_publish) : + unsigned long elapsed = (now >= _last_status_publish) ? + (now - _last_status_publish) : (ULONG_MAX - _last_status_publish + now + 1); MQTT_DEBUG_PRINTLN("Status publish timer expired (elapsed: %lu ms, interval: %lu ms)", elapsed, _status_interval); } else { MQTT_DEBUG_PRINTLN("Status publish attempt (first publish or retry)"); } - + _last_status_retry = now; if (publishStatus()) { _last_status_publish = now; @@ -1008,7 +1324,6 @@ void MQTTBridge::loop() { MQTT_DEBUG_PRINTLN("Status published successfully, next publish in %lu ms", _status_interval); } else { MQTT_DEBUG_PRINTLN("Status publish failed, will retry in %lu ms", STATUS_RETRY_INTERVAL); - // _last_status_retry already set above - will prevent immediate retry } } } else { @@ -1016,23 +1331,23 @@ void MQTTBridge::loop() { _last_status_retry = 0; } } - + // Check if status hasn't been published successfully for too long - // If status publishes have been failing for > 10 minutes, force full MQTT reinitialization if (_status_enabled && _last_status_publish != 0) { + unsigned long now = millis(); unsigned long time_since_last_success = (now >= _last_status_publish) ? (now - _last_status_publish) : (ULONG_MAX - _last_status_publish + now + 1); const unsigned long MAX_FAILURE_TIME_MS = 600000; // 10 minutes - + if (time_since_last_success > MAX_FAILURE_TIME_MS) { static unsigned long last_reinit_log = 0; - if (now - last_reinit_log > 300000) { // Log every 5 minutes max + if (now - last_reinit_log > 300000) { MQTT_DEBUG_PRINTLN("CRITICAL: Status publish has been failing for %lu ms (>%lu ms), forcing MQTT session reinitialization", time_since_last_success, MAX_FAILURE_TIME_MS); last_reinit_log = now; } - + recreateMqttClientsForFragmentationRecovery(); _last_status_publish = 0; _last_status_retry = 0; @@ -1040,106 +1355,522 @@ void MQTTBridge::loop() { } } } - #endif - + #ifdef ESP_PLATFORM runCriticalMemoryCheckAndRecovery(); #endif + #endif } +// --------------------------------------------------------------------------- +// Packet handling +// --------------------------------------------------------------------------- + void MQTTBridge::onPacketReceived(mesh::Packet *packet) { if (!_initialized || !_packets_enabled) return; - - // Check if we have any valid brokers to send to - bool has_valid_brokers = _config_valid || - (_analyzer_us_enabled && _analyzer_us_client) || - (_analyzer_eu_enabled && _analyzer_eu_client); - - if (!has_valid_brokers) return; - + + // Check if we have any enabled slots to send to + bool has_valid_slots = false; + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].client) { + has_valid_slots = true; + break; + } + } + if (!has_valid_slots) return; + // Queue packet for transmission queuePacket(packet, false); } void MQTTBridge::sendPacket(mesh::Packet *packet) { if (!_initialized || !_packets_enabled || !_tx_enabled) return; - + // Queue packet for transmission (only if TX enabled) queuePacket(packet, true); } -bool MQTTBridge::isMQTTConfigValid() { - // Check if MQTT server is configured (not default placeholder) - if (strlen(_prefs->mqtt_server) == 0 || - strcmp(_prefs->mqtt_server, "your-mqtt-broker.com") == 0) { - return false; +void MQTTBridge::processPacketQueue() { + #ifdef ESP_PLATFORM + // Use FreeRTOS queue + if (_packet_queue_handle == nullptr) { + return; } - - // Check if MQTT port is valid - if (_prefs->mqtt_port == 0 || _prefs->mqtt_port > 65535) { - return false; - } - - // Username and password are optional - anonymous mode is supported - // Only reject if they contain the default placeholder values - if (strcmp(_prefs->mqtt_username, "your-username") == 0) { - return false; - } - - if (strcmp(_prefs->mqtt_password, "your-password") == 0) { - return false; - } - - return true; -} -bool MQTTBridge::isIATAValid() const { - // Check if IATA code is configured (not empty, not default "XXX") - if (strlen(_iata) == 0 || strcmp(_iata, "XXX") == 0) { - return false; - } - return true; -} + // Update queue count from actual queue state + _queue_count = uxQueueMessagesWaiting(_packet_queue_handle); -void MQTTBridge::ensureMainMqttClient() { - if (!_config_valid || _mqtt_client != nullptr) return; - _mqtt_client = new PsychicMqttClient(); - optimizeMqttClientConfig(_mqtt_client, false); - _mqtt_client->onConnect([this](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("MQTT broker connected"); - _main_broker_reconnect_backoff_attempt = 0; - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].enabled && !_brokers[i].connected) { - _brokers[i].connected = true; - _active_brokers++; - _cached_has_brokers = isAnyBrokerConnected(); - break; + if (_queue_count == 0) { + return; + } + + // Use cached connection status to avoid redundant checks + bool has_connected_slots = _cached_has_connected_slots; + + if (!has_connected_slots) { + if (_queue_count > 0) { + unsigned long now = millis(); + if (now - _last_no_broker_log > NO_BROKER_LOG_INTERVAL) { + MQTT_DEBUG_PRINTLN("Queue has %d packets but no slots connected", _queue_count); + _last_no_broker_log = now; } } - }); - _mqtt_client->onDisconnect([this](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("MQTT broker disconnected"); - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].connected) { - _brokers[i].connected = false; - _active_brokers--; - _cached_has_brokers = isAnyBrokerConnected(); - break; + return; + } + + _last_no_broker_log = 0; + + // Process up to 1 packet per call to maintain responsiveness + int processed = 0; + int max_per_loop = 1; + unsigned long loop_start_time = millis(); + const unsigned long MAX_PROCESSING_TIME_MS = 30; + + while (processed < max_per_loop) { + unsigned long elapsed = millis() - loop_start_time; + if (elapsed > MAX_PROCESSING_TIME_MS) { + break; + } + + QueuedPacket queued; + // Try to receive from queue (non-blocking) + if (xQueueReceive(_packet_queue_handle, &queued, 0) != pdTRUE) { + break; // No more packets + } + + // Publish packet (use stored raw data if available) + publishPacket(queued.packet, queued.is_tx, + queued.has_raw_data ? queued.raw_data : nullptr, + queued.has_raw_data ? queued.raw_len : 0, + queued.has_raw_data ? queued.snr : 0.0f, + queued.has_raw_data ? queued.rssi : 0.0f); + + // Publish raw if enabled + if (_raw_enabled) { + publishRaw(queued.packet); + } + + // NOTE: Do NOT free the packet here - the Dispatcher owns and frees it after logRx() returns. + queued.packet = nullptr; + + _queue_count--; + processed++; + } + #else + // Non-ESP32: Use circular buffer + if (_queue_count == 0) { + return; + } + + bool has_connected_slots = _cached_has_connected_slots; + + if (!has_connected_slots) { + if (_queue_count > 0) { + unsigned long now = millis(); + if (now - _last_no_broker_log > NO_BROKER_LOG_INTERVAL) { + MQTT_DEBUG_PRINTLN("Queue has %d packets but no slots connected", _queue_count); + _last_no_broker_log = now; } } - }); - MQTT_DEBUG_PRINTLN("Main MQTT client recreated (fresh buffers)"); + return; + } + + _last_no_broker_log = 0; + + int processed = 0; + int max_per_loop = 1; + unsigned long loop_start_time = millis(); + const unsigned long MAX_PROCESSING_TIME_MS = 30; + + while (_queue_count > 0 && processed < max_per_loop) { + unsigned long elapsed = millis() - loop_start_time; + if (elapsed > MAX_PROCESSING_TIME_MS) { + break; + } + + QueuedPacket& queued = _packet_queue[_queue_head]; + + publishPacket(queued.packet, queued.is_tx, + queued.has_raw_data ? queued.raw_data : nullptr, + queued.has_raw_data ? queued.raw_len : 0, + queued.has_raw_data ? queued.snr : 0.0f, + queued.has_raw_data ? queued.rssi : 0.0f); + + if (_raw_enabled) { + publishRaw(queued.packet); + } + + // NOTE: Do NOT free the packet here - the Dispatcher owns and frees it after logRx() returns. + queued.packet = nullptr; + + dequeuePacket(); + processed++; + } + #endif } +// --------------------------------------------------------------------------- +// Publishing +// --------------------------------------------------------------------------- + +bool MQTTBridge::publishStatus() { + // Check if IATA is configured before attempting to publish + if (!isIATAValid()) { + static unsigned long last_iata_warning = 0; + unsigned long now = millis(); + if (now - last_iata_warning > 300000) { + MQTT_DEBUG_PRINTLN("MQTT: Cannot publish status - IATA code not configured (current: '%s'). Please set mqtt.iata via CLI.", _iata); + last_iata_warning = now; + } + return false; + } + + if (!_cached_has_connected_slots) { + return false; + } + + // JSON buffer in PSRAM when available + static const size_t STATUS_JSON_BUFFER_SIZE = 768; + char* json_buffer = (char*)psram_malloc(STATUS_JSON_BUFFER_SIZE); + if (json_buffer == nullptr) { + return false; + } + char origin_id[65]; + char timestamp[32]; + char radio_info[64]; + + // Get current timestamp in ISO 8601 format + struct tm timeinfo; + if (getLocalTime(&timeinfo)) { + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S.000000", &timeinfo); + } else { + strcpy(timestamp, "2024-01-01T12:00:00.000000"); + } + + snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d", + _prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr); + + strncpy(origin_id, _device_id, sizeof(origin_id) - 1); + origin_id[sizeof(origin_id) - 1] = '\0'; + + char client_version[64]; + getClientVersion(client_version, sizeof(client_version)); + + // Collect stats on-demand if sources are available + int battery_mv = -1; + int uptime_secs = -1; + int errors = -1; + int noise_floor = -999; + int tx_air_secs = -1; + int rx_air_secs = -1; + int recv_errors = -1; + + if (_board) battery_mv = _board->getBattMilliVolts(); + if (_ms) uptime_secs = _ms->getMillis() / 1000; + if (_dispatcher) { + errors = _dispatcher->getErrFlags(); + tx_air_secs = _dispatcher->getTotalAirTime() / 1000; + rx_air_secs = _dispatcher->getReceiveAirTime() / 1000; + } + if (_radio) { + noise_floor = (int16_t)_radio->getNoiseFloor(); + recv_errors = (int)_radio->getPacketsRecvErrors(); + } + + int len = MQTTMessageBuilder::buildStatusMessage( + _origin, origin_id, _board_model, _firmware_version, radio_info, + client_version, "online", timestamp, json_buffer, STATUS_JSON_BUFFER_SIZE, + battery_mv, uptime_secs, errors, _queue_count, noise_floor, + tx_air_secs, rx_air_secs, recv_errors + ); + + if (len > 0) { + char topic[128]; + snprintf(topic, sizeof(topic), "meshcore/%s/%s/status", _iata, _device_id); + + bool published = publishToAllSlots(topic, json_buffer, true); + + if (published) { + MQTT_DEBUG_PRINTLN("Status published"); + psram_free(json_buffer); + return true; + } + } + + psram_free(json_buffer); + return false; +} + +void MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, + const uint8_t* raw_data, int raw_len, + float snr, float rssi) { + if (!packet) return; + + // Check if IATA is configured before attempting to publish + if (!isIATAValid()) { + static unsigned long last_iata_warning = 0; + unsigned long now = millis(); + if (now - last_iata_warning > 300000) { + MQTT_DEBUG_PRINTLN("MQTT: Cannot publish packet - IATA code not configured (current: '%s'). Please set mqtt.iata via CLI.", _iata); + last_iata_warning = now; + } + return; + } + + // Memory pressure check: Skip publishes when heap is severely fragmented + #ifdef ESP32 + unsigned long now = millis(); + if (now - _last_memory_check > 5000) { + size_t max_alloc = ESP.getMaxAllocHeap(); + if (max_alloc < 60000) { + _skipped_publishes++; + static unsigned long last_skip_log = 0; + if (now - last_skip_log > 60000) { + MQTT_DEBUG_PRINTLN("MQTT: Skipping publish due to memory pressure (Max alloc: %d, skipped: %d)", max_alloc, _skipped_publishes); + last_skip_log = now; + } + return; + } + _last_memory_check = now; + } + #endif + + // JSON buffer: prefer PSRAM; fallback to stack if allocation fails + static const size_t PUBLISH_JSON_BUFFER_SIZE = 2048; + char* json_buffer_psram = (char*)psram_malloc(PUBLISH_JSON_BUFFER_SIZE); + char json_buffer_stack[1024]; + char json_buffer_large_stack[2048]; + int packet_size = packet->getRawLength(); + char* active_buffer; + size_t active_buffer_size; + if (json_buffer_psram != nullptr) { + active_buffer = json_buffer_psram; + active_buffer_size = PUBLISH_JSON_BUFFER_SIZE; + } else { + active_buffer = (packet_size > 200) ? json_buffer_large_stack : json_buffer_stack; + active_buffer_size = (packet_size > 200) ? 2048 : 1024; + } + char origin_id[65]; + + strncpy(origin_id, _device_id, sizeof(origin_id) - 1); + origin_id[sizeof(origin_id) - 1] = '\0'; + + // Build packet message using raw radio data if provided + int len; + if (raw_data && raw_len > 0) { + len = MQTTMessageBuilder::buildPacketJSONFromRaw( + raw_data, raw_len, packet, is_tx, _origin, origin_id, + snr, rssi, _timezone, active_buffer, active_buffer_size + ); + } else if (_last_raw_data && _last_raw_len > 0 && (millis() - _last_raw_timestamp) < 1000) { + len = MQTTMessageBuilder::buildPacketJSONFromRaw( + _last_raw_data, _last_raw_len, packet, is_tx, _origin, origin_id, + _last_snr, _last_rssi, _timezone, active_buffer, active_buffer_size + ); + } else { + len = MQTTMessageBuilder::buildPacketJSON( + packet, is_tx, _origin, origin_id, _timezone, active_buffer, active_buffer_size + ); + } + + if (len > 0) { + char topic[128]; + snprintf(topic, sizeof(topic), "meshcore/%s/%s/packets", _iata, _device_id); + publishToAllSlots(topic, active_buffer, false); + } else { + uint8_t packet_type = packet->getPayloadType(); + if (packet_type == 4 || packet_type == 9) { + MQTT_DEBUG_PRINTLN("Failed to build packet JSON for type=%d (len=%d), packet not published", packet_type, len); + } + } + psram_free(json_buffer_psram); +} + +void MQTTBridge::publishRaw(mesh::Packet* packet) { + if (!packet) return; + + if (!isIATAValid()) { + static unsigned long last_iata_warning = 0; + unsigned long now = millis(); + if (now - last_iata_warning > 300000) { + MQTT_DEBUG_PRINTLN("MQTT: Cannot publish raw packet - IATA code not configured (current: '%s'). Please set mqtt.iata via CLI.", _iata); + last_iata_warning = now; + } + return; + } + + // JSON buffer: prefer PSRAM; fallback to stack if allocation fails + char* json_buffer_psram = (char*)psram_malloc(2048); + char json_buffer_stack[1024]; + char json_buffer_large_stack[2048]; + int packet_size = packet->getRawLength(); + char* active_buffer; + size_t active_buffer_size; + if (json_buffer_psram != nullptr) { + active_buffer = json_buffer_psram; + active_buffer_size = 2048; + } else { + active_buffer = (packet_size > 200) ? json_buffer_large_stack : json_buffer_stack; + active_buffer_size = (packet_size > 200) ? 2048 : 1024; + } + char origin_id[65]; + + strncpy(origin_id, _device_id, sizeof(origin_id) - 1); + origin_id[sizeof(origin_id) - 1] = '\0'; + + int len = MQTTMessageBuilder::buildRawJSON( + packet, _origin, origin_id, _timezone, active_buffer, active_buffer_size + ); + + if (len > 0) { + char topic[128]; + snprintf(topic, sizeof(topic), "meshcore/%s/%s/raw", _iata, _device_id); + publishToAllSlots(topic, active_buffer, false); + } + psram_free(json_buffer_psram); +} + +// --------------------------------------------------------------------------- +// Queue management +// --------------------------------------------------------------------------- + +void MQTTBridge::queuePacket(mesh::Packet* packet, bool is_tx) { + #ifdef ESP_PLATFORM + // Use FreeRTOS queue for thread-safe operation + if (_packet_queue_handle == nullptr) { + return; + } + + QueuedPacket queued; + memset(&queued, 0, sizeof(QueuedPacket)); + + queued.packet = packet; + queued.timestamp = millis(); + queued.is_tx = is_tx; + queued.has_raw_data = false; + + // Capture raw radio data with mutex protection + if (!is_tx) { + if (xSemaphoreTake(_raw_data_mutex, 0) == pdTRUE) { + unsigned long current_time = millis(); + if (_last_raw_len > 0 && (current_time - _last_raw_timestamp) < 1000) { + if (_last_raw_data && _last_raw_len <= sizeof(queued.raw_data)) { + memcpy(queued.raw_data, _last_raw_data, _last_raw_len); + queued.raw_len = _last_raw_len; + queued.snr = _last_snr; + queued.rssi = _last_rssi; + queued.has_raw_data = true; + } + } + xSemaphoreGive(_raw_data_mutex); + } + } + + // Try to send to queue (non-blocking) + if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) { + QueuedPacket oldest; + if (xQueueReceive(_packet_queue_handle, &oldest, 0) == pdTRUE) { + MQTT_DEBUG_PRINTLN("Queue full, dropping oldest packet reference"); + if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) { + MQTT_DEBUG_PRINTLN("Failed to queue packet after dropping oldest"); + return; + } + } else { + MQTT_DEBUG_PRINTLN("Queue full and cannot remove oldest packet"); + return; + } + } + + UBaseType_t queue_messages = uxQueueMessagesWaiting(_packet_queue_handle); + _queue_count = queue_messages; + #else + // Non-ESP32: Use circular buffer + if (_queue_count >= MAX_QUEUE_SIZE) { + QueuedPacket& oldest = _packet_queue[_queue_head]; + MQTT_DEBUG_PRINTLN("Queue full, dropping oldest packet reference (queue size: %d)", _queue_count); + oldest.packet = nullptr; + dequeuePacket(); + } + + QueuedPacket& queued = _packet_queue[_queue_tail]; + memset(&queued, 0, sizeof(QueuedPacket)); + + queued.packet = packet; + queued.timestamp = millis(); + queued.is_tx = is_tx; + queued.has_raw_data = false; + + if (!is_tx && _last_raw_data && _last_raw_len > 0 && (millis() - _last_raw_timestamp) < 1000) { + if (_last_raw_len <= sizeof(queued.raw_data)) { + memcpy(queued.raw_data, _last_raw_data, _last_raw_len); + queued.raw_len = _last_raw_len; + queued.snr = _last_snr; + queued.rssi = _last_rssi; + queued.has_raw_data = true; + } + } + + _queue_tail = (_queue_tail + 1) % MAX_QUEUE_SIZE; + _queue_count++; + #endif +} + +void MQTTBridge::dequeuePacket() { + #ifdef ESP_PLATFORM + // On ESP32, dequeuePacket() is not used - we use FreeRTOS queue operations directly + return; + #else + if (_queue_count == 0) return; + + QueuedPacket& dequeued = _packet_queue[_queue_head]; + memset(&dequeued, 0, sizeof(QueuedPacket)); + dequeued.has_raw_data = false; + + _queue_head = (_queue_head + 1) % MAX_QUEUE_SIZE; + _queue_count--; + #endif +} + +// --------------------------------------------------------------------------- +// Raw radio data storage +// --------------------------------------------------------------------------- + +void MQTTBridge::storeRawRadioData(const uint8_t* raw_data, int len, float snr, float rssi) { + if (len > 0 && len <= LAST_RAW_DATA_SIZE && _last_raw_data) { + #ifdef ESP_PLATFORM + if (_raw_data_mutex != nullptr && xSemaphoreTake(_raw_data_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + memcpy(_last_raw_data, raw_data, len); + _last_raw_len = len; + _last_snr = snr; + _last_rssi = rssi; + _last_raw_timestamp = millis(); + xSemaphoreGive(_raw_data_mutex); + MQTT_DEBUG_PRINTLN("Stored raw radio data: %d bytes, SNR=%.1f, RSSI=%.1f", len, snr, rssi); + } + #else + memcpy(_last_raw_data, raw_data, len); + _last_raw_len = len; + _last_snr = snr; + _last_rssi = rssi; + _last_raw_timestamp = millis(); + MQTT_DEBUG_PRINTLN("Stored raw radio data: %d bytes, SNR=%.1f, RSSI=%.1f", len, snr, rssi); + #endif + } +} + +// --------------------------------------------------------------------------- +// Memory management +// --------------------------------------------------------------------------- + #ifdef ESP_PLATFORM void MQTTBridge::runCriticalMemoryCheckAndRecovery() { - const unsigned long CRITICAL_CHECK_INTERVAL_MS = 60000; // Sample heap at most every 60s - const unsigned long PRESSURE_WINDOW_CRITICAL_MS = 180000; // Recover if critical pressure for 3 min - const unsigned long PRESSURE_WINDOW_MODERATE_MS = 300000; // Recover if moderate pressure for 5 min - const unsigned long RECOVERY_THROTTLE_MS = 300000; // 5 min between recovery runs - const unsigned long CRITICAL_LOG_INTERVAL_MS = 900000; // Log CRITICAL/WARNING/client count at most every 15 min - const size_t PRESSURE_THRESHOLD_CRITICAL = 58000; // max_alloc below this = critical (3 min window) - const size_t PRESSURE_THRESHOLD_MODERATE = 70000; // max_alloc below this = under pressure (clear when >= this) + const unsigned long CRITICAL_CHECK_INTERVAL_MS = 60000; + const unsigned long PRESSURE_WINDOW_CRITICAL_MS = 180000; + const unsigned long PRESSURE_WINDOW_MODERATE_MS = 300000; + const unsigned long RECOVERY_THROTTLE_MS = 300000; + const unsigned long CRITICAL_LOG_INTERVAL_MS = 900000; + const size_t PRESSURE_THRESHOLD_CRITICAL = 58000; + const size_t PRESSURE_THRESHOLD_MODERATE = 70000; unsigned long now = millis(); if (now - _last_critical_check_run < CRITICAL_CHECK_INTERVAL_MS) { @@ -1176,13 +1907,15 @@ void MQTTBridge::runCriticalMemoryCheckAndRecovery() { } else if (max_alloc < 60000) { MQTT_DEBUG_PRINTLN("WARNING: Memory pressure. Free: %d, Max: %d", (int)free_h, (int)max_alloc); } - int n_main = (_mqtt_client != nullptr) ? 1 : 0; - int n_us = (_analyzer_us_client != nullptr) ? 1 : 0; - int n_eu = (_analyzer_eu_client != nullptr) ? 1 : 0; - MQTT_DEBUG_PRINTLN("MQTT clients active: %d (main=%d us=%d eu=%d)", n_main + n_us + n_eu, n_main, n_us, n_eu); + // Log slot client count + int n_active = 0; + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].client != nullptr) n_active++; + } + MQTT_DEBUG_PRINTLN("MQTT clients active: %d", n_active); } - // Dedicated recovery: critical (<58k) recovers after 3 min; moderate (58k–70k) after 5 min + // Dedicated recovery unsigned long required_window_ms = (max_alloc < PRESSURE_THRESHOLD_CRITICAL) ? PRESSURE_WINDOW_CRITICAL_MS : PRESSURE_WINDOW_MODERATE_MS; @@ -1199,882 +1932,283 @@ void MQTTBridge::runCriticalMemoryCheckAndRecovery() { void MQTTBridge::recreateMqttClientsForFragmentationRecovery() { // Disconnect, delete, and recreate all MQTT clients so they allocate fresh buffers. - // This can recover max_alloc when the internal heap was fragmented (e.g. after poor - // WiFi, failed publishes, and reconnect). - if (_mqtt_client) { - if (_mqtt_client->connected()) _mqtt_client->disconnect(); - #ifdef ESP_PLATFORM - vTaskDelay(pdMS_TO_TICKS(100)); - #else - delay(100); - #endif - delete _mqtt_client; - _mqtt_client = nullptr; - } - if (_analyzer_us_client) { - if (_analyzer_us_client->connected()) _analyzer_us_client->disconnect(); - delete _analyzer_us_client; - _analyzer_us_client = nullptr; - } - if (_analyzer_eu_client) { - if (_analyzer_eu_client->connected()) _analyzer_eu_client->disconnect(); - delete _analyzer_eu_client; - _analyzer_eu_client = nullptr; - } - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].enabled) { - _brokers[i].connected = false; - _brokers[i].initial_connect_done = false; - _brokers[i].last_attempt = 0; + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].enabled) { + teardownSlot(i); + setupSlot(i); } } - _active_brokers = 0; - _cached_has_brokers = false; - _cached_has_analyzer_servers = false; - setupAnalyzerServers(); - // Recreate analyzer client objects (we just set them to nullptr above; setupAnalyzerServers - // only calls setupAnalyzerClients when enabled flags change, so we must call it explicitly). - setupAnalyzerClients(); + updateCachedConnectionStatus(); } -void MQTTBridge::connectToBrokers() { - // Recreate main client if it was deleted during reinit (allows fresh heap allocations) - ensureMainMqttClient(); - // Check if MQTT configuration is valid before attempting connection - if (!_config_valid) { +// --------------------------------------------------------------------------- +// NTP time sync +// --------------------------------------------------------------------------- + +void MQTTBridge::syncTimeWithNTP() { + if (!WiFi.isConnected()) { + MQTT_DEBUG_PRINTLN("Cannot sync time - WiFi not connected"); return; } - - // Check WiFi status first - don't attempt MQTT connection if WiFi is disconnected - if (WiFi.status() != WL_CONNECTED) { - // WiFi is not connected - skip MQTT connection attempts - // WiFi auto-reconnect will handle WiFi, then we can connect MQTT - static unsigned long last_wifi_warning = 0; - unsigned long now = millis(); - if (now - last_wifi_warning > 300000) { // Log every 5 minutes max - MQTT_DEBUG_PRINTLN("Skipping MQTT broker connection - WiFi not connected"); - last_wifi_warning = now; - } - return; - } - - // Main broker reconnect uses exponential backoff: 15s, 30s, 60s, 120s, 300s (reset on connect) - static const unsigned long MAIN_BROKER_BACKOFF_MS[] = { 15000, 30000, 60000, 120000, 300000 }; - // For now, connect to the first enabled broker - // TODO: Implement multi-broker support with PsychicMqttClient - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (!_brokers[i].enabled) continue; - - // Only call connect() once for initial connection. - // After that, ESP-IDF's auto-reconnect handles reconnection automatically. - // If we forced disconnect (e.g. on publish failure), we must call connect() again - // since disconnect() stops the client; use exponential backoff to avoid reconnect storms. - if (!_brokers[i].initial_connect_done) { - MQTT_DEBUG_PRINTLN("Initial connection to broker %d: %s:%d", i, _brokers[i].host, _brokers[i].port); - - // Set broker URI and connect using PsychicMqttClient API - char broker_uri[128]; - snprintf(broker_uri, sizeof(broker_uri), "mqtt://%s:%d", _brokers[i].host, _brokers[i].port); - _mqtt_client->setServer(broker_uri); - - // Set credentials if provided - if (strlen(_brokers[i].username) > 0) { - _mqtt_client->setCredentials(_brokers[i].username, _brokers[i].password); - } - - // Connect to the broker (PsychicMqttClient uses async connection) - // ESP-IDF MQTT client has auto-reconnect enabled by default (10s retry) - _mqtt_client->connect(); - - _brokers[i].initial_connect_done = true; - _brokers[i].last_attempt = millis(); - MQTT_DEBUG_PRINTLN("Initiated connection to broker %d (auto-reconnect will handle future reconnections)", i); - } else if (_mqtt_client && !_mqtt_client->connected()) { - unsigned long now = millis(); - unsigned long reconnect_elapsed = (_brokers[i].last_attempt <= now) - ? (now - _brokers[i].last_attempt) - : (ULONG_MAX - _brokers[i].last_attempt + now + 1); - unsigned int idx = (_main_broker_reconnect_backoff_attempt < 5) ? _main_broker_reconnect_backoff_attempt : 4; - unsigned long delay_ms = MAIN_BROKER_BACKOFF_MS[idx]; - if (reconnect_elapsed >= delay_ms) { - MQTT_DEBUG_PRINTLN("Reconnecting to broker %d: %s:%d (backoff)", i, _brokers[i].host, _brokers[i].port); - char broker_uri[128]; - snprintf(broker_uri, sizeof(broker_uri), "mqtt://%s:%d", _brokers[i].host, _brokers[i].port); - _mqtt_client->setServer(broker_uri); - if (strlen(_brokers[i].username) > 0) { - _mqtt_client->setCredentials(_brokers[i].username, _brokers[i].password); - } - _mqtt_client->connect(); - _brokers[i].last_attempt = now; - if (_main_broker_reconnect_backoff_attempt < 5) { - _main_broker_reconnect_backoff_attempt++; - } - } - } - - // Note: Connection state (_brokers[i].connected) is updated by onConnect/onDisconnect callbacks - // We just update the cached status here for consistency - _cached_has_brokers = isAnyBrokerConnected(); - } - - // Update cached broker status after connection attempts - _cached_has_brokers = isAnyBrokerConnected(); -} - -void MQTTBridge::processPacketQueue() { - #ifdef ESP_PLATFORM - // Use FreeRTOS queue - if (_packet_queue_handle == nullptr) { - return; - } - - // Update queue count from actual queue state - _queue_count = uxQueueMessagesWaiting(_packet_queue_handle); - - if (_queue_count == 0) { - return; - } - - // Use cached broker connection status to avoid redundant checks - bool has_connected_brokers = _cached_has_brokers || _cached_has_analyzer_servers; - - if (!has_connected_brokers) { - if (_queue_count > 0) { - unsigned long now = millis(); - if (now - _last_no_broker_log > NO_BROKER_LOG_INTERVAL) { - MQTT_DEBUG_PRINTLN("Queue has %d packets but no brokers connected", _queue_count); - _last_no_broker_log = now; - } - } - return; - } - - _last_no_broker_log = 0; - - // Process up to 1 packet per call to maintain responsiveness - int processed = 0; - int max_per_loop = 1; - unsigned long loop_start_time = millis(); - const unsigned long MAX_PROCESSING_TIME_MS = 30; - - while (processed < max_per_loop) { - unsigned long elapsed = millis() - loop_start_time; - if (elapsed > MAX_PROCESSING_TIME_MS) { - break; - } - - QueuedPacket queued; - // Try to receive from queue (non-blocking) - if (xQueueReceive(_packet_queue_handle, &queued, 0) != pdTRUE) { - break; // No more packets - } - - // Publish packet (use stored raw data if available) - publishPacket(queued.packet, queued.is_tx, - queued.has_raw_data ? queued.raw_data : nullptr, - queued.has_raw_data ? queued.raw_len : 0, - queued.has_raw_data ? queued.snr : 0.0f, - queued.has_raw_data ? queued.rssi : 0.0f); - - // Publish raw if enabled - if (_raw_enabled) { - publishRaw(queued.packet); - } - - // NOTE: Do NOT free the packet here - the Dispatcher owns and frees it after logRx() returns. - // The MQTT bridge only stores a pointer to read from; it does not own the packet. - queued.packet = nullptr; - - _queue_count--; - processed++; - - // No need for vTaskDelay here - task already yields at end of main loop - } - #else - // Non-ESP32: Use circular buffer - if (_queue_count == 0) { - return; - } - - // Use cached broker connection status to avoid redundant checks - bool has_connected_brokers = _cached_has_brokers || _cached_has_analyzer_servers; - - if (!has_connected_brokers) { - if (_queue_count > 0) { - unsigned long now = millis(); - if (now - _last_no_broker_log > NO_BROKER_LOG_INTERVAL) { - MQTT_DEBUG_PRINTLN("Queue has %d packets but no brokers connected", _queue_count); - _last_no_broker_log = now; - } - } - return; - } - - _last_no_broker_log = 0; - - int processed = 0; - int max_per_loop = 1; - unsigned long loop_start_time = millis(); - const unsigned long MAX_PROCESSING_TIME_MS = 30; - - while (_queue_count > 0 && processed < max_per_loop) { - unsigned long elapsed = millis() - loop_start_time; - if (elapsed > MAX_PROCESSING_TIME_MS) { - break; - } - - QueuedPacket& queued = _packet_queue[_queue_head]; - - publishPacket(queued.packet, queued.is_tx, - queued.has_raw_data ? queued.raw_data : nullptr, - queued.has_raw_data ? queued.raw_len : 0, - queued.has_raw_data ? queued.snr : 0.0f, - queued.has_raw_data ? queued.rssi : 0.0f); - - if (_raw_enabled) { - publishRaw(queued.packet); - } - - // NOTE: Do NOT free the packet here - the Dispatcher owns and frees it after logRx() returns. - queued.packet = nullptr; - - dequeuePacket(); - processed++; - } - #endif -} - -bool MQTTBridge::publishStatus() { - // Check if IATA is configured before attempting to publish - if (!isIATAValid()) { - static unsigned long last_iata_warning = 0; - unsigned long now = millis(); - // Only log this warning every 5 minutes to avoid spam - if (now - last_iata_warning > 300000) { - MQTT_DEBUG_PRINTLN("MQTT: Cannot publish status - IATA code not configured (current: '%s'). Please set mqtt.iata via CLI.", _iata); - last_iata_warning = now; - } - return false; - } - - // Allow status publish even when max_alloc is low; buffer is PSRAM, so attempt may succeed. - // Recovery can be triggered after a successful publish (see task loop). - - // Use cached destination status to avoid redundant checks - // Note: Connection state is verified in connectToBrokers() which runs before publishStatus() - bool has_custom_brokers = _cached_has_brokers && _config_valid; - bool has_destinations = has_custom_brokers || _cached_has_analyzer_servers; - - if (!has_destinations) { - return false; // No destinations available - } - - // JSON buffer in PSRAM when available (plan §4) - static const size_t STATUS_JSON_BUFFER_SIZE = 768; - char* json_buffer = (char*)psram_malloc(STATUS_JSON_BUFFER_SIZE); - if (json_buffer == nullptr) { - return false; - } - char origin_id[65]; - char timestamp[32]; - char radio_info[64]; - - // Get current timestamp in ISO 8601 format - struct tm timeinfo; - if (getLocalTime(&timeinfo)) { - strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S.000000", &timeinfo); - } else { - strcpy(timestamp, "2024-01-01T12:00:00.000000"); - } - - // Build radio info string (freq,bw,sf,cr) - snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d", - _prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr); - - // Use actual device ID - strncpy(origin_id, _device_id, sizeof(origin_id) - 1); - origin_id[sizeof(origin_id) - 1] = '\0'; - - // Build client version string - char client_version[64]; - getClientVersion(client_version, sizeof(client_version)); - - // Collect stats on-demand if sources are available - int battery_mv = -1; - int uptime_secs = -1; - int errors = -1; - int noise_floor = -999; - int tx_air_secs = -1; - int rx_air_secs = -1; - int recv_errors = -1; - - if (_board) { - battery_mv = _board->getBattMilliVolts(); - } - if (_ms) { - uptime_secs = _ms->getMillis() / 1000; - } - if (_dispatcher) { - errors = _dispatcher->getErrFlags(); - tx_air_secs = _dispatcher->getTotalAirTime() / 1000; - rx_air_secs = _dispatcher->getReceiveAirTime() / 1000; - } - if (_radio) { - noise_floor = (int16_t)_radio->getNoiseFloor(); - recv_errors = (int)_radio->getPacketsRecvErrors(); - } - - // Build status message with stats - int len = MQTTMessageBuilder::buildStatusMessage( - _origin, - origin_id, - _board_model, // model - now dynamic! - _firmware_version, // firmware version - radio_info, - client_version, // client version - "online", - timestamp, - json_buffer, - STATUS_JSON_BUFFER_SIZE, - battery_mv, - uptime_secs, - errors, - _queue_count, // Use current queue length - noise_floor, - tx_air_secs, - rx_air_secs, - recv_errors - ); - - if (len > 0) { - bool published = false; - - // Build topic string once and reuse (optimization: avoid redundant snprintf calls) - char topic[128]; - snprintf(topic, sizeof(topic), "meshcore/%s/%s/status", _iata, _device_id); - size_t json_len = strlen(json_buffer); // Cache length to avoid multiple strlen() calls - - // Publish to all connected custom brokers - // Use same logic as packet publishes for consistency - if (_config_valid && _mqtt_client) { - // Share the same broker URI tracking as packet publishes to avoid sync issues - // Track last broker URI to avoid calling setServer() unnecessarily (memory optimization) - // setServer() may allocate memory, so we only call it when the broker changes - static char last_broker_uri_shared[128] = ""; - - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - // Verify broker is actually connected (state might be stale) - if (_brokers[i].enabled && _brokers[i].connected) { - // Check connection state right before publish (like packet publishes do) - if (!_mqtt_client->connected()) { - // Connection lost - mark as disconnected but don't disconnect here - // (packet publishes handle this more gracefully) - _brokers[i].connected = false; - _active_brokers--; - _brokers[i].last_attempt = millis(); // Throttle reconnection - _cached_has_brokers = isAnyBrokerConnected(); - continue; - } - - // Build broker URI - char broker_uri[128]; - snprintf(broker_uri, sizeof(broker_uri), "mqtt://%s:%d", _brokers[i].host, _brokers[i].port); - - // Only call setServer() if broker URI changed (reduces memory allocations) - if (strcmp(broker_uri, last_broker_uri_shared) != 0) { - _mqtt_client->setServer(broker_uri); - strncpy(last_broker_uri_shared, broker_uri, sizeof(last_broker_uri_shared) - 1); - last_broker_uri_shared[sizeof(last_broker_uri_shared) - 1] = '\0'; - } - - // Publish with timeout check - don't block if connection is slow - int publish_result = _mqtt_client->publish(topic, 1, true, json_buffer, json_len); - if (publish_result > 0) { - published = true; - s_consecutive_main_publish_failures = 0; - } else { - s_consecutive_main_publish_failures++; - bool should_disconnect = (s_consecutive_main_publish_failures >= MAIN_CLIENT_DISCONNECT_FAILURE_THRESHOLD); - static unsigned long last_status_publish_fail_log = 0; - unsigned long now = millis(); - if (now - last_status_publish_fail_log > 60000) { - MQTT_DEBUG_PRINTLN("Status publish failed (result=%d), failures=%d%s", publish_result, s_consecutive_main_publish_failures, - should_disconnect ? ", forcing reconnect" : ""); - last_status_publish_fail_log = now; - } - if (should_disconnect && _mqtt_client->connected()) { - _mqtt_client->disconnect(); - s_consecutive_main_publish_failures = 0; - } - if (should_disconnect) { - _brokers[i].connected = false; - _active_brokers--; - _brokers[i].last_attempt = millis(); - _cached_has_brokers = isAnyBrokerConnected(); - } - } - } - } - } else if (_config_valid) { - // Connection state is out of sync - mark all brokers as disconnected - // (Same logic as packet publishes) - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].enabled && _brokers[i].connected) { - _brokers[i].connected = false; - _active_brokers--; - } - } - _cached_has_brokers = false; - } - - // Always publish to Let's Mesh Analyzer servers if enabled and connected - // Use shared helper function to publish same JSON to both servers (avoids duplication) - // Use same memory threshold as main check (60000) for consistency - if (_cached_has_analyzer_servers) { - #ifdef ESP32 - size_t max_alloc = ESP.getMaxAllocHeap(); - if (max_alloc >= 60000) { // Same threshold as main memory check - #endif - // publishToAnalyzerServers returns true if at least one publish succeeded - if (publishToAnalyzerServers(topic, json_buffer, true)) { // retained=true for status - published = true; - } - #ifdef ESP32 - } - #endif - } - - // Return true if we successfully published to at least one destination - if (published) { - MQTT_DEBUG_PRINTLN("Status published"); - psram_free(json_buffer); - return true; - } - } - - psram_free(json_buffer); - return false; // Failed to build or publish message -} - -void MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, - const uint8_t* raw_data, int raw_len, - float snr, float rssi) { - if (!packet) return; - - // Check if IATA is configured before attempting to publish - if (!isIATAValid()) { - static unsigned long last_iata_warning = 0; - unsigned long now = millis(); - // Only log this warning every 5 minutes to avoid spam - if (now - last_iata_warning > 300000) { - MQTT_DEBUG_PRINTLN("MQTT: Cannot publish packet - IATA code not configured (current: '%s'). Please set mqtt.iata via CLI.", _iata); - last_iata_warning = now; - } - return; - } - - // Memory pressure check: Skip publishes when heap is severely fragmented - // This prevents further fragmentation and allows memory to recover - // Threshold: Max alloc < 60KB indicates severe fragmentation - #ifdef ESP32 unsigned long now = millis(); - if (now - _last_memory_check > 5000) { // Check every 5 seconds - size_t max_alloc = ESP.getMaxAllocHeap(); - if (max_alloc < 60000) { // Less than 60KB max alloc = severe fragmentation - _skipped_publishes++; - static unsigned long last_skip_log = 0; - if (now - last_skip_log > 60000) { // Log every minute - MQTT_DEBUG_PRINTLN("MQTT: Skipping publish due to memory pressure (Max alloc: %d, skipped: %d)", max_alloc, _skipped_publishes); - last_skip_log = now; - } - return; // Skip this publish to allow memory to recover - } - _last_memory_check = now; - } - #endif - - // JSON buffer: prefer PSRAM to reduce stack (plan §4); fallback to stack if allocation fails - static const size_t PUBLISH_JSON_BUFFER_SIZE = 2048; - char* json_buffer_psram = (char*)psram_malloc(PUBLISH_JSON_BUFFER_SIZE); - char json_buffer_stack[1024]; - char json_buffer_large_stack[2048]; - int packet_size = packet->getRawLength(); - char* active_buffer; - size_t active_buffer_size; - if (json_buffer_psram != nullptr) { - active_buffer = json_buffer_psram; - active_buffer_size = PUBLISH_JSON_BUFFER_SIZE; - } else { - active_buffer = (packet_size > 200) ? json_buffer_large_stack : json_buffer_stack; - active_buffer_size = (packet_size > 200) ? 2048 : 1024; - } - char origin_id[65]; - - // Use actual device ID - strncpy(origin_id, _device_id, sizeof(origin_id) - 1); - origin_id[sizeof(origin_id) - 1] = '\0'; - - // Build packet message using raw radio data if provided - int len; - if (raw_data && raw_len > 0) { - // Use provided raw radio data - len = MQTTMessageBuilder::buildPacketJSONFromRaw( - raw_data, raw_len, packet, is_tx, _origin, origin_id, - snr, rssi, _timezone, active_buffer, active_buffer_size - ); - } else if (_last_raw_data && _last_raw_len > 0 && (millis() - _last_raw_timestamp) < 1000) { - // Fallback to global raw radio data (within 1 second of packet) - len = MQTTMessageBuilder::buildPacketJSONFromRaw( - _last_raw_data, _last_raw_len, packet, is_tx, _origin, origin_id, - _last_snr, _last_rssi, _timezone, active_buffer, active_buffer_size - ); - } else { - // Fallback to reconstructed packet data - len = MQTTMessageBuilder::buildPacketJSON( - packet, is_tx, _origin, origin_id, _timezone, active_buffer, active_buffer_size - ); - } - - if (len > 0) { - // Build topic string once and reuse (optimization: avoid redundant snprintf calls) - char topic[128]; - snprintf(topic, sizeof(topic), "meshcore/%s/%s/packets", _iata, _device_id); - size_t json_len = strlen(active_buffer); // Cache length to avoid multiple strlen() calls - - // Publish to custom brokers (only if config is valid) - // Double-check client is actually connected before attempting publish - if (_config_valid && _mqtt_client && _mqtt_client->connected()) { - // Track last broker URI to avoid calling setServer() unnecessarily (memory optimization) - // setServer() may allocate memory, so we only call it when the broker changes - static char last_broker_uri[128] = ""; - - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - // Verify broker is actually connected (state might be stale) - if (_brokers[i].enabled && _brokers[i].connected && _mqtt_client->connected()) { - // Build broker URI - char broker_uri[128]; - snprintf(broker_uri, sizeof(broker_uri), "mqtt://%s:%d", _brokers[i].host, _brokers[i].port); - - // Only call setServer() if broker URI changed (reduces memory allocations) - if (strcmp(broker_uri, last_broker_uri) != 0) { - _mqtt_client->setServer(broker_uri); - strncpy(last_broker_uri, broker_uri, sizeof(last_broker_uri) - 1); - last_broker_uri[sizeof(last_broker_uri) - 1] = '\0'; - } - - // Publish with timeout check - don't block if connection is slow - // This prevents blocking the main loop when MQTT broker is slow or unresponsive - int publish_result = _mqtt_client->publish(topic, 1, false, active_buffer, json_len); // qos=1, retained=false - if (publish_result > 0) { - s_consecutive_main_publish_failures = 0; - } else { - s_consecutive_main_publish_failures++; - // Only disconnect after several consecutive failures to avoid heap fragmentation from disconnect/reconnect storms - bool should_disconnect = (s_consecutive_main_publish_failures >= MAIN_CLIENT_DISCONNECT_FAILURE_THRESHOLD); - static unsigned long last_publish_fail_log = 0; - unsigned long now = millis(); - if (now - last_publish_fail_log > 60000) { - MQTT_DEBUG_PRINTLN("Publish failed (result=%d), failures=%d%s", publish_result, s_consecutive_main_publish_failures, - should_disconnect ? ", forcing reconnect" : ""); - last_publish_fail_log = now; - } - if (should_disconnect && _mqtt_client->connected()) { - _mqtt_client->disconnect(); - s_consecutive_main_publish_failures = 0; - } - if (should_disconnect) { - _brokers[i].connected = false; - _active_brokers--; - _brokers[i].last_attempt = millis(); - _cached_has_brokers = isAnyBrokerConnected(); - } - } - } - } - } else if (_config_valid) { - // Connection state is out of sync - mark all brokers as disconnected - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].enabled && _brokers[i].connected) { - _brokers[i].connected = false; - _active_brokers--; - } - } - } - - // Always publish to Let's Mesh Analyzer servers (independent of custom broker config) - // Skip analyzer servers if memory is severely fragmented (they're less critical than custom brokers) - #ifdef ESP32 - size_t max_alloc = ESP.getMaxAllocHeap(); - if (max_alloc >= 60000) { // Only publish to analyzer servers if memory is OK - publishToAnalyzerServers(topic, active_buffer, false); - } - #else - publishToAnalyzerServers(topic, active_buffer, false); - #endif - } else { - // Debug: log when packet message building fails - uint8_t packet_type = packet->getPayloadType(); - if (packet_type == 4 || packet_type == 9) { // ADVERT or TRACE - MQTT_DEBUG_PRINTLN("Failed to build packet JSON for type=%d (len=%d), packet not published", packet_type, len); - } - } - psram_free(json_buffer_psram); -} - -void MQTTBridge::publishRaw(mesh::Packet* packet) { - if (!packet) return; - - // Check if IATA is configured before attempting to publish - if (!isIATAValid()) { - static unsigned long last_iata_warning = 0; - unsigned long now = millis(); - // Only log this warning every 5 minutes to avoid spam - if (now - last_iata_warning > 300000) { - MQTT_DEBUG_PRINTLN("MQTT: Cannot publish raw packet - IATA code not configured (current: '%s'). Please set mqtt.iata via CLI.", _iata); - last_iata_warning = now; - } + if (_ntp_synced && (now - _last_ntp_sync) < 5000) { return; } - - // JSON buffer: prefer PSRAM (plan §4); fallback to stack if allocation fails - char* json_buffer_psram = (char*)psram_malloc(2048); - char json_buffer_stack[1024]; - char json_buffer_large_stack[2048]; - int packet_size = packet->getRawLength(); - char* active_buffer; - size_t active_buffer_size; - if (json_buffer_psram != nullptr) { - active_buffer = json_buffer_psram; - active_buffer_size = 2048; - } else { - active_buffer = (packet_size > 200) ? json_buffer_large_stack : json_buffer_stack; - active_buffer_size = (packet_size > 200) ? 2048 : 1024; + + static bool sync_in_progress = false; + if (sync_in_progress) { + return; } - char origin_id[65]; - - // Use actual device ID - strncpy(origin_id, _device_id, sizeof(origin_id) - 1); - origin_id[sizeof(origin_id) - 1] = '\0'; - - // Build raw message - int len = MQTTMessageBuilder::buildRawJSON( - packet, _origin, origin_id, _timezone, active_buffer, active_buffer_size - ); - - if (len > 0) { - // Build topic string once and reuse (optimization: avoid redundant snprintf calls) - char topic[128]; - snprintf(topic, sizeof(topic), "meshcore/%s/%s/raw", _iata, _device_id); - size_t json_len = strlen(active_buffer); // Cache length to avoid multiple strlen() calls - - // Publish to custom brokers (only if config is valid) - // Double-check client is actually connected before attempting publish - if (_config_valid && _mqtt_client && _mqtt_client->connected()) { - // Track last broker URI to avoid calling setServer() unnecessarily (memory optimization) - // setServer() may allocate memory, so we only call it when the broker changes - static char last_broker_uri_raw[128] = ""; - - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - // Verify broker is actually connected (state might be stale) - if (_brokers[i].enabled && _brokers[i].connected && _mqtt_client->connected()) { - // Build broker URI - char broker_uri[128]; - snprintf(broker_uri, sizeof(broker_uri), "mqtt://%s:%d", _brokers[i].host, _brokers[i].port); - - // Only call setServer() if broker URI changed (reduces memory allocations) - if (strcmp(broker_uri, last_broker_uri_raw) != 0) { - _mqtt_client->setServer(broker_uri); - strncpy(last_broker_uri_raw, broker_uri, sizeof(last_broker_uri_raw) - 1); - last_broker_uri_raw[sizeof(last_broker_uri_raw) - 1] = '\0'; - } - - // Publish with timeout check - don't block if connection is slow - int publish_result = _mqtt_client->publish(topic, 1, false, active_buffer, json_len); // qos=1, retained=false - if (publish_result > 0) { - s_consecutive_main_publish_failures = 0; - } else { - s_consecutive_main_publish_failures++; - bool should_disconnect = (s_consecutive_main_publish_failures >= MAIN_CLIENT_DISCONNECT_FAILURE_THRESHOLD); - static unsigned long last_raw_publish_fail_log = 0; - unsigned long now = millis(); - if (now - last_raw_publish_fail_log > 60000) { - MQTT_DEBUG_PRINTLN("Raw publish failed (result=%d), failures=%d%s", publish_result, s_consecutive_main_publish_failures, - should_disconnect ? ", forcing reconnect" : ""); - last_raw_publish_fail_log = now; - } - if (should_disconnect && _mqtt_client->connected()) { - _mqtt_client->disconnect(); - s_consecutive_main_publish_failures = 0; - } - if (should_disconnect) { - _brokers[i].connected = false; - _active_brokers--; - _brokers[i].last_attempt = millis(); - _cached_has_brokers = isAnyBrokerConnected(); - } - } - } + sync_in_progress = true; + + MQTT_DEBUG_PRINTLN("Syncing time with NTP..."); + + #ifdef ESP_PLATFORM + IPAddress resolved_ip; + if (!WiFi.hostByName("pool.ntp.org", resolved_ip)) { + MQTT_DEBUG_PRINTLN("WARNING: DNS resolution failed for pool.ntp.org - NTP sync may fail"); + } + #endif + + bool ntp_ok = false; + unsigned long epochTime = 0; + const unsigned long kMinValidEpoch = 1704067200; // 2024-01-01 00:00:00 UTC + + _ntp_client.begin(); + const int kMaxNtpRetries = 3; + for (int attempt = 1; attempt <= kMaxNtpRetries && !ntp_ok; attempt++) { + if (attempt > 1) { + MQTT_DEBUG_PRINTLN("NTP retry %d/%d...", attempt, kMaxNtpRetries); + delay(1000); + } + if (_ntp_client.forceUpdate()) { + epochTime = _ntp_client.getEpochTime(); + if (epochTime >= kMinValidEpoch) { + ntp_ok = true; } } - - // Always publish to Let's Mesh Analyzer servers (independent of custom broker config) - // Skip analyzer servers if memory is severely fragmented (they're less critical than custom brokers) - #ifdef ESP32 - size_t max_alloc = ESP.getMaxAllocHeap(); - if (max_alloc >= 60000) { // Only publish to analyzer servers if memory is OK - publishToAnalyzerServers(topic, active_buffer, false); + } + _ntp_client.end(); + + // Fallback: use ESP32 built-in SNTP (configTime) when NTPClient fails + #ifdef ESP_PLATFORM + if (!ntp_ok) { + MQTT_DEBUG_PRINTLN("NTP client failed, trying SNTP fallback..."); + configTime(0, 0, "pool.ntp.org"); + for (int i = 0; i < 20; i++) { + delay(500); + epochTime = (unsigned long)time(nullptr); + if (epochTime >= kMinValidEpoch) { + ntp_ok = true; + MQTT_DEBUG_PRINTLN("SNTP fallback succeeded: %lu", epochTime); + break; + } } - #else - publishToAnalyzerServers(topic, active_buffer, false); + } + #endif + + if (ntp_ok) { + configTime(0, 0, "pool.ntp.org"); + + if (_rtc) { + _rtc->setCurrentTime(epochTime); + } + + bool was_ntp_synced = _ntp_synced; + _ntp_synced = true; + _last_ntp_sync = millis(); + sync_in_progress = false; + + MQTT_DEBUG_PRINTLN("Time synced: %lu", epochTime); + + // Note: Slot setup (including token creation) is deferred until after NTP sync + // via _slots_setup_done flag in the main loop, so no token fixup needed here. + + // Set timezone from string (with DST support) - only if changed + static char last_timezone[64] = ""; + if (strcmp(_prefs->timezone_string, last_timezone) != 0) { + if (_timezone) { + delete _timezone; + _timezone = nullptr; + } + Timezone* tz = createTimezoneFromString(_prefs->timezone_string); + if (tz) { + _timezone = tz; + } else { + TimeChangeRule utc = {"UTC", Last, Sun, Mar, 0, 0}; + _timezone = new Timezone(utc, utc); + } + strncpy(last_timezone, _prefs->timezone_string, sizeof(last_timezone) - 1); + last_timezone[sizeof(last_timezone) - 1] = '\0'; + } + + (void)gmtime((time_t*)&epochTime); + (void)localtime((time_t*)&epochTime); + } else { + MQTT_DEBUG_PRINTLN("NTP sync failed"); + sync_in_progress = false; + } +} + +// --------------------------------------------------------------------------- +// Timezone helper +// --------------------------------------------------------------------------- + +Timezone* MQTTBridge::createTimezoneFromString(const char* tz_string) { + // Create Timezone objects for common IANA timezone strings + + // North America + if (strcmp(tz_string, "America/Los_Angeles") == 0 || strcmp(tz_string, "America/Vancouver") == 0) { + TimeChangeRule pst = {"PST", First, Sun, Nov, 2, -480}; // UTC-8 + TimeChangeRule pdt = {"PDT", Second, Sun, Mar, 2, -420}; // UTC-7 + return new Timezone(pdt, pst); + } else if (strcmp(tz_string, "America/Denver") == 0) { + TimeChangeRule mst = {"MST", First, Sun, Nov, 2, -420}; // UTC-7 + TimeChangeRule mdt = {"MDT", Second, Sun, Mar, 2, -360}; // UTC-6 + return new Timezone(mdt, mst); + } else if (strcmp(tz_string, "America/Chicago") == 0) { + TimeChangeRule cst = {"CST", First, Sun, Nov, 2, -360}; // UTC-6 + TimeChangeRule cdt = {"CDT", Second, Sun, Mar, 2, -300}; // UTC-5 + return new Timezone(cdt, cst); + } else if (strcmp(tz_string, "America/New_York") == 0 || strcmp(tz_string, "America/Toronto") == 0) { + TimeChangeRule est = {"EST", First, Sun, Nov, 2, -300}; // UTC-5 + TimeChangeRule edt = {"EDT", Second, Sun, Mar, 2, -240}; // UTC-4 + return new Timezone(edt, est); + } else if (strcmp(tz_string, "America/Anchorage") == 0) { + TimeChangeRule akst = {"AKST", First, Sun, Nov, 2, -540}; // UTC-9 + TimeChangeRule akdt = {"AKDT", Second, Sun, Mar, 2, -480}; // UTC-8 + return new Timezone(akdt, akst); + } else if (strcmp(tz_string, "Pacific/Honolulu") == 0) { + TimeChangeRule hst = {"HST", Last, Sun, Oct, 2, -600}; // UTC-10 (no DST) + return new Timezone(hst, hst); + + // Europe + } else if (strcmp(tz_string, "Europe/London") == 0) { + TimeChangeRule gmt = {"GMT", Last, Sun, Oct, 2, 0}; // UTC+0 + TimeChangeRule bst = {"BST", Last, Sun, Mar, 1, 60}; // UTC+1 + return new Timezone(bst, gmt); + } else if (strcmp(tz_string, "Europe/Paris") == 0 || strcmp(tz_string, "Europe/Berlin") == 0) { + TimeChangeRule cet = {"CET", Last, Sun, Oct, 3, 60}; // UTC+1 + TimeChangeRule cest = {"CEST", Last, Sun, Mar, 2, 120}; // UTC+2 + return new Timezone(cest, cet); + } else if (strcmp(tz_string, "Europe/Moscow") == 0) { + TimeChangeRule msk = {"MSK", Last, Sun, Oct, 3, 180}; // UTC+3 (no DST since 2014) + return new Timezone(msk, msk); + + // Asia + } else if (strcmp(tz_string, "Asia/Tokyo") == 0) { + TimeChangeRule jst = {"JST", Last, Sun, Oct, 2, 540}; // UTC+9 (no DST) + return new Timezone(jst, jst); + } else if (strcmp(tz_string, "Asia/Shanghai") == 0 || strcmp(tz_string, "Asia/Hong_Kong") == 0) { + TimeChangeRule cst = {"CST", Last, Sun, Oct, 2, 480}; // UTC+8 (no DST) + return new Timezone(cst, cst); + } else if (strcmp(tz_string, "Asia/Kolkata") == 0) { + TimeChangeRule ist = {"IST", Last, Sun, Oct, 2, 330}; // UTC+5:30 (no DST) + return new Timezone(ist, ist); + } else if (strcmp(tz_string, "Asia/Dubai") == 0) { + TimeChangeRule gst = {"GST", Last, Sun, Oct, 2, 240}; // UTC+4 (no DST) + return new Timezone(gst, gst); + + // Australia + } else if (strcmp(tz_string, "Australia/Sydney") == 0 || strcmp(tz_string, "Australia/Melbourne") == 0) { + TimeChangeRule aest = {"AEST", First, Sun, Apr, 3, 600}; // UTC+10 + TimeChangeRule aedt = {"AEDT", First, Sun, Oct, 2, 660}; // UTC+11 + return new Timezone(aedt, aest); + } else if (strcmp(tz_string, "Australia/Perth") == 0) { + TimeChangeRule awst = {"AWST", Last, Sun, Oct, 2, 480}; // UTC+8 (no DST) + return new Timezone(awst, awst); + + // Timezone abbreviations (with DST handling) + } else if (strcmp(tz_string, "PDT") == 0 || strcmp(tz_string, "PST") == 0) { + TimeChangeRule pst = {"PST", First, Sun, Nov, 2, -480}; + TimeChangeRule pdt = {"PDT", Second, Sun, Mar, 2, -420}; + return new Timezone(pdt, pst); + } else if (strcmp(tz_string, "MDT") == 0 || strcmp(tz_string, "MST") == 0) { + TimeChangeRule mst = {"MST", First, Sun, Nov, 2, -420}; + TimeChangeRule mdt = {"MDT", Second, Sun, Mar, 2, -360}; + return new Timezone(mdt, mst); + } else if (strcmp(tz_string, "CDT") == 0 || strcmp(tz_string, "CST") == 0) { + TimeChangeRule cst = {"CST", First, Sun, Nov, 2, -360}; + TimeChangeRule cdt = {"CDT", Second, Sun, Mar, 2, -300}; + return new Timezone(cdt, cst); + } else if (strcmp(tz_string, "EDT") == 0 || strcmp(tz_string, "EST") == 0) { + TimeChangeRule est = {"EST", First, Sun, Nov, 2, -300}; + TimeChangeRule edt = {"EDT", Second, Sun, Mar, 2, -240}; + return new Timezone(edt, est); + } else if (strcmp(tz_string, "BST") == 0 || strcmp(tz_string, "GMT") == 0) { + TimeChangeRule gmt = {"GMT", Last, Sun, Oct, 2, 0}; + TimeChangeRule bst = {"BST", Last, Sun, Mar, 1, 60}; + return new Timezone(bst, gmt); + } else if (strcmp(tz_string, "CEST") == 0 || strcmp(tz_string, "CET") == 0) { + TimeChangeRule cet = {"CET", Last, Sun, Oct, 3, 60}; + TimeChangeRule cest = {"CEST", Last, Sun, Mar, 2, 120}; + return new Timezone(cest, cet); + + // UTC and simple offsets + } else if (strcmp(tz_string, "UTC") == 0) { + TimeChangeRule utc = {"UTC", Last, Sun, Mar, 0, 0}; + return new Timezone(utc, utc); + } else if (strncmp(tz_string, "UTC", 3) == 0) { + int offset = atoi(tz_string + 3); + TimeChangeRule utc_offset = {"UTC", Last, Sun, Mar, 0, offset * 60}; + return new Timezone(utc_offset, utc_offset); + } else if (strncmp(tz_string, "GMT", 3) == 0) { + int offset = atoi(tz_string + 3); + TimeChangeRule gmt_offset = {"GMT", Last, Sun, Mar, 0, offset * 60}; + return new Timezone(gmt_offset, gmt_offset); + } else if (strncmp(tz_string, "+", 1) == 0 || strncmp(tz_string, "-", 1) == 0) { + int offset = atoi(tz_string); + TimeChangeRule offset_tz = {"TZ", Last, Sun, Mar, 0, offset * 60}; + return new Timezone(offset_tz, offset_tz); + } else { + MQTT_DEBUG_PRINTLN("Unknown timezone: %s", tz_string); + return nullptr; + } +} + +// --------------------------------------------------------------------------- +// Utility methods +// --------------------------------------------------------------------------- + +void MQTTBridge::getClientVersion(char* buffer, size_t buffer_size) const { + if (!buffer || buffer_size == 0) { + return; + } + snprintf(buffer, buffer_size, "meshcore/%s", _firmware_version); +} + +void MQTTBridge::optimizeMqttClientConfig(PsychicMqttClient* client, bool needs_large_buffer) { + if (!client) return; + + // Keepalive 45s: Cloudflare closes WebSocket connections after 100s idle (non-configurable). + client->setKeepAlive(45); + + // Use a single buffer size for all clients to reduce heap fragmentation. + // 896 is the minimum safe size for JWT clients (CONNECT + 768-byte JWT). + static const int MQTT_CLIENT_BUFFER_SIZE = 896; + + client->setBufferSize(MQTT_CLIENT_BUFFER_SIZE); + + // Access ESP-IDF config to optimize additional settings + esp_mqtt_client_config_t* config = client->getMqttConfig(); + if (config) { + #if defined(ESP_IDF_VERSION_MAJOR) && ESP_IDF_VERSION_MAJOR >= 5 + if (config->buffer.out_size == 0 || config->buffer.out_size > MQTT_CLIENT_BUFFER_SIZE) { + config->buffer.out_size = MQTT_CLIENT_BUFFER_SIZE; + } #endif } - psram_free(json_buffer_psram); } -void MQTTBridge::queuePacket(mesh::Packet* packet, bool is_tx) { - #ifdef ESP_PLATFORM - // Use FreeRTOS queue for thread-safe operation - if (_packet_queue_handle == nullptr) { - return; // Queue not initialized - } - - QueuedPacket queued; - memset(&queued, 0, sizeof(QueuedPacket)); - - queued.packet = packet; - queued.timestamp = millis(); - queued.is_tx = is_tx; - queued.has_raw_data = false; - - // Capture raw radio data with mutex protection - // Use non-blocking mutex to prevent Core 1 from blocking - if mutex is busy, skip raw data - if (!is_tx) { - if (xSemaphoreTake(_raw_data_mutex, 0) == pdTRUE) { - unsigned long current_time = millis(); - if (_last_raw_len > 0 && (current_time - _last_raw_timestamp) < 1000) { - if (_last_raw_data && _last_raw_len <= sizeof(queued.raw_data)) { - memcpy(queued.raw_data, _last_raw_data, _last_raw_len); - queued.raw_len = _last_raw_len; - queued.snr = _last_snr; - queued.rssi = _last_rssi; - queued.has_raw_data = true; - } - } - xSemaphoreGive(_raw_data_mutex); - } - // If mutex unavailable, packet is queued without raw data (acceptable trade-off for responsiveness) - } - - // Try to send to queue (non-blocking) - if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) { - // Queue full - try to remove oldest packet - QueuedPacket oldest; - if (xQueueReceive(_packet_queue_handle, &oldest, 0) == pdTRUE) { - // NOTE: Do NOT free oldest.packet - the Dispatcher owns and frees it. - // We just drop our reference to it. - MQTT_DEBUG_PRINTLN("Queue full, dropping oldest packet reference"); - // Now try to send again - if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) { - MQTT_DEBUG_PRINTLN("Failed to queue packet after dropping oldest"); - return; - } - } else { - MQTT_DEBUG_PRINTLN("Queue full and cannot remove oldest packet"); - return; - } - } - - // Update queue count (approximate, since we can't atomically update it) - UBaseType_t queue_messages = uxQueueMessagesWaiting(_packet_queue_handle); - _queue_count = queue_messages; - #else - // Non-ESP32: Use circular buffer - if (_queue_count >= MAX_QUEUE_SIZE) { - QueuedPacket& oldest = _packet_queue[_queue_head]; - // NOTE: Do NOT free oldest.packet - the Dispatcher owns and frees it. - // We just drop our reference to it. - MQTT_DEBUG_PRINTLN("Queue full, dropping oldest packet reference (queue size: %d)", _queue_count); - oldest.packet = nullptr; - dequeuePacket(); - } - - QueuedPacket& queued = _packet_queue[_queue_tail]; - memset(&queued, 0, sizeof(QueuedPacket)); - - queued.packet = packet; - queued.timestamp = millis(); - queued.is_tx = is_tx; - queued.has_raw_data = false; - - if (!is_tx && _last_raw_data && _last_raw_len > 0 && (millis() - _last_raw_timestamp) < 1000) { - if (_last_raw_len <= sizeof(queued.raw_data)) { - memcpy(queued.raw_data, _last_raw_data, _last_raw_len); - queued.raw_len = _last_raw_len; - queued.snr = _last_snr; - queued.rssi = _last_rssi; - queued.has_raw_data = true; - } - } - - _queue_tail = (_queue_tail + 1) % MAX_QUEUE_SIZE; - _queue_count++; - #endif +void MQTTBridge::logMemoryStatus() { + MQTT_DEBUG_PRINTLN("Memory: Free=%d, Max=%d, Queue=%d/%d", + ESP.getFreeHeap(), ESP.getMaxAllocHeap(), _queue_count, MAX_QUEUE_SIZE); } -void MQTTBridge::dequeuePacket() { - #ifdef ESP_PLATFORM - // On ESP32, dequeuePacket() is not used - we use FreeRTOS queue operations directly - // This method should never be called on ESP32 - return; - #else - // Non-ESP32: Use circular buffer - if (_queue_count == 0) return; - - // Clear the dequeued packet structure to free memory and prevent stale data - QueuedPacket& dequeued = _packet_queue[_queue_head]; - memset(&dequeued, 0, sizeof(QueuedPacket)); - dequeued.has_raw_data = false; // Explicitly set after memset - - _queue_head = (_queue_head + 1) % MAX_QUEUE_SIZE; - _queue_count--; - #endif -} - -bool MQTTBridge::isAnyBrokerConnected() { - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].enabled && _brokers[i].connected) { - return true; - } - } - return false; -} - -void MQTTBridge::setBrokerDefaults() { - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - memset(&_brokers[i], 0, sizeof(MQTTBroker)); - _brokers[i].port = 1883; - _brokers[i].qos = 0; - _brokers[i].enabled = false; - _brokers[i].connected = false; - _brokers[i].initial_connect_done = false; - _brokers[i].reconnect_interval = 5000; // 5 seconds - } -} - -void MQTTBridge::setBroker(int broker_index, const char* host, uint16_t port, - const char* username, const char* password, bool enabled) { - if (broker_index < 0 || broker_index >= MAX_MQTT_BROKERS_COUNT) return; - - MQTTBroker& broker = _brokers[broker_index]; - strncpy(broker.host, host, sizeof(broker.host) - 1); - broker.port = port; - strncpy(broker.username, username, sizeof(broker.username) - 1); - strncpy(broker.password, password, sizeof(broker.password) - 1); - broker.enabled = enabled; - broker.connected = false; - broker.reconnect_interval = 5000; -} +// --------------------------------------------------------------------------- +// Setters and accessors +// --------------------------------------------------------------------------- void MQTTBridge::setOrigin(const char* origin) { strncpy(_origin, origin, sizeof(_origin) - 1); @@ -2084,7 +2218,6 @@ void MQTTBridge::setOrigin(const char* origin) { void MQTTBridge::setIATA(const char* iata) { strncpy(_iata, iata, sizeof(_iata) - 1); _iata[sizeof(_iata) - 1] = '\0'; - // Convert IATA code to uppercase (IATA codes are conventionally uppercase) for (int i = 0; _iata[i]; i++) { _iata[i] = toupper(_iata[i]); } @@ -2111,725 +2244,6 @@ void MQTTBridge::setBuildDate(const char* build_date) { _build_date[sizeof(_build_date) - 1] = '\0'; } -void MQTTBridge::storeRawRadioData(const uint8_t* raw_data, int len, float snr, float rssi) { - if (len > 0 && len <= LAST_RAW_DATA_SIZE && _last_raw_data) { - #ifdef ESP_PLATFORM - // Protect with mutex for thread-safe access - if (_raw_data_mutex != nullptr && xSemaphoreTake(_raw_data_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { - memcpy(_last_raw_data, raw_data, len); - _last_raw_len = len; - _last_snr = snr; - _last_rssi = rssi; - _last_raw_timestamp = millis(); - xSemaphoreGive(_raw_data_mutex); - MQTT_DEBUG_PRINTLN("Stored raw radio data: %d bytes, SNR=%.1f, RSSI=%.1f", len, snr, rssi); - } - #else - memcpy(_last_raw_data, raw_data, len); - _last_raw_len = len; - _last_snr = snr; - _last_rssi = rssi; - _last_raw_timestamp = millis(); - MQTT_DEBUG_PRINTLN("Stored raw radio data: %d bytes, SNR=%.1f, RSSI=%.1f", len, snr, rssi); - #endif - } -} - -void MQTTBridge::setupAnalyzerServers() { - // Update analyzer server settings from preferences - bool previous_us_enabled = _analyzer_us_enabled; - bool previous_eu_enabled = _analyzer_eu_enabled; - - _analyzer_us_enabled = _prefs->mqtt_analyzer_us_enabled; - _analyzer_eu_enabled = _prefs->mqtt_analyzer_eu_enabled; - - MQTT_DEBUG_PRINTLN("Analyzer servers - US: %s, EU: %s", - _analyzer_us_enabled ? "enabled" : "disabled", - _analyzer_eu_enabled ? "enabled" : "disabled"); - - // Create authentication token if any analyzer servers are enabled - // Only create tokens if WiFi is connected and NTP is synced (to ensure correct timestamps) - if (_analyzer_us_enabled || _analyzer_eu_enabled) { - if (WiFi.status() == WL_CONNECTED && _ntp_synced) { - if (createAuthToken()) { - MQTT_DEBUG_PRINTLN("Created authentication token for analyzer servers"); - // Update client credentials with new tokens if clients exist - if (_analyzer_us_enabled && _analyzer_us_client && _auth_token_us && strlen(_auth_token_us) > 0) { - _analyzer_us_client->setCredentials(_analyzer_username, _auth_token_us); - } - if (_analyzer_eu_enabled && _analyzer_eu_client && _auth_token_eu && strlen(_auth_token_eu) > 0) { - _analyzer_eu_client->setCredentials(_analyzer_username, _auth_token_eu); - } - } else { - MQTT_DEBUG_PRINTLN("Failed to create authentication token"); - } - } else { - MQTT_DEBUG_PRINTLN("Deferring JWT token creation - WiFi: %s, NTP: %s", - (WiFi.status() == WL_CONNECTED) ? "connected" : "disconnected", - _ntp_synced ? "synced" : "not synced"); - } - } - - // If settings changed and bridge is already initialized, recreate clients - // This handles the case where settings change after initialization - if (_initialized && (previous_us_enabled != _analyzer_us_enabled || previous_eu_enabled != _analyzer_eu_enabled)) { - MQTT_DEBUG_PRINTLN("Analyzer server settings changed - recreating clients"); - setupAnalyzerClients(); - } -} - -bool MQTTBridge::createAuthToken() { - if (!_identity) { - MQTT_DEBUG_PRINTLN("No identity for auth token"); - return false; - } - - // Create username in the format: v1_{UPPERCASE_PUBLIC_KEY} - char public_key_hex[65]; - mesh::Utils::toHex(public_key_hex, _identity->pub_key, PUB_KEY_SIZE); - snprintf(_analyzer_username, sizeof(_analyzer_username), "v1_%s", public_key_hex); - - bool us_token_created = false; - bool eu_token_created = false; - - unsigned long current_time = time(nullptr); - unsigned long expires_in = 86400; // 24 hours - bool time_synced = (current_time >= 1000000000); - - // Prepare owner public key (if set) - convert to uppercase hex - const char* owner_key = nullptr; - char owner_key_uppercase[65]; - if (_prefs->mqtt_owner_public_key[0] != '\0') { - strncpy(owner_key_uppercase, _prefs->mqtt_owner_public_key, sizeof(owner_key_uppercase) - 1); - owner_key_uppercase[sizeof(owner_key_uppercase) - 1] = '\0'; - for (int i = 0; owner_key_uppercase[i]; i++) { - owner_key_uppercase[i] = toupper(owner_key_uppercase[i]); - } - owner_key = owner_key_uppercase; - } - - char client_version[64]; - getClientVersion(client_version, sizeof(client_version)); - - const char* email = (_prefs->mqtt_email[0] != '\0') ? _prefs->mqtt_email : nullptr; - - // Create JWT token for US server (only if buffer was allocated) - if (_analyzer_us_enabled && _auth_token_us) { - if (JWTHelper::createAuthToken( - *_identity, "mqtt-us-v1.letsmesh.net", - 0, expires_in, _auth_token_us, AUTH_TOKEN_SIZE, - owner_key, client_version, email)) { - us_token_created = true; - _token_us_expires_at = time_synced ? (current_time + expires_in) : 0; - } else { - MQTT_DEBUG_PRINTLN("Failed to create US token"); - _token_us_expires_at = 0; - } - } - - // Create JWT token for EU server (only if buffer was allocated) - if (_analyzer_eu_enabled && _auth_token_eu) { - if (JWTHelper::createAuthToken( - *_identity, "mqtt-eu-v1.letsmesh.net", - 0, expires_in, _auth_token_eu, AUTH_TOKEN_SIZE, - owner_key, client_version, email)) { - eu_token_created = true; - _token_eu_expires_at = time_synced ? (current_time + expires_in) : 0; - } else { - MQTT_DEBUG_PRINTLN("Failed to create EU token"); - _token_eu_expires_at = 0; - } - } - - if (us_token_created || eu_token_created) { - MQTT_DEBUG_PRINTLN("Auth tokens created (US:%s EU:%s)", - us_token_created ? "yes" : "no", eu_token_created ? "yes" : "no"); - } - - return us_token_created || eu_token_created; -} - -bool MQTTBridge::publishToAnalyzerServers(const char* topic, const char* payload, bool retained) { - if (!_analyzer_us_enabled && !_analyzer_eu_enabled) return false; - - bool published = false; - - // Publish to US server if enabled - if (_analyzer_us_enabled && _analyzer_us_client) { - if (publishToAnalyzerClient(_analyzer_us_client, topic, payload, retained)) { - published = true; - } - } - - // Publish to EU server if enabled - if (_analyzer_eu_enabled && _analyzer_eu_client) { - if (publishToAnalyzerClient(_analyzer_eu_client, topic, payload, retained)) { - published = true; - } - } - - return published; // Return true if at least one publish succeeded -} - -// Google Trust Services - GTS Root R4 -const char* GTS_ROOT_R4 = - "-----BEGIN CERTIFICATE-----\n" - "MIIDejCCAmKgAwIBAgIQf+UwvzMTQ77dghYQST2KGzANBgkqhkiG9w0BAQsFADBX\n" - "MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEQMA4GA1UE\n" - "CxMHUm9vdCBDQTEbMBkGA1UEAxMSR2xvYmFsU2lnbiBSb290IENBMB4XDTIzMTEx\n" - "NTAzNDMyMVoXDTI4MDEyODAwMDA0MlowRzELMAkGA1UEBhMCVVMxIjAgBgNVBAoT\n" - "GUdvb2dsZSBUcnVzdCBTZXJ2aWNlcyBMTEMxFDASBgNVBAMTC0dUUyBSb290IFI0\n" - "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE83Rzp2iLYK5DuDXFgTB7S0md+8Fhzube\n" - "Rr1r1WEYNa5A3XP3iZEwWus87oV8okB2O6nGuEfYKueSkWpz6bFyOZ8pn6KY019e\n" - "WIZlD6GEZQbR3IvJx3PIjGov5cSr0R2Ko4H/MIH8MA4GA1UdDwEB/wQEAwIBhjAd\n" - "BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zAd\n" - "BgNVHQ4EFgQUgEzW63T/STaj1dj8tT7FavCUHYwwHwYDVR0jBBgwFoAUYHtmGkUN\n" - "l8qJUC99BM00qP/8/UswNgYIKwYBBQUHAQEEKjAoMCYGCCsGAQUFBzAChhpodHRw\n" - "Oi8vaS5wa2kuZ29vZy9nc3IxLmNydDAtBgNVHR8EJjAkMCKgIKAehhxodHRwOi8v\n" - "Yy5wa2kuZ29vZy9yL2dzcjEuY3JsMBMGA1UdIAQMMAowCAYGZ4EMAQIBMA0GCSqG\n" - "SIb3DQEBCwUAA4IBAQAYQrsPBtYDh5bjP2OBDwmkoWhIDDkic574y04tfzHpn+cJ\n" - "odI2D4SseesQ6bDrarZ7C30ddLibZatoKiws3UL9xnELz4ct92vID24FfVbiI1hY\n" - "+SW6FoVHkNeWIP0GCbaM4C6uVdF5dTUsMVs/ZbzNnIdCp5Gxmx5ejvEau8otR/Cs\n" - "kGN+hr/W5GvT1tMBjgWKZ1i4//emhA1JG1BbPzoLJQvyEotc03lXjTaCzv8mEbep\n" - "8RqZ7a2CPsgRbuvTPBwcOMBBmuFeU88+FSBX6+7iP0il8b4Z0QFqIwwMHfs/L6K1\n" - "vepuoxtGzi4CZ68zJpiq1UvSqTbFJjtbD4seiMHl\n" - "-----END CERTIFICATE-----\n"; - -void MQTTBridge::setupAnalyzerClients() { - MQTT_DEBUG_PRINTLN("Setting up PsychicMqttClient WebSocket clients..."); - MQTT_DEBUG_PRINTLN("Analyzer servers - US: %s, EU: %s", - _analyzer_us_enabled ? "enabled" : "disabled", - _analyzer_eu_enabled ? "enabled" : "disabled"); - - // Clean up existing clients if they're no longer enabled - // This handles the case where settings change after initialization - if (!_analyzer_us_enabled && _analyzer_us_client) { - MQTT_DEBUG_PRINTLN("US analyzer disabled - cleaning up client"); - _analyzer_us_client->disconnect(); - delete _analyzer_us_client; - _analyzer_us_client = nullptr; - } - - if (!_analyzer_eu_enabled && _analyzer_eu_client) { - MQTT_DEBUG_PRINTLN("EU analyzer disabled - cleaning up client"); - _analyzer_eu_client->disconnect(); - delete _analyzer_eu_client; - _analyzer_eu_client = nullptr; - } - - if (!_analyzer_us_enabled && !_analyzer_eu_enabled) { - MQTT_DEBUG_PRINTLN("No analyzer servers enabled, skipping PsychicMqttClient setup"); - return; - } - - // Setup US server client (only if enabled and doesn't already exist) - if (_analyzer_us_enabled && !_analyzer_us_client) { - _analyzer_us_client = new PsychicMqttClient(); - #ifdef MQTT_MEMORY_DEBUG - // #region agent log - agentLogHeap("MQTTBridge.cpp:2142", "after_new_analyzer_us_client", "H4", - ESP.getFreeHeap(), ESP.getMaxAllocHeap(), - heap_caps_get_free_size(MALLOC_CAP_INTERNAL), - #ifdef BOARD_HAS_PSRAM - heap_caps_get_free_size(MALLOC_CAP_SPIRAM) - #else - 0ul - #endif - ); - // #endregion - #endif - // Optimize MQTT client configuration for memory efficiency - // Analyzer clients use 768-byte JWT tokens, need larger buffer for CONNECT message - optimizeMqttClientConfig(_analyzer_us_client, true); - - // Set up event callbacks for US server - _analyzer_us_client->onConnect([this](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("Connected to US analyzer"); - // Update cached analyzer server status - _cached_has_analyzer_servers = (_analyzer_us_enabled && _analyzer_us_client && _analyzer_us_client->connected()) || - (_analyzer_eu_enabled && _analyzer_eu_client && _analyzer_eu_client->connected()); - publishStatusToAnalyzerClient(_analyzer_us_client, "mqtt-us-v1.letsmesh.net"); - }); - - _analyzer_us_client->onDisconnect([this](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("Disconnected from US analyzer"); - // Update cached analyzer server status - _cached_has_analyzer_servers = (_analyzer_us_enabled && _analyzer_us_client && _analyzer_us_client->connected()) || - (_analyzer_eu_enabled && _analyzer_eu_client && _analyzer_eu_client->connected()); - }); - - _analyzer_us_client->onError([this](esp_mqtt_error_codes error) { - MQTT_DEBUG_PRINTLN("US analyzer error: type=%d, code=%d", error.error_type, error.connect_return_code); - }); - - _analyzer_us_client->setServer("wss://mqtt-us-v1.letsmesh.net:443/mqtt"); - if (_auth_token_us) _analyzer_us_client->setCredentials(_analyzer_username, _auth_token_us); - _analyzer_us_client->setCACert(GTS_ROOT_R4); - - if (WiFi.status() == WL_CONNECTED && _ntp_synced) { - _analyzer_us_client->connect(); - } - } - - // Setup EU server client (only if enabled and doesn't already exist) - if (_analyzer_eu_enabled && !_analyzer_eu_client) { - _analyzer_eu_client = new PsychicMqttClient(); - #ifdef MQTT_MEMORY_DEBUG - // #region agent log - agentLogHeap("MQTTBridge.cpp:2182", "after_new_analyzer_eu_client", "H4", - ESP.getFreeHeap(), ESP.getMaxAllocHeap(), - heap_caps_get_free_size(MALLOC_CAP_INTERNAL), - #ifdef BOARD_HAS_PSRAM - heap_caps_get_free_size(MALLOC_CAP_SPIRAM) - #else - 0ul - #endif - ); - // #endregion - #endif - // Optimize MQTT client configuration for memory efficiency - // Analyzer clients use 768-byte JWT tokens, need larger buffer for CONNECT message - optimizeMqttClientConfig(_analyzer_eu_client, true); - - // Set up event callbacks for EU server - _analyzer_eu_client->onConnect([this](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("Connected to EU analyzer"); - // Update cached analyzer server status - _cached_has_analyzer_servers = (_analyzer_us_enabled && _analyzer_us_client && _analyzer_us_client->connected()) || - (_analyzer_eu_enabled && _analyzer_eu_client && _analyzer_eu_client->connected()); - publishStatusToAnalyzerClient(_analyzer_eu_client, "mqtt-eu-v1.letsmesh.net"); - }); - - _analyzer_eu_client->onDisconnect([this](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("Disconnected from EU analyzer"); - // Update cached analyzer server status - _cached_has_analyzer_servers = (_analyzer_us_enabled && _analyzer_us_client && _analyzer_us_client->connected()) || - (_analyzer_eu_enabled && _analyzer_eu_client && _analyzer_eu_client->connected()); - }); - - _analyzer_eu_client->onError([this](esp_mqtt_error_codes error) { - MQTT_DEBUG_PRINTLN("EU analyzer error: type=%d, code=%d", error.error_type, error.connect_return_code); - }); - - _analyzer_eu_client->setServer("wss://mqtt-eu-v1.letsmesh.net:443/mqtt"); - if (_auth_token_eu) _analyzer_eu_client->setCredentials(_analyzer_username, _auth_token_eu); - _analyzer_eu_client->setCACert(GTS_ROOT_R4); - - if (WiFi.status() == WL_CONNECTED && _ntp_synced) { - _analyzer_eu_client->connect(); - } - } -} - -bool MQTTBridge::publishToAnalyzerClient(PsychicMqttClient* client, const char* topic, const char* payload, bool retained) { - if (!client) { - return false; // Don't log null client - this is expected if analyzer is disabled - } - - if (!client->connected()) { - // Throttle log spam - only log periodically for each analyzer server - unsigned long now = millis(); - bool should_log = false; - - if (client == _analyzer_us_client && (now - _last_analyzer_us_log > ANALYZER_LOG_INTERVAL)) { - should_log = true; - _last_analyzer_us_log = now; - } else if (client == _analyzer_eu_client && (now - _last_analyzer_eu_log > ANALYZER_LOG_INTERVAL)) { - should_log = true; - _last_analyzer_eu_log = now; - } - - if (should_log) { - MQTT_DEBUG_PRINTLN("PsychicMqttClient not connected - skipping publish to topic: %s", topic); - } - return false; - } - - // Reset log timer when connected - if (client == _analyzer_us_client) { - _last_analyzer_us_log = 0; - } else if (client == _analyzer_eu_client) { - _last_analyzer_eu_log = 0; - } - - int result = client->publish(topic, 1, retained, payload, strlen(payload)); - if (result <= 0) { - static unsigned long last_analyzer_publish_fail_log = 0; - unsigned long now = millis(); - if (now - last_analyzer_publish_fail_log > 60000) { // Log every minute max - MQTT_DEBUG_PRINTLN("Analyzer publish failed (result=%d)", result); - last_analyzer_publish_fail_log = now; - } - return false; - } - - return true; // Publish succeeded -} - -void MQTTBridge::publishStatusToAnalyzerClient(PsychicMqttClient* client, const char* server_name) { - if (!client || !client->connected()) { - return; - } - - // Check if IATA is configured before attempting to publish - if (!isIATAValid()) { - static unsigned long last_iata_warning = 0; - unsigned long now = millis(); - // Only log this warning every 5 minutes to avoid spam - if (now - last_iata_warning > 300000) { - MQTT_DEBUG_PRINTLN("MQTT: Cannot publish status to analyzer - IATA code not configured (current: '%s'). Please set mqtt.iata via CLI.", _iata); - last_iata_warning = now; - } - return; - } - - // Create status message - char status_topic[128]; - snprintf(status_topic, sizeof(status_topic), "meshcore/%s/%s/status", _iata, _device_id); - - // JSON buffer in PSRAM when available (plan §4) - static const size_t ANALYZER_STATUS_JSON_SIZE = 768; - char* json_buffer = (char*)psram_malloc(ANALYZER_STATUS_JSON_SIZE); - if (json_buffer == nullptr) { - return; - } - char origin_id[65]; - char timestamp[32]; - char radio_info[64]; - - // Get current timestamp in ISO 8601 format - struct tm timeinfo; - if (getLocalTime(&timeinfo)) { - strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S.000000", &timeinfo); - } else { - strcpy(timestamp, "2024-01-01T12:00:00.000000"); - } - - // Build radio info string (freq,bw,sf,cr) - snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d", - _prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr); - - // Use actual device ID - strncpy(origin_id, _device_id, sizeof(origin_id) - 1); - origin_id[sizeof(origin_id) - 1] = '\0'; - - // Build client version string - char client_version[64]; - getClientVersion(client_version, sizeof(client_version)); - - // Collect stats on-demand if sources are available - int battery_mv = -1; - int uptime_secs = -1; - int errors = -1; - int noise_floor = -999; - int tx_air_secs = -1; - int rx_air_secs = -1; - int recv_errors = -1; - - if (_board) { - battery_mv = _board->getBattMilliVolts(); - } - if (_ms) { - uptime_secs = _ms->getMillis() / 1000; - } - if (_dispatcher) { - errors = _dispatcher->getErrFlags(); - tx_air_secs = _dispatcher->getTotalAirTime() / 1000; - rx_air_secs = _dispatcher->getReceiveAirTime() / 1000; - } - if (_radio) { - noise_floor = (int16_t)_radio->getNoiseFloor(); - recv_errors = (int)_radio->getPacketsRecvErrors(); - } - - // Build status message using MQTTMessageBuilder with stats - int len = MQTTMessageBuilder::buildStatusMessage( - _origin, - origin_id, - _board_model, // model - _firmware_version, // firmware version - radio_info, - client_version, // client version - "online", - timestamp, - json_buffer, - ANALYZER_STATUS_JSON_SIZE, - battery_mv, - uptime_secs, - errors, - _queue_count, // Use current queue length - noise_floor, - tx_air_secs, - rx_air_secs, - recv_errors - ); - - if (len > 0) { - int result = client->publish(status_topic, 1, true, json_buffer, strlen(json_buffer)); - if (result <= 0) { - MQTT_DEBUG_PRINTLN("Status publish to %s failed", server_name); - } - } - psram_free(json_buffer); -} - -void MQTTBridge::maintainAnalyzerConnections() { - if (!_identity) { - return; - } - - // Check WiFi status first - don't attempt MQTT reconnection if WiFi is disconnected - if (WiFi.status() != WL_CONNECTED) { - return; - } - - // JWT tokens require valid timestamps. Allow if NTP sync flag is set, or if system clock - // is clearly set (e.g. not firmware default 2024) so we don't block reconnection after - // a successful NTP sync at boot when _ntp_synced might be wrong. - unsigned long clock_sec = time(nullptr); - bool clock_looks_set = (clock_sec >= 1735689600); // 2025-01-01 00:00:00 UTC - if (!_ntp_synced && !clock_looks_set) { - return; - } - - // Create JWT tokens if they don't exist yet and conditions are met - if ((_analyzer_us_enabled || _analyzer_eu_enabled) && - ((!_auth_token_us || strlen(_auth_token_us) == 0) && (!_auth_token_eu || strlen(_auth_token_eu) == 0))) { - if (createAuthToken()) { - if (_analyzer_us_enabled && _analyzer_us_client && _auth_token_us && strlen(_auth_token_us) > 0) { - _analyzer_us_client->setCredentials(_analyzer_username, _auth_token_us); - if (!_analyzer_us_client->connected()) { - _analyzer_us_client->connect(); - } - } - if (_analyzer_eu_enabled && _analyzer_eu_client && _auth_token_eu && strlen(_auth_token_eu) > 0) { - _analyzer_eu_client->setCredentials(_analyzer_username, _auth_token_eu); - if (!_analyzer_eu_client->connected()) { - _analyzer_eu_client->connect(); - } - } - } - } - - unsigned long current_time = time(nullptr); - // If time is not synced (time() returns 0 or very small value), skip expiration checks - // Tokens will still work but we can't track expiration properly - // If expiration time was set before time sync, it will be a small value, so we'll renew - bool time_synced = (current_time >= 1000000000); // After year 2001 - - const unsigned long RENEWAL_BUFFER = 60; // Renew tokens 60 seconds before expiration (minimal buffer to avoid downtime) - const unsigned long DISCONNECT_THRESHOLD = 60; // Only disconnect if token expires within 60 seconds - const unsigned long RENEWAL_THROTTLE_MS = 60000; // Don't attempt renewal more than once per minute - static const unsigned long ANALYZER_BACKOFF_MS[] = { 60000, 120000, 240000, 300000 }; // 1min, 2min, 4min, 5min cap - - unsigned long now_millis = millis(); - - // Check and renew US server token if needed - if (_analyzer_us_enabled && _analyzer_us_client) { - if (_analyzer_us_client->connected()) { - _analyzer_us_reconnect_backoff_attempt = 0; - } - // Check if token is expired or will expire soon - // Only check expiration if time is synced - if time isn't synced, we can't validate expiration - // If time wasn't synced when token was created, expiration time will be invalid (< 1000000000), so renew when time syncs - bool token_needs_renewal = false; - if (!time_synced) { - // Time not synced yet - only renew if token is missing (expires_at == 0) - // Don't renew if token exists but expiration is invalid - wait for time sync - token_needs_renewal = (_token_us_expires_at == 0); - } else { - // Time is synced - check if token needs renewal - token_needs_renewal = (_token_us_expires_at == 0) || - !(_token_us_expires_at >= 1000000000) || // Expiration time invalid (created before time sync) - (current_time >= _token_us_expires_at) || - (current_time >= (_token_us_expires_at - RENEWAL_BUFFER)); - } - - // Throttle renewal attempts - don't try more than once per minute to avoid blocking - bool can_attempt_renewal = (now_millis - _last_token_renewal_attempt_us) >= RENEWAL_THROTTLE_MS; - - // Check if client is disconnected and needs reconnection with new token - bool needs_reconnect = !_analyzer_us_client->connected(); - - if (token_needs_renewal && can_attempt_renewal) { - _last_token_renewal_attempt_us = now_millis; - - // Prepare owner public key (if set) - convert to uppercase hex - const char* owner_key = nullptr; - char owner_key_uppercase[65]; - if (_prefs->mqtt_owner_public_key[0] != '\0') { - // Copy and convert to uppercase - strncpy(owner_key_uppercase, _prefs->mqtt_owner_public_key, sizeof(owner_key_uppercase) - 1); - owner_key_uppercase[sizeof(owner_key_uppercase) - 1] = '\0'; - for (int i = 0; owner_key_uppercase[i]; i++) { - owner_key_uppercase[i] = toupper(owner_key_uppercase[i]); - } - owner_key = owner_key_uppercase; - } - - // Build client version string (same format as used in status messages) - char client_version[64]; - getClientVersion(client_version, sizeof(client_version)); - - // Get email from preferences (if set) - const char* email = nullptr; - if (_prefs->mqtt_email[0] != '\0') { - email = _prefs->mqtt_email; - } - - // Store old expiration time before renewing (to check if we need to disconnect) - unsigned long old_token_expires_at = _token_us_expires_at; - - // Renew the token (only if buffer was allocated) - if (_auth_token_us && JWTHelper::createAuthToken( - *_identity, "mqtt-us-v1.letsmesh.net", - 0, 86400, _auth_token_us, AUTH_TOKEN_SIZE, - owner_key, client_version, email)) { - unsigned long expires_in = 86400; // 24 hours - _token_us_expires_at = time_synced ? (current_time + expires_in) : 0; - MQTT_DEBUG_PRINTLN("US token renewed"); - - _analyzer_us_client->setCredentials(_analyzer_username, _auth_token_us); - - bool old_token_expired_or_imminent = !time_synced || - (old_token_expires_at == 0) || - (current_time >= old_token_expires_at) || - (time_synced && old_token_expires_at >= 1000000000 && - current_time >= (old_token_expires_at - DISCONNECT_THRESHOLD)); - - if (old_token_expired_or_imminent && _analyzer_us_client->connected()) { - _analyzer_us_client->disconnect(); - _last_reconnect_attempt_us = now_millis; - _analyzer_us_client->connect(); - } else if (!_analyzer_us_client->connected()) { - _last_reconnect_attempt_us = now_millis; - _analyzer_us_client->connect(); - } - } else { - MQTT_DEBUG_PRINTLN("Failed to renew US token"); - _token_us_expires_at = 0; - } - } else if (needs_reconnect) { - unsigned long reconnect_elapsed = (now_millis >= _last_reconnect_attempt_us) ? - (now_millis - _last_reconnect_attempt_us) : - (ULONG_MAX - _last_reconnect_attempt_us + now_millis + 1); - unsigned int idx = (_analyzer_us_reconnect_backoff_attempt < 4) ? _analyzer_us_reconnect_backoff_attempt : 3; - unsigned long delay_ms = ANALYZER_BACKOFF_MS[idx]; - if (reconnect_elapsed >= delay_ms) { - _last_reconnect_attempt_us = now_millis; - if (_analyzer_us_reconnect_backoff_attempt < 4) { - _analyzer_us_reconnect_backoff_attempt++; - } - _analyzer_us_client->connect(); - } - } - } - - // Check and renew EU server token if needed - if (_analyzer_eu_enabled && _analyzer_eu_client) { - if (_analyzer_eu_client->connected()) { - _analyzer_eu_reconnect_backoff_attempt = 0; - } - // Check if token is expired or will expire soon - // Only check expiration if time is synced - if time isn't synced, we can't validate expiration - // If time wasn't synced when token was created, expiration time will be invalid (< 1000000000), so renew when time syncs - bool token_needs_renewal = false; - if (!time_synced) { - // Time not synced yet - only renew if token is missing (expires_at == 0) - // Don't renew if token exists but expiration is invalid - wait for time sync - token_needs_renewal = (_token_eu_expires_at == 0); - } else { - // Time is synced - check if token needs renewal - token_needs_renewal = (_token_eu_expires_at == 0) || - !(_token_eu_expires_at >= 1000000000) || // Expiration time invalid (created before time sync) - (current_time >= _token_eu_expires_at) || - (current_time >= (_token_eu_expires_at - RENEWAL_BUFFER)); - } - - // Throttle renewal attempts - don't try more than once per minute to avoid blocking - bool can_attempt_renewal = (now_millis - _last_token_renewal_attempt_eu) >= RENEWAL_THROTTLE_MS; - - // Check if client is disconnected and needs reconnection with new token - bool needs_reconnect = !_analyzer_eu_client->connected(); - - if (token_needs_renewal && can_attempt_renewal) { - _last_token_renewal_attempt_eu = now_millis; - - // Prepare owner public key (if set) - convert to uppercase hex - const char* owner_key = nullptr; - char owner_key_uppercase[65]; - if (_prefs->mqtt_owner_public_key[0] != '\0') { - // Copy and convert to uppercase - strncpy(owner_key_uppercase, _prefs->mqtt_owner_public_key, sizeof(owner_key_uppercase) - 1); - owner_key_uppercase[sizeof(owner_key_uppercase) - 1] = '\0'; - for (int i = 0; owner_key_uppercase[i]; i++) { - owner_key_uppercase[i] = toupper(owner_key_uppercase[i]); - } - owner_key = owner_key_uppercase; - } - - // Build client version string - char client_version[64]; - getClientVersion(client_version, sizeof(client_version)); - - // Get email from preferences (if set) - const char* email = nullptr; - if (_prefs->mqtt_email[0] != '\0') { - email = _prefs->mqtt_email; - } - - // Store old expiration time before renewing (to check if we need to disconnect) - unsigned long old_token_expires_at = _token_eu_expires_at; - - // Renew the token (only if buffer was allocated) - if (_auth_token_eu && JWTHelper::createAuthToken( - *_identity, "mqtt-eu-v1.letsmesh.net", - 0, 86400, _auth_token_eu, AUTH_TOKEN_SIZE, - owner_key, client_version, email)) { - unsigned long expires_in = 86400; // 24 hours - _token_eu_expires_at = time_synced ? (current_time + expires_in) : 0; - MQTT_DEBUG_PRINTLN("EU token renewed"); - - _analyzer_eu_client->setCredentials(_analyzer_username, _auth_token_eu); - - bool old_token_expired_or_imminent = !time_synced || - (old_token_expires_at == 0) || - (current_time >= old_token_expires_at) || - (time_synced && old_token_expires_at >= 1000000000 && - current_time >= (old_token_expires_at - DISCONNECT_THRESHOLD)); - - if (old_token_expired_or_imminent && _analyzer_eu_client->connected()) { - _analyzer_eu_client->disconnect(); - _last_reconnect_attempt_eu = now_millis; - _analyzer_eu_client->connect(); - } else if (!_analyzer_eu_client->connected()) { - _last_reconnect_attempt_eu = now_millis; - _analyzer_eu_client->connect(); - } - } else { - MQTT_DEBUG_PRINTLN("Failed to renew EU token"); - _token_eu_expires_at = 0; - } - } else if (needs_reconnect) { - unsigned long reconnect_elapsed = (now_millis >= _last_reconnect_attempt_eu) ? - (now_millis - _last_reconnect_attempt_eu) : - (ULONG_MAX - _last_reconnect_attempt_eu + now_millis + 1); - unsigned int idx = (_analyzer_eu_reconnect_backoff_attempt < 4) ? _analyzer_eu_reconnect_backoff_attempt : 3; - unsigned long delay_ms = ANALYZER_BACKOFF_MS[idx]; - if (reconnect_elapsed >= delay_ms) { - _last_reconnect_attempt_eu = now_millis; - if (_analyzer_eu_reconnect_backoff_attempt < 4) { - _analyzer_eu_reconnect_backoff_attempt++; - } - _analyzer_eu_client->connect(); - } - } - } - - // Note: PsychicMqttClient handles automatic reconnection internally, - // but we need to ensure tokens are renewed before reconnection attempts -} - void MQTTBridge::setMessageTypes(bool status, bool packets, bool raw) { _status_enabled = status; _packets_enabled = packets; @@ -2838,8 +2252,8 @@ void MQTTBridge::setMessageTypes(bool status, bool packets, bool raw) { int MQTTBridge::getConnectedBrokers() const { int count = 0; - for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) { - if (_brokers[i].enabled && _brokers[i].connected) { + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].connected) { count++; } } @@ -2848,7 +2262,6 @@ int MQTTBridge::getConnectedBrokers() const { int MQTTBridge::getQueueSize() const { #ifdef ESP_PLATFORM - // Get actual queue size from FreeRTOS queue if (_packet_queue_handle != nullptr) { return uxQueueMessagesWaiting(_packet_queue_handle); } @@ -2858,7 +2271,7 @@ int MQTTBridge::getQueueSize() const { #endif } -void MQTTBridge::setStatsSources(mesh::Dispatcher* dispatcher, mesh::Radio* radio, +void MQTTBridge::setStatsSources(mesh::Dispatcher* dispatcher, mesh::Radio* radio, mesh::MainBoard* board, mesh::MillisecondClock* ms) { _dispatcher = dispatcher; _radio = radio; @@ -2866,311 +2279,4 @@ void MQTTBridge::setStatsSources(mesh::Dispatcher* dispatcher, mesh::Radio* radi _ms = ms; } -void MQTTBridge::syncTimeWithNTP() { - if (!WiFi.isConnected()) { - MQTT_DEBUG_PRINTLN("Cannot sync time - WiFi not connected"); - return; - } - - // Prevent multiple simultaneous NTP syncs - // Check if we're already synced and sync was recent (within last 5 seconds) - unsigned long now = millis(); - if (_ntp_synced && (now - _last_ntp_sync) < 5000) { - // Already synced recently, skip - return; - } - - // Set flag to prevent concurrent syncs - static bool sync_in_progress = false; - if (sync_in_progress) { - return; // Another sync is already in progress - } - sync_in_progress = true; - - MQTT_DEBUG_PRINTLN("Syncing time with NTP..."); - - // Test DNS resolution before attempting NTP sync - #ifdef ESP_PLATFORM - IPAddress resolved_ip; - if (!WiFi.hostByName("pool.ntp.org", resolved_ip)) { - MQTT_DEBUG_PRINTLN("WARNING: DNS resolution failed for pool.ntp.org - NTP sync may fail"); - } - #endif - - bool ntp_ok = false; - unsigned long epochTime = 0; - const unsigned long kMinValidEpoch = 1704067200; // 2024-01-01 00:00:00 UTC - - // Begin NTP client and try forceUpdate with retries (helps on some boards e.g. Heltec V3) - _ntp_client.begin(); - const int kMaxNtpRetries = 3; - for (int attempt = 1; attempt <= kMaxNtpRetries && !ntp_ok; attempt++) { - if (attempt > 1) { - MQTT_DEBUG_PRINTLN("NTP retry %d/%d...", attempt, kMaxNtpRetries); - delay(1000); - } - if (_ntp_client.forceUpdate()) { - epochTime = _ntp_client.getEpochTime(); - if (epochTime >= kMinValidEpoch) { - ntp_ok = true; - } - } - } - _ntp_client.end(); - - // Fallback: use ESP32 built-in SNTP (configTime) when NTPClient fails - #ifdef ESP_PLATFORM - if (!ntp_ok) { - MQTT_DEBUG_PRINTLN("NTP client failed, trying SNTP fallback..."); - configTime(0, 0, "pool.ntp.org"); - for (int i = 0; i < 20; i++) { - delay(500); - epochTime = (unsigned long)time(nullptr); - if (epochTime >= kMinValidEpoch) { - ntp_ok = true; - MQTT_DEBUG_PRINTLN("SNTP fallback succeeded: %lu", epochTime); - break; - } - } - } - #endif - - if (ntp_ok) { - // Set system timezone to UTC (idempotent; SNTP fallback already uses pool.ntp.org) - configTime(0, 0, "pool.ntp.org"); - - // Update the device's RTC clock with UTC time (if available) - if (_rtc) { - _rtc->setCurrentTime(epochTime); - } - - bool was_ntp_synced = _ntp_synced; - _ntp_synced = true; - _last_ntp_sync = millis(); - sync_in_progress = false; - - MQTT_DEBUG_PRINTLN("Time synced: %lu", epochTime); - - if (!was_ntp_synced) { - unsigned long current_time = time(nullptr); - unsigned long expires_in = 86400; // 24 hours - - if (_analyzer_us_enabled && _token_us_expires_at == 0 && _auth_token_us && strlen(_auth_token_us) > 0) { - _token_us_expires_at = current_time + expires_in; - MQTT_DEBUG_PRINTLN("US token expiration set after NTP sync: %lu", _token_us_expires_at); - } - - if (_analyzer_eu_enabled && _token_eu_expires_at == 0 && _auth_token_eu && strlen(_auth_token_eu) > 0) { - _token_eu_expires_at = current_time + expires_in; - } - - if ((_analyzer_us_enabled || _analyzer_eu_enabled) && - ((!_auth_token_us || strlen(_auth_token_us) == 0) && (!_auth_token_eu || strlen(_auth_token_eu) == 0))) { - if (createAuthToken()) { - if (_analyzer_us_enabled && _analyzer_us_client && _auth_token_us && strlen(_auth_token_us) > 0) { - _analyzer_us_client->setCredentials(_analyzer_username, _auth_token_us); - if (!_analyzer_us_client->connected()) { - _analyzer_us_client->connect(); - } - } - if (_analyzer_eu_enabled && _analyzer_eu_client && _auth_token_eu && strlen(_auth_token_eu) > 0) { - _analyzer_eu_client->setCredentials(_analyzer_username, _auth_token_eu); - if (!_analyzer_eu_client->connected()) { - _analyzer_eu_client->connect(); - } - } - } else { - MQTT_DEBUG_PRINTLN("Failed to create tokens after NTP sync"); - } - } - } - - // Set timezone from string (with DST support) - only if changed - static char last_timezone[64] = ""; - if (strcmp(_prefs->timezone_string, last_timezone) != 0) { - if (_timezone) { - delete _timezone; - _timezone = nullptr; - } - Timezone* tz = createTimezoneFromString(_prefs->timezone_string); - if (tz) { - _timezone = tz; - } else { - TimeChangeRule utc = {"UTC", Last, Sun, Mar, 0, 0}; - _timezone = new Timezone(utc, utc); - } - strncpy(last_timezone, _prefs->timezone_string, sizeof(last_timezone) - 1); - last_timezone[sizeof(last_timezone) - 1] = '\0'; - } - - (void)gmtime((time_t*)&epochTime); - (void)localtime((time_t*)&epochTime); - } else { - MQTT_DEBUG_PRINTLN("NTP sync failed"); - sync_in_progress = false; - } -} - -Timezone* MQTTBridge::createTimezoneFromString(const char* tz_string) { - // Create Timezone objects for common IANA timezone strings - - // North America - if (strcmp(tz_string, "America/Los_Angeles") == 0 || strcmp(tz_string, "America/Vancouver") == 0) { - TimeChangeRule pst = {"PST", First, Sun, Nov, 2, -480}; // UTC-8 - TimeChangeRule pdt = {"PDT", Second, Sun, Mar, 2, -420}; // UTC-7 - return new Timezone(pdt, pst); - } else if (strcmp(tz_string, "America/Denver") == 0) { - TimeChangeRule mst = {"MST", First, Sun, Nov, 2, -420}; // UTC-7 - TimeChangeRule mdt = {"MDT", Second, Sun, Mar, 2, -360}; // UTC-6 - return new Timezone(mdt, mst); - } else if (strcmp(tz_string, "America/Chicago") == 0) { - TimeChangeRule cst = {"CST", First, Sun, Nov, 2, -360}; // UTC-6 - TimeChangeRule cdt = {"CDT", Second, Sun, Mar, 2, -300}; // UTC-5 - return new Timezone(cdt, cst); - } else if (strcmp(tz_string, "America/New_York") == 0 || strcmp(tz_string, "America/Toronto") == 0) { - TimeChangeRule est = {"EST", First, Sun, Nov, 2, -300}; // UTC-5 - TimeChangeRule edt = {"EDT", Second, Sun, Mar, 2, -240}; // UTC-4 - return new Timezone(edt, est); - } else if (strcmp(tz_string, "America/Anchorage") == 0) { - TimeChangeRule akst = {"AKST", First, Sun, Nov, 2, -540}; // UTC-9 - TimeChangeRule akdt = {"AKDT", Second, Sun, Mar, 2, -480}; // UTC-8 - return new Timezone(akdt, akst); - } else if (strcmp(tz_string, "Pacific/Honolulu") == 0) { - TimeChangeRule hst = {"HST", Last, Sun, Oct, 2, -600}; // UTC-10 (no DST) - return new Timezone(hst, hst); - - // Europe - } else if (strcmp(tz_string, "Europe/London") == 0) { - TimeChangeRule gmt = {"GMT", Last, Sun, Oct, 2, 0}; // UTC+0 - TimeChangeRule bst = {"BST", Last, Sun, Mar, 1, 60}; // UTC+1 - return new Timezone(bst, gmt); - } else if (strcmp(tz_string, "Europe/Paris") == 0 || strcmp(tz_string, "Europe/Berlin") == 0) { - TimeChangeRule cet = {"CET", Last, Sun, Oct, 3, 60}; // UTC+1 - TimeChangeRule cest = {"CEST", Last, Sun, Mar, 2, 120}; // UTC+2 - return new Timezone(cest, cet); - } else if (strcmp(tz_string, "Europe/Moscow") == 0) { - TimeChangeRule msk = {"MSK", Last, Sun, Oct, 3, 180}; // UTC+3 (no DST since 2014) - return new Timezone(msk, msk); - - // Asia - } else if (strcmp(tz_string, "Asia/Tokyo") == 0) { - TimeChangeRule jst = {"JST", Last, Sun, Oct, 2, 540}; // UTC+9 (no DST) - return new Timezone(jst, jst); - } else if (strcmp(tz_string, "Asia/Shanghai") == 0 || strcmp(tz_string, "Asia/Hong_Kong") == 0) { - TimeChangeRule cst = {"CST", Last, Sun, Oct, 2, 480}; // UTC+8 (no DST) - return new Timezone(cst, cst); - } else if (strcmp(tz_string, "Asia/Kolkata") == 0) { - TimeChangeRule ist = {"IST", Last, Sun, Oct, 2, 330}; // UTC+5:30 (no DST) - return new Timezone(ist, ist); - } else if (strcmp(tz_string, "Asia/Dubai") == 0) { - TimeChangeRule gst = {"GST", Last, Sun, Oct, 2, 240}; // UTC+4 (no DST) - return new Timezone(gst, gst); - - // Australia - } else if (strcmp(tz_string, "Australia/Sydney") == 0 || strcmp(tz_string, "Australia/Melbourne") == 0) { - TimeChangeRule aest = {"AEST", First, Sun, Apr, 3, 600}; // UTC+10 - TimeChangeRule aedt = {"AEDT", First, Sun, Oct, 2, 660}; // UTC+11 - return new Timezone(aedt, aest); - } else if (strcmp(tz_string, "Australia/Perth") == 0) { - TimeChangeRule awst = {"AWST", Last, Sun, Oct, 2, 480}; // UTC+8 (no DST) - return new Timezone(awst, awst); - - // Timezone abbreviations (with DST handling) - } else if (strcmp(tz_string, "PDT") == 0 || strcmp(tz_string, "PST") == 0) { - // Pacific Time (PST/PDT) - TimeChangeRule pst = {"PST", First, Sun, Nov, 2, -480}; // UTC-8 - TimeChangeRule pdt = {"PDT", Second, Sun, Mar, 2, -420}; // UTC-7 - return new Timezone(pdt, pst); - } else if (strcmp(tz_string, "MDT") == 0 || strcmp(tz_string, "MST") == 0) { - // Mountain Time (MST/MDT) - TimeChangeRule mst = {"MST", First, Sun, Nov, 2, -420}; // UTC-7 - TimeChangeRule mdt = {"MDT", Second, Sun, Mar, 2, -360}; // UTC-6 - return new Timezone(mdt, mst); - } else if (strcmp(tz_string, "CDT") == 0 || strcmp(tz_string, "CST") == 0) { - // Central Time (CST/CDT) - TimeChangeRule cst = {"CST", First, Sun, Nov, 2, -360}; // UTC-6 - TimeChangeRule cdt = {"CDT", Second, Sun, Mar, 2, -300}; // UTC-5 - return new Timezone(cdt, cst); - } else if (strcmp(tz_string, "EDT") == 0 || strcmp(tz_string, "EST") == 0) { - // Eastern Time (EST/EDT) - TimeChangeRule est = {"EST", First, Sun, Nov, 2, -300}; // UTC-5 - TimeChangeRule edt = {"EDT", Second, Sun, Mar, 2, -240}; // UTC-4 - return new Timezone(edt, est); - } else if (strcmp(tz_string, "BST") == 0 || strcmp(tz_string, "GMT") == 0) { - // British Time (GMT/BST) - TimeChangeRule gmt = {"GMT", Last, Sun, Oct, 2, 0}; // UTC+0 - TimeChangeRule bst = {"BST", Last, Sun, Mar, 1, 60}; // UTC+1 - return new Timezone(bst, gmt); - } else if (strcmp(tz_string, "CEST") == 0 || strcmp(tz_string, "CET") == 0) { - // Central European Time (CET/CEST) - TimeChangeRule cet = {"CET", Last, Sun, Oct, 3, 60}; // UTC+1 - TimeChangeRule cest = {"CEST", Last, Sun, Mar, 2, 120}; // UTC+2 - return new Timezone(cest, cet); - - // UTC and simple offsets - } else if (strcmp(tz_string, "UTC") == 0) { - TimeChangeRule utc = {"UTC", Last, Sun, Mar, 0, 0}; - return new Timezone(utc, utc); - } else if (strncmp(tz_string, "UTC", 3) == 0) { - // Handle UTC+/-X format (UTC-8, UTC+5, etc.) - int offset = atoi(tz_string + 3); - TimeChangeRule utc_offset = {"UTC", Last, Sun, Mar, 0, offset * 60}; - return new Timezone(utc_offset, utc_offset); - } else if (strncmp(tz_string, "GMT", 3) == 0) { - // Handle GMT+/-X format (GMT-8, GMT+5, etc.) - int offset = atoi(tz_string + 3); - TimeChangeRule gmt_offset = {"GMT", Last, Sun, Mar, 0, offset * 60}; - return new Timezone(gmt_offset, gmt_offset); - } else if (strncmp(tz_string, "+", 1) == 0 || strncmp(tz_string, "-", 1) == 0) { - // Handle simple +/-X format (+5, -8, etc.) - int offset = atoi(tz_string); - TimeChangeRule offset_tz = {"TZ", Last, Sun, Mar, 0, offset * 60}; - return new Timezone(offset_tz, offset_tz); - } else { - // Unknown timezone, return null - MQTT_DEBUG_PRINTLN("Unknown timezone: %s", tz_string); - return nullptr; - } -} - -void MQTTBridge::getClientVersion(char* buffer, size_t buffer_size) const { - if (!buffer || buffer_size == 0) { - return; - } - // Generate client version string in format "meshcore/{firmware_version}" - snprintf(buffer, buffer_size, "meshcore/%s", _firmware_version); -} - -void MQTTBridge::optimizeMqttClientConfig(PsychicMqttClient* client, bool is_analyzer_client) { - if (!client) return; - - // Keepalive 45s: Cloudflare closes WebSocket connections after 100s idle (non-configurable). - // Sending PINGREQ every 45s keeps the connection alive through the proxy. - client->setKeepAlive(45); - - // Use a single buffer size for all clients to reduce heap fragmentation: mixed sizes - // (e.g. 640 vs 896) create different-sized holes that are harder to reuse on reconnect. - // 896 is the minimum safe size for analyzer clients (CONNECT + 768-byte JWT); main - // client uses the same size so all MQTT buffer allocations are identical. - static const int MQTT_CLIENT_BUFFER_SIZE = 896; - - client->setBufferSize(MQTT_CLIENT_BUFFER_SIZE); - - // Access ESP-IDF config to optimize additional settings - esp_mqtt_client_config_t* config = client->getMqttConfig(); - if (config) { - #if defined(ESP_IDF_VERSION_MAJOR) && ESP_IDF_VERSION_MAJOR >= 5 - if (config->buffer.out_size == 0 || config->buffer.out_size > MQTT_CLIENT_BUFFER_SIZE) { - config->buffer.out_size = MQTT_CLIENT_BUFFER_SIZE; - } - #endif - } -} - -void MQTTBridge::logMemoryStatus() { - MQTT_DEBUG_PRINTLN("Memory: Free=%d, Max=%d, Queue=%d/%d", - ESP.getFreeHeap(), ESP.getMaxAllocHeap(), _queue_count, MAX_QUEUE_SIZE); -} - #endif - diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 0afc2217..b2367ac2 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -8,6 +8,7 @@ #include #include #include "helpers/JWTHelper.h" +#include "helpers/MQTTPresets.h" #ifdef ESP_PLATFORM #include @@ -37,45 +38,52 @@ * uplink packet data to multiple MQTT brokers for monitoring and analysis. * * Features: - * - Multiple MQTT broker support + * - Up to 3 configurable MQTT connection slots + * - Built-in presets for LetsMesh Analyzer (US/EU) and MeshMapper + * - Custom broker support with username/password auth + * - JWT authentication with Ed25519 device signing * - Automatic reconnection with exponential backoff * - JSON message formatting for status, packets, and raw data - * - Configurable topics and QoS levels * - Packet queuing during connection issues * - * Message Types: - * - Status: Device connection status and metadata - * - Packets: Full packet data with RF characteristics - * - Raw: Minimal raw packet data for map integration - * * Configuration: * - Define WITH_MQTT_BRIDGE to enable this bridge - * - Configure brokers via CLI commands - * - Set origin name and IATA code for topic structure + * - Configure slots via: set mqtt1.preset , set mqtt2.preset , etc. + * - Available presets: analyzer-us, analyzer-eu, meshmapper, custom, none */ class MQTTBridge : public BridgeBase { private: - PsychicMqttClient* _mqtt_client; - - // MQTT broker configuration - struct MQTTBroker { + // Connection slot - each slot holds one MQTT connection + struct MQTTSlot { + PsychicMqttClient* client; + const MQTTPresetDef* preset; // Points to MQTT_PRESETS[] entry, nullptr for custom/none + bool enabled; // true when preset is not "none" + bool connected; // Updated in callbacks + bool initial_connect_done; // True after first connect() call + + // JWT auth state (only used when preset->auth_type == MQTT_AUTH_JWT) + char* auth_token; // PSRAM-allocated, AUTH_TOKEN_SIZE bytes + unsigned long token_expires_at; + unsigned long last_token_renewal; + + // Custom broker settings (only used when preset_name is "custom") char host[64]; uint16_t port; char username[32]; char password[64]; - char client_id[32]; - uint8_t qos; - bool enabled; - bool connected; - bool initial_connect_done; // True after first connect() call - auto-reconnect handles the rest - unsigned long last_attempt; - unsigned long reconnect_interval; + + // Reconnect backoff + uint8_t reconnect_backoff; // 0..4 index into backoff table + unsigned long last_reconnect_attempt; + unsigned long last_log_time; // Throttle disconnect log messages }; - - static const int MAX_MQTT_BROKERS_COUNT = 3; - MQTTBroker _brokers[MAX_MQTT_BROKERS_COUNT]; - int _active_brokers; - + + static const size_t AUTH_TOKEN_SIZE = 768; + MQTTSlot _slots[MAX_MQTT_SLOTS]; + + // JWT username shared across all JWT-auth slots (same device identity) + char _jwt_username[70]; // Format: v1_{UPPERCASE_PUBLIC_KEY} + // Message configuration char _origin[32]; char _iata[8]; @@ -89,7 +97,7 @@ private: bool _tx_enabled; unsigned long _last_status_publish; unsigned long _status_interval; - + // Packet queue for offline scenarios struct QueuedPacket { mesh::Packet* packet; @@ -102,18 +110,18 @@ private: float rssi; bool has_raw_data; }; - + static const int MAX_QUEUE_SIZE = 10; - + // FreeRTOS queue for thread-safe packet queuing #ifdef ESP_PLATFORM QueueHandle_t _packet_queue_handle; TaskHandle_t _mqtt_task_handle; SemaphoreHandle_t _raw_data_mutex; // Mutex for raw radio data - // PSRAM-backed task stack (plan §3); TCB kept in internal RAM + // PSRAM-backed task stack; TCB kept in internal RAM StackType_t* _mqtt_task_stack; // nullptr if using dynamic task creation StaticTask_t _mqtt_task_tcb; - // PSRAM-backed packet queue storage (plan §5) + // PSRAM-backed packet queue storage uint8_t* _packet_queue_storage; // nullptr if using dynamic queue StaticQueue_t _packet_queue_struct; #else @@ -123,350 +131,154 @@ private: int _queue_tail; #endif int _queue_count; // Protected by queue operations or mutex - + // NTP time sync WiFiUDP _ntp_udp; NTPClient _ntp_client; unsigned long _last_ntp_sync; bool _ntp_synced; bool _ntp_sync_pending; // Flag to trigger NTP sync from loop() instead of event handler - + bool _slots_setup_done; // Deferred: slots set up after NTP sync + // Timezone handling Timezone* _timezone; - - // Raw radio data storage (plan §6: PSRAM when BOARD_HAS_PSRAM) + + // Raw radio data storage (PSRAM when BOARD_HAS_PSRAM) static const size_t LAST_RAW_DATA_SIZE = 256; uint8_t* _last_raw_data; int _last_raw_len; float _last_snr; float _last_rssi; unsigned long _last_raw_timestamp; - - // Let's Mesh Analyzer support - bool _analyzer_us_enabled; - bool _analyzer_eu_enabled; - static const size_t AUTH_TOKEN_SIZE = 768; - char* _auth_token_us; // JWT token for US server (PSRAM when BOARD_HAS_PSRAM) - char* _auth_token_eu; // JWT token for EU server (PSRAM when BOARD_HAS_PSRAM) - char _analyzer_username[70]; // Username in format v1_{UPPERCASE_PUBLIC_KEY} - - // Token expiration tracking - unsigned long _token_us_expires_at; - unsigned long _token_eu_expires_at; - + // Memory pressure monitoring unsigned long _last_memory_check; int _skipped_publishes; // Count of skipped publishes due to memory pressure - unsigned long _last_fragmentation_recovery; // Throttle: 5 min between recovery runs (task + loop) - unsigned long _fragmentation_pressure_since; // 0 = not under pressure; else first time max_alloc < threshold + unsigned long _last_fragmentation_recovery; // Throttle: 5 min between recovery runs + unsigned long _fragmentation_pressure_since; // 0 = not under pressure unsigned long _last_critical_check_run; // Throttle: run unified check at most every 60s - unsigned long _last_token_renewal_attempt_us; - unsigned long _last_token_renewal_attempt_eu; - unsigned long _last_reconnect_attempt_us; - unsigned long _last_reconnect_attempt_eu; - + // Status publish retry tracking unsigned long _last_status_retry; // Track last retry attempt (separate from successful publish) static const unsigned long STATUS_RETRY_INTERVAL = 30000; // Retry every 30 seconds if failed - + // Device identity for JWT token creation mesh::LocalIdentity *_identity; - - // PsychicMqttClient instances for different brokers - PsychicMqttClient* _analyzer_us_client; - PsychicMqttClient* _analyzer_eu_client; - - // Configuration validation state - bool _config_valid; - - // Cached broker connection status (updated in callbacks to avoid redundant checks) - bool _cached_has_brokers; - bool _cached_has_analyzer_servers; - - // Throttle logging for disconnected broker messages + + // Cached connection status (updated in callbacks to avoid redundant checks) + bool _cached_has_connected_slots; + + // Throttle logging unsigned long _last_no_broker_log; static const unsigned long NO_BROKER_LOG_INTERVAL = 30000; // Log every 30 seconds max - - // Throttle logging for analyzer client disconnected messages - unsigned long _last_analyzer_us_log; - unsigned long _last_analyzer_eu_log; - static const unsigned long ANALYZER_LOG_INTERVAL = 30000; // Log every 30 seconds max + static const unsigned long SLOT_LOG_INTERVAL = 30000; // Log every 30 seconds max unsigned long _last_config_warning; // Throttle configuration mismatch warnings static const unsigned long CONFIG_WARNING_INTERVAL = 300000; // Log every 5 minutes max - // WiFi connection state and exponential backoff (one place for mqttTaskLoop + loop()) + // WiFi connection state and exponential backoff unsigned long _last_wifi_check; wl_status_t _last_wifi_status; bool _wifi_status_initialized; unsigned long _wifi_disconnected_time; // 0 when connected unsigned long _last_wifi_reconnect_attempt; uint8_t _wifi_reconnect_backoff_attempt; // 0..5 → 15s, 30s, 60s, 120s, 300s; reset on connect - // Main broker reconnect backoff (reset in onConnect) - uint8_t _main_broker_reconnect_backoff_attempt; // 0..5 → 15s, 30s, 60s, 120s, 300s - // Analyzer reconnect backoff (reset when that client is connected) - uint8_t _analyzer_us_reconnect_backoff_attempt; // 0..4 → 60s, 120s, 240s, 300000 - uint8_t _analyzer_eu_reconnect_backoff_attempt; - + // Optional pointers for collecting stats internally (set by mesh if available) mesh::Dispatcher* _dispatcher; // For air times and errors mesh::Radio* _radio; // For noise floor mesh::MainBoard* _board; // For battery voltage mesh::MillisecondClock* _ms; // For uptime - - // Internal methods - void ensureMainMqttClient(); // Create main MQTT client if _config_valid and _mqtt_client is null (e.g. after reinit) + + // Internal methods - slot management + void setupSlot(int index); // Create/destroy client for a slot based on its preset + void teardownSlot(int index); // Disconnect and free slot resources + void maintainSlotConnections(); // Maintain all slot connections (token renewal, reconnect) + void maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced); + bool createSlotAuthToken(int index); // Create/renew JWT token for a slot + bool publishToSlot(int index, const char* topic, const char* payload, bool retained = false); + bool publishToAllSlots(const char* topic, const char* payload, bool retained = false); + void publishStatusToSlot(int index); + void updateCachedConnectionStatus(); + #ifdef ESP_PLATFORM - void runCriticalMemoryCheckAndRecovery(); // Unified heap check, pressure timer, optional recovery + void runCriticalMemoryCheckAndRecovery(); #endif - void recreateMqttClientsForFragmentationRecovery(); // Disconnect, delete, recreate all MQTT clients to recover max_alloc - void connectToBrokers(); + void recreateMqttClientsForFragmentationRecovery(); void processPacketQueue(); bool publishStatus(); // Returns true if status was successfully published - // Single place for WiFi monitoring: disconnect analyzers on drop, force reconnect with exponential backoff. - // Returns true if we transitioned to connected this call (e.g. for NTP sync in loop()). bool handleWiFiConnection(unsigned long now); - + // FreeRTOS task function (runs on Core 0) #ifdef ESP_PLATFORM static void mqttTask(void* parameter); void mqttTaskLoop(); // Main loop for MQTT task void initializeWiFiInTask(); // WiFi initialization moved to task #endif - void publishPacket(mesh::Packet* packet, bool is_tx, - const uint8_t* raw_data = nullptr, int raw_len = 0, + void publishPacket(mesh::Packet* packet, bool is_tx, + const uint8_t* raw_data = nullptr, int raw_len = 0, float snr = 0.0f, float rssi = 0.0f); void publishRaw(mesh::Packet* packet); void queuePacket(mesh::Packet* packet, bool is_tx); void dequeuePacket(); - bool isAnyBrokerConnected(); - void setBrokerDefaults(); + bool isAnySlotConnected(); void syncTimeWithNTP(); Timezone* createTimezoneFromString(const char* tz_string); - bool isMQTTConfigValid(); - void checkConfigurationMismatch(); // Check for bridge.source/mqtt.tx mismatch - bool isIATAValid() const; // Check if IATA code is configured - + void checkConfigurationMismatch(); + bool isIATAValid() const; + + void optimizeMqttClientConfig(PsychicMqttClient* client, bool needs_large_buffer = false); + void getClientVersion(char* buffer, size_t buffer_size) const; + void logMemoryStatus(); + public: - /** - * Constructs an MQTTBridge instance - * - * @param prefs Node preferences for configuration settings - * @param mgr PacketManager for allocating and queuing packets - * @param rtc RTCClock for timestamping debug messages - * @param identity Device identity for JWT token creation - */ MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity); - /** - * Initializes the MQTT bridge - * - * - Sets up default broker configuration - * - Initializes WiFi client - * - Prepares MQTT clients for each broker - */ void begin() override; - - /** - * Stops the MQTT bridge - * - * - Disconnects from all brokers - * - Clears packet queue - * - Releases resources - */ void end() override; - - /** - * Checks if MQTT configuration is valid - * - * @return true if all required MQTT settings are properly configured - */ - bool isConfigValid() const; - - /** - * Static method to validate MQTT configuration from preferences - * - * @param prefs Node preferences containing MQTT settings - * @return true if all required MQTT settings are properly configured - */ - static bool isConfigValid(const NodePrefs* prefs); - - /** - * Time (millis()) when WiFi was last seen connected. 0 when disconnected or unknown. - * Used by get wifi.status to report uptime when WITH_MQTT_BRIDGE is defined. - */ - static unsigned long getWifiConnectedAtMillis(); - - /** - * Format an informative MQTT status line for get mqtt.status (msgs, broker, analyzers, queue). - * Writes into buf, at most bufsize bytes. Requires bridge to have been initialized (begin() called). - */ - static void formatMqttStatusReply(char* buf, size_t bufsize, const NodePrefs* prefs); - - /** - * Check if MQTT bridge is ready to operate (has WiFi credentials) - * - * @return true if WiFi credentials are configured and bridge can connect - */ - bool isReady() const; - - /** - * Main loop handler - * - Maintains broker connections - * - Processes packet queue - * - Publishes status updates - */ void loop() override; - - /** - * Called when a packet is received via mesh - * Queues the packet for MQTT publishing if enabled - * - * @param packet The received mesh packet - */ void onPacketReceived(mesh::Packet *packet) override; - - /** - * Called when a packet needs to be transmitted via MQTT - * Publishes the packet to all connected brokers - * - * @param packet The mesh packet to transmit - */ void sendPacket(mesh::Packet *packet) override; /** - * Configure MQTT broker settings + * Configure a slot with a preset name. Call this when the user runs + * "set mqttN.preset ". Handles teardown of old connection and + * setup of new one. * - * @param broker_index Broker index (0-2) + * @param slot_index Slot index (0-2) + * @param preset_name Preset name: "analyzer-us", "analyzer-eu", "meshmapper", "custom", "none" + */ + void setSlotPreset(int slot_index, const char* preset_name); + + /** + * Configure custom broker settings for a slot. Only applies when the + * slot's preset is "custom". + * + * @param slot_index Slot index (0-2) * @param host Broker hostname * @param port Broker port - * @param username MQTT username - * @param password MQTT password - * @param enabled Whether broker is enabled + * @param username MQTT username (empty for anonymous) + * @param password MQTT password (empty for anonymous) */ - void setBroker(int broker_index, const char* host, uint16_t port, - const char* username, const char* password, bool enabled); + void setSlotCustomBroker(int slot_index, const char* host, uint16_t port, + const char* username, const char* password); - /** - * Set device origin name for MQTT topics - * - * @param origin Device name - */ void setOrigin(const char* origin); - - /** - * Set IATA code for MQTT topics - * - * @param iata Airport code - */ void setIATA(const char* iata); - - /** - * Set device public key for MQTT topics - * - * @param device_id Device public key (hex string) - */ void setDeviceID(const char* device_id); - - /** - * Set firmware version for status messages - * - * @param firmware_version Firmware version string - */ void setFirmwareVersion(const char* firmware_version); - - /** - * Set board model for status messages - * - * @param board_model Board model string - */ void setBoardModel(const char* board_model); - - /** - * Set build date for client version - * - * @param build_date Build date string - */ void setBuildDate(const char* build_date); - - /** - * Stores raw radio data for MQTT messages - * - * @param raw_data Raw radio transmission data - * @param len Length of raw data - * @param snr Signal-to-noise ratio - * @param rssi Received signal strength indicator - */ void storeRawRadioData(const uint8_t* raw_data, int len, float snr, float rssi); - - // Let's Mesh Analyzer methods - void setupAnalyzerServers(); - bool createAuthToken(); - bool publishToAnalyzerServers(const char* topic, const char* payload, bool retained = false); // Returns true if at least one publish succeeded - - // PsychicMqttClient WebSocket methods - void setupAnalyzerClients(); - void maintainAnalyzerConnections(); - bool publishToAnalyzerClient(PsychicMqttClient* client, const char* topic, const char* payload, bool retained = false); // Returns true if publish succeeded - void publishStatusToAnalyzerClient(PsychicMqttClient* client, const char* server_name); - - /** - * Optimize MQTT client configuration for memory efficiency - * Reduces buffer sizes to minimize memory usage while maintaining functionality - * - * @param client MQTT client to optimize - * @param is_analyzer_client If true, uses larger buffer for JWT tokens (768 bytes) - */ - void optimizeMqttClientConfig(PsychicMqttClient* client, bool is_analyzer_client = false); - - /** - * Enable/disable message types - * - * @param status Enable status messages - * @param packets Enable packet messages - * @param raw Enable raw messages - */ void setMessageTypes(bool status, bool packets, bool raw); - - /** - * Get connection status for all brokers - * - * @return Number of connected brokers - */ int getConnectedBrokers() const; - - /** - * Get queue status - * - * @return Number of queued packets - */ int getQueueSize() const; + bool isReady() const; - /** - * Set optional pointers for stats collection. - * If these are set, stats will be collected automatically when publishing status. - * - * @param dispatcher Dispatcher (or Mesh*) for air times and errors - * @param radio Radio for noise floor - * @param board MainBoard for battery voltage - * @param ms MillisecondClock for uptime - */ - void setStatsSources(mesh::Dispatcher* dispatcher, mesh::Radio* radio, + static unsigned long getWifiConnectedAtMillis(); + static void formatMqttStatusReply(char* buf, size_t bufsize, const NodePrefs* prefs); + + void setStatsSources(mesh::Dispatcher* dispatcher, mesh::Radio* radio, mesh::MainBoard* board, mesh::MillisecondClock* ms); - -private: - /** - * Generate client version string in format "meshcore/{firmware_version}" - * Memory-efficient: writes to provided buffer, no dynamic allocation - * - * @param buffer Buffer to write the client version string to - * @param buffer_size Size of the buffer (must be at least 64 bytes) - */ - void getClientVersion(char* buffer, size_t buffer_size) const; - - /** - * Log memory status for debugging - */ - void logMemoryStatus(); }; #endif