Implement Let's Mesh Analyzer integration in MQTT Bridge

- Switch from PubSub to PsychicMqttClient for async operations and websockets support
- Add support for US and EU Let's Mesh Analyzer servers with JWT authentication.
- Introduce CLI commands to enable/disable analyzer servers.
- Update NodePrefs to store analyzer server settings.
- Modify MQTTBridge to publish status and packet data to analyzer servers via WebSocket MQTT.
- Enhance documentation to reflect new features and configuration options.
This commit is contained in:
agessaman
2026-01-02 13:36:40 -08:00
parent e9fe66c1b0
commit 180c1d49fe
13 changed files with 4638 additions and 67 deletions
+20 -1
View File
@@ -56,6 +56,8 @@ The MQTT bridge comes with the following defaults:
- **WiFi Password**: "password_here" (must be configured)
- **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
@@ -69,6 +71,8 @@ The MQTT bridge comes with the following defaults:
- `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.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)
#### Set Commands
- `set mqtt.origin <name>` - Set device origin name
@@ -78,6 +82,8 @@ The MQTT bridge comes with the following defaults:
- `set mqtt.raw on|off` - Enable/disable raw messages
- `set mqtt.tx on|off` - Enable/disable TX packet messages
- `set mqtt.interval <ms>` - Set status publish interval (1000-3600000 ms)
- `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
### WiFi Commands
@@ -219,6 +225,16 @@ 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)
## Testing
1. Flash the MQTT bridge firmware to your device
@@ -235,11 +251,14 @@ Minimal raw packet data for map integration.
- **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
- Device-signed JWTs for WebSocket MQTT servers
- 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
+8 -4
View File
@@ -681,7 +681,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
#elif defined(WITH_ESPNOW_BRIDGE)
, bridge(&_prefs, _mgr, &rtc)
#elif defined(WITH_MQTT_BRIDGE)
, bridge(&_prefs, _mgr, &rtc)
, bridge(&_prefs, _mgr, &rtc, &self_id)
#endif
{
last_millis = 0;
@@ -743,9 +743,13 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
StrHelper::strncpy(_prefs.wifi_ssid, "ssid_here", sizeof(_prefs.wifi_ssid));
StrHelper::strncpy(_prefs.wifi_password, "password_here", sizeof(_prefs.wifi_password));
// Timezone defaults (Pacific Time with DST support)
StrHelper::strncpy(_prefs.timezone_string, "America/Los_Angeles", sizeof(_prefs.timezone_string));
_prefs.timezone_offset = -8; // fallback
// Timezone defaults (Pacific Time with DST support)
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
}
void MyMesh::begin(FILESYSTEM *fs) {
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python
#
# modified ESP32 x509 certificate bundle generation utility to run with platformio
#
# Converts PEM and DER certificates to a custom bundle format which stores just the
# subject name and public key to reduce space
#
# The bundle will have the format: number of certificates; crt 1 subject name length; crt 1 public key length;
# crt 1 subject name; crt 1 public key; crt 2...
#
# SPDX-FileCopyrightText: 2018-2022 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
from __future__ import with_statement
from pathlib import Path
import os
import struct
import sys
import requests
from io import open
Import("env")
try:
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
except ImportError:
env.Execute("$PYTHONEXE -m pip install cryptography")
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
ca_bundle_bin_file = 'x509_crt_bundle.bin'
mozilla_cacert_url = 'https://curl.se/ca/cacert.pem'
adafruit_filtered_cacert_url = 'https://raw.githubusercontent.com/adafruit/certificates/main/data/roots-filtered.pem'
adafruit_full_cacert_url = 'https://raw.githubusercontent.com/adafruit/certificates/main/data/roots-full.pem'
certs_dir = Path("./ssl_certs")
binary_dir = Path("./src/certs")
quiet = False
def download_cacert_file(source):
if source == "mozilla":
response = requests.get(mozilla_cacert_url)
elif source == "adafruit":
response = requests.get(adafruit_filtered_cacert_url)
elif source == "adafruit-full":
response = requests.get(adafruit_full_cacert_url)
else:
raise InputError('Invalid certificate source')
if response.status_code == 200:
# Ensure the directory exists, create it if necessary
os.makedirs(certs_dir, exist_ok=True)
# Generate the full path to the output file
output_file = os.path.join(certs_dir, "cacert.pem")
# Write the certificate bundle to the output file with utf-8 encoding
with open(output_file, "w", encoding="utf-8") as f:
f.write(response.text)
status('Certificate bundle downloaded to: %s' % output_file)
else:
status('Failed to fetch the certificate bundle.')
def status(msg):
""" Print status message to stderr """
if not quiet:
critical(msg)
def critical(msg):
""" Print critical message to stderr """
sys.stderr.write('SSL Cert Store: ')
sys.stderr.write(msg)
sys.stderr.write('\n')
class CertificateBundle:
def __init__(self):
self.certificates = []
self.compressed_crts = []
if os.path.isfile(ca_bundle_bin_file):
os.remove(ca_bundle_bin_file)
def add_from_path(self, crts_path):
found = False
for file_path in os.listdir(crts_path):
found |= self.add_from_file(os.path.join(crts_path, file_path))
if found is False:
raise InputError('No valid x509 certificates found in %s' % crts_path)
def add_from_file(self, file_path):
try:
if file_path.endswith('.pem'):
status('Parsing certificates from %s' % file_path)
with open(file_path, 'r', encoding='utf-8') as f:
crt_str = f.read()
self.add_from_pem(crt_str)
return True
elif file_path.endswith('.der'):
status('Parsing certificates from %s' % file_path)
with open(file_path, 'rb') as f:
crt_str = f.read()
self.add_from_der(crt_str)
return True
except ValueError:
critical('Invalid certificate in %s' % file_path)
raise InputError('Invalid certificate')
return False
def add_from_pem(self, crt_str):
""" A single PEM file may have multiple certificates """
crt = ''
count = 0
start = False
for strg in crt_str.splitlines(True):
if strg == '-----BEGIN CERTIFICATE-----\n' and start is False:
crt = ''
start = True
elif strg == '-----END CERTIFICATE-----\n' and start is True:
crt += strg + '\n'
start = False
self.certificates.append(x509.load_pem_x509_certificate(crt.encode(), default_backend()))
count += 1
if start is True:
crt += strg
if count == 0:
raise InputError('No certificate found')
status('Successfully added %d certificates' % count)
def add_from_der(self, crt_str):
self.certificates.append(x509.load_der_x509_certificate(crt_str, default_backend()))
status('Successfully added 1 certificate')
def create_bundle(self):
# Sort certificates in order to do binary search when looking up certificates
self.certificates = sorted(self.certificates, key=lambda cert: cert.subject.public_bytes(default_backend()))
bundle = struct.pack('>H', len(self.certificates))
for crt in self.certificates:
""" Read the public key as DER format """
pub_key = crt.public_key()
pub_key_der = pub_key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
""" Read the subject name as DER format """
sub_name_der = crt.subject.public_bytes(default_backend())
name_len = len(sub_name_der)
key_len = len(pub_key_der)
len_data = struct.pack('>HH', name_len, key_len)
bundle += len_data
bundle += sub_name_der
bundle += pub_key_der
return bundle
class InputError(RuntimeError):
def __init__(self, e):
super(InputError, self).__init__(e)
def main():
bundle = CertificateBundle()
try:
cert_source = env.GetProjectOption("board_ssl_cert_source")
if (cert_source == "mozilla" or cert_source == "adafruit"):
download_cacert_file(cert_source)
bundle.add_from_file(os.path.join(certs_dir, "cacert.pem"))
elif (cert_source == "folder"):
bundle.add_from_path(certs_dir)
except ValueError:
critical('Invalid configuration option: use \'board_ssl_cert_source\' parameter in platformio.ini' )
raise InputError('Invalid certificate')
status('Successfully added %d certificates in total' % len(bundle.certificates))
crt_bundle = bundle.create_bundle()
# Ensure the directory exists, create it if necessary
os.makedirs(binary_dir, exist_ok=True)
output_file = os.path.join(binary_dir, ca_bundle_bin_file)
with open(output_file, 'wb') as f:
f.write(crt_bundle)
status('Successfully created %s' % output_file)
try:
main()
except InputError as e:
print(e)
sys.exit(2)
Binary file not shown.
+60
View File
@@ -70,6 +70,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
file.read((uint8_t *)&_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156
file.read((uint8_t *)&_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157
file.read((uint8_t *)&_prefs->advert_loc_policy, sizeof (_prefs->advert_loc_policy)); // 161
<<<<<<< HEAD
file.read((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 (from upstream)
// MQTT settings - skip reading from main prefs file (now stored separately)
// For backward compatibility, we'll skip these bytes if they exist in old files
@@ -98,6 +99,29 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
file.read(skip_buffer, to_read);
remaining -= to_read;
}
=======
// MQTT settings
file.read((uint8_t *)&_prefs->mqtt_origin, sizeof(_prefs->mqtt_origin)); // 162
file.read((uint8_t *)&_prefs->mqtt_iata, sizeof(_prefs->mqtt_iata)); // 194
file.read((uint8_t *)&_prefs->mqtt_status_enabled, sizeof(_prefs->mqtt_status_enabled)); // 202
file.read((uint8_t *)&_prefs->mqtt_packets_enabled, sizeof(_prefs->mqtt_packets_enabled)); // 203
file.read((uint8_t *)&_prefs->mqtt_raw_enabled, sizeof(_prefs->mqtt_raw_enabled)); // 204
file.read((uint8_t *)&_prefs->mqtt_tx_enabled, sizeof(_prefs->mqtt_tx_enabled)); // 205
file.read((uint8_t *)&_prefs->mqtt_status_interval, sizeof(_prefs->mqtt_status_interval)); // 206
// WiFi settings
file.read((uint8_t *)&_prefs->wifi_ssid, sizeof(_prefs->wifi_ssid)); // 209
file.read((uint8_t *)&_prefs->wifi_password, sizeof(_prefs->wifi_password)); // 241
// Timezone settings
file.read((uint8_t *)&_prefs->timezone_string, sizeof(_prefs->timezone_string)); // 305
file.read((uint8_t *)&_prefs->timezone_offset, sizeof(_prefs->timezone_offset)); // 337
// Let's Mesh Analyzer settings
file.read((uint8_t *)&_prefs->mqtt_analyzer_us_enabled, sizeof(_prefs->mqtt_analyzer_us_enabled)); // 338
file.read((uint8_t *)&_prefs->mqtt_analyzer_eu_enabled, sizeof(_prefs->mqtt_analyzer_eu_enabled)); // 339
// 209
>>>>>>> 6f42dc3 (Implement Let's Mesh Analyzer integration in MQTT Bridge)
// sanitise bad pref values
_prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f);
@@ -177,6 +201,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)&_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156
file.write((uint8_t *)&_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157
file.write((uint8_t *)&_prefs->advert_loc_policy, sizeof(_prefs->advert_loc_policy)); // 161
<<<<<<< HEAD
file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 (from upstream)
// MQTT settings - no longer saved here (stored in separate /mqtt_prefs file)
// Write zeros/padding to maintain file format compatibility
@@ -203,6 +228,29 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write(pad, to_write);
remaining -= to_write;
}
=======
// MQTT settings
file.write((uint8_t *)&_prefs->mqtt_origin, sizeof(_prefs->mqtt_origin)); // 162
file.write((uint8_t *)&_prefs->mqtt_iata, sizeof(_prefs->mqtt_iata)); // 194
file.write((uint8_t *)&_prefs->mqtt_status_enabled, sizeof(_prefs->mqtt_status_enabled)); // 202
file.write((uint8_t *)&_prefs->mqtt_packets_enabled, sizeof(_prefs->mqtt_packets_enabled)); // 203
file.write((uint8_t *)&_prefs->mqtt_raw_enabled, sizeof(_prefs->mqtt_raw_enabled)); // 204
file.write((uint8_t *)&_prefs->mqtt_tx_enabled, sizeof(_prefs->mqtt_tx_enabled)); // 205
file.write((uint8_t *)&_prefs->mqtt_status_interval, sizeof(_prefs->mqtt_status_interval)); // 206
// WiFi settings
file.write((uint8_t *)&_prefs->wifi_ssid, sizeof(_prefs->wifi_ssid)); // 209
file.write((uint8_t *)&_prefs->wifi_password, sizeof(_prefs->wifi_password)); // 241
// Timezone settings
file.write((uint8_t *)&_prefs->timezone_string, sizeof(_prefs->timezone_string)); // 305
file.write((uint8_t *)&_prefs->timezone_offset, sizeof(_prefs->timezone_offset)); // 337
// Let's Mesh Analyzer settings
file.write((uint8_t *)&_prefs->mqtt_analyzer_us_enabled, sizeof(_prefs->mqtt_analyzer_us_enabled)); // 338
file.write((uint8_t *)&_prefs->mqtt_analyzer_eu_enabled, sizeof(_prefs->mqtt_analyzer_eu_enabled)); // 339
// 209
>>>>>>> 6f42dc3 (Implement Let's Mesh Analyzer integration in MQTT Bridge)
file.close();
}
@@ -413,6 +461,10 @@ 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", 16) == 0) {
sprintf(reply, "> %s", _prefs->mqtt_analyzer_us_enabled ? "on" : "off");
} else if (memcmp(config, "mqtt.analyzer.eu", 16) == 0) {
sprintf(reply, "> %s", _prefs->mqtt_analyzer_eu_enabled ? "on" : "off");
#endif
} else {
sprintf(reply, "??: %s", config);
@@ -661,6 +713,14 @@ 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.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");
#endif
} else {
sprintf(reply, "unknown config: %s", config);
+6
View File
@@ -98,6 +98,7 @@ struct MQTTPrefs {
// 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
<<<<<<< HEAD
<<<<<<< HEAD
// MQTT server settings
@@ -105,12 +106,17 @@ struct MQTTPrefs {
uint16_t mqtt_port; // MQTT server port
char mqtt_username[32]; // MQTT username
char mqtt_password[64]; // MQTT password
=======
>>>>>>> 6f42dc3 (Implement Let's Mesh Analyzer integration in MQTT Bridge)
// 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
<<<<<<< HEAD
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
=======
>>>>>>> 6f42dc3 (Implement Let's Mesh Analyzer integration in MQTT Bridge)
};
#endif
+252
View File
@@ -0,0 +1,252 @@
#include "JWTHelper.h"
#include <ArduinoJson.h>
#include <SHA256.h>
#include <string.h>
#include "ed_25519.h"
#include "mbedtls/base64.h"
// Base64 URL encoding table (without padding)
static const char base64url_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
bool JWTHelper::createAuthToken(
const mesh::LocalIdentity& identity,
const char* audience,
unsigned long issuedAt,
unsigned long expiresIn,
char* token,
size_t tokenSize
) {
Serial.printf("JWTHelper: Starting JWT creation for audience: %s\n", audience);
if (!audience || !token || tokenSize == 0) {
Serial.printf("JWTHelper: Invalid parameters - audience: %p, token: %p, tokenSize: %d\n", audience, token, (int)tokenSize);
return false;
}
// Use current time if not specified
if (issuedAt == 0) {
issuedAt = time(nullptr);
}
Serial.printf("JWTHelper: Using issuedAt: %lu\n", issuedAt);
// Create header
char header[256];
size_t headerLen = createHeader(header, sizeof(header));
if (headerLen == 0) {
Serial.printf("JWTHelper: Failed to create header\n");
return false;
}
Serial.printf("JWTHelper: Header created, length: %d\n", (int)headerLen);
Serial.printf("JWTHelper: Header: %s\n", header);
// Get public key as UPPERCASE HEX string (not base64!)
char publicKeyHex[65]; // 32 bytes * 2 + null terminator
mesh::Utils::toHex(publicKeyHex, identity.pub_key, PUB_KEY_SIZE);
// Convert to uppercase
for (int i = 0; publicKeyHex[i]; i++) {
publicKeyHex[i] = toupper(publicKeyHex[i]);
}
Serial.printf("JWTHelper: Public key hex: %s (length: %d)\n", publicKeyHex, (int)strlen(publicKeyHex));
// Create payload with HEX public key (not base64!)
char payload[512];
size_t payloadLen = createPayload(publicKeyHex, audience, issuedAt, expiresIn, payload, sizeof(payload));
if (payloadLen == 0) {
Serial.printf("JWTHelper: Failed to create payload\n");
return false;
}
Serial.printf("JWTHelper: Payload created, length: %d\n", (int)payloadLen);
Serial.printf("JWTHelper: Payload: %s\n", payload);
// Create signing input: header.payload
char signingInput[768];
size_t signingInputLen = headerLen + 1 + payloadLen;
if (signingInputLen >= sizeof(signingInput)) {
Serial.printf("JWTHelper: Signing input too large: %d >= %d\n", (int)signingInputLen, (int)sizeof(signingInput));
return false;
}
memcpy(signingInput, header, headerLen);
signingInput[headerLen] = '.';
memcpy(signingInput + headerLen + 1, payload, payloadLen);
Serial.printf("JWTHelper: Signing input created, length: %d\n", (int)signingInputLen);
// Sign the data using direct Ed25519 signing
uint8_t signature[64];
// Create a non-const copy of the identity to access writeTo method
mesh::LocalIdentity identity_copy = identity;
// Export the private and public keys using the writeTo method
uint8_t export_buffer[96]; // PRV_KEY_SIZE + PUB_KEY_SIZE = 64 + 32 = 96 bytes
size_t exported_size = identity_copy.writeTo(export_buffer, sizeof(export_buffer));
if (exported_size != 96) {
Serial.printf("JWTHelper: Failed to export keys, got %d bytes instead of 96\n", (int)exported_size);
return false;
}
// The first 64 bytes are the private key, next 32 bytes are the public key
uint8_t* private_key = export_buffer; // First 64 bytes
uint8_t* public_key = export_buffer + 64; // Next 32 bytes
Serial.printf("JWTHelper: Using direct Ed25519 signing\n");
Serial.printf("JWTHelper: Private key length: %d, Public key length: %d\n", 64, 32);
// Use direct Ed25519 signing
ed25519_sign(signature, (const unsigned char*)signingInput, signingInputLen, public_key, private_key);
Serial.printf("JWTHelper: Signature created using direct Ed25519\n");
// Verify the signature locally
int verify_result = ed25519_verify(signature, (const unsigned char*)signingInput, signingInputLen, public_key);
Serial.printf("JWTHelper: Signature verification result: %d (should be 1 for valid)\n", verify_result);
if (verify_result != 1) {
Serial.println("JWTHelper: ERROR - Signature verification failed!");
return false;
}
// Log the exact signing input
Serial.printf("JWTHelper: Signing input: %s\n", signingInput);
Serial.printf("JWTHelper: Signing input hex: ");
for (size_t i = 0; i < signingInputLen; i++) {
Serial.printf("%02x", signingInput[i]);
}
Serial.println();
// Log the signature
Serial.printf("JWTHelper: Signature hex: ");
for (int i = 0; i < 64; i++) {
Serial.printf("%02x", signature[i]);
}
Serial.println();
// Convert signature to hex (MeshCore Decoder expects hex, not base64url)
char signatureHex[129]; // 64 bytes * 2 + null terminator
for (int i = 0; i < 64; i++) {
sprintf(signatureHex + (i * 2), "%02X", signature[i]);
}
signatureHex[128] = '\0';
Serial.printf("JWTHelper: Signature converted to hex, length: %d\n", (int)strlen(signatureHex));
Serial.printf("JWTHelper: Signature Hex: %s\n", signatureHex);
// Create final token: header.payload.signatureHex (MeshCore Decoder format)
size_t sigHexLen = strlen(signatureHex);
size_t totalLen = headerLen + 1 + payloadLen + 1 + sigHexLen;
if (totalLen >= tokenSize) {
Serial.printf("JWTHelper: Token too large: %d >= %d\n", (int)totalLen, (int)tokenSize);
return false;
}
memcpy(token, header, headerLen);
token[headerLen] = '.';
memcpy(token + headerLen + 1, payload, payloadLen);
token[headerLen + 1 + payloadLen] = '.';
memcpy(token + headerLen + 1 + payloadLen + 1, signatureHex, sigHexLen);
token[totalLen] = '\0';
Serial.printf("JWTHelper: JWT token created successfully, total length: %d\n", (int)totalLen);
Serial.printf("JWTHelper: JWT Token: %s\n", token);
return true;
}
size_t JWTHelper::base64UrlEncode(const uint8_t* input, size_t inputLen, char* output, size_t outputSize) {
Serial.printf("JWTHelper: base64UrlEncode called with inputLen: %d, outputSize: %d\n", (int)inputLen, (int)outputSize);
if (!input || !output || outputSize == 0) {
Serial.printf("JWTHelper: base64UrlEncode invalid parameters\n");
return 0;
}
// Use ESP32's built-in mbedTLS base64 encoding
size_t outlen = 0;
int ret = mbedtls_base64_encode((unsigned char*)output, outputSize - 1, &outlen, input, inputLen);
if (ret != 0) {
Serial.printf("JWTHelper: mbedtls_base64_encode failed with error: %d\n", ret);
return 0;
}
Serial.printf("JWTHelper: mbedtls_base64_encode result: %s (outlen: %d)\n", output, (int)outlen);
// Convert to base64 URL format (replace + with -, / with _, remove padding =)
String encoded(output);
encoded.replace('+', '-');
encoded.replace('/', '_');
encoded.replace("=", "");
// Copy back to output buffer
size_t len = encoded.length();
if (len >= outputSize) {
Serial.printf("JWTHelper: base64UrlEncode output too large: %d >= %d\n", (int)len, (int)outputSize);
return 0;
}
strcpy(output, encoded.c_str());
Serial.printf("JWTHelper: base64UrlEncode completed, outputLen: %d\n", (int)len);
return len;
}
size_t JWTHelper::createHeader(char* output, size_t outputSize) {
Serial.printf("JWTHelper: createHeader called with outputSize: %d\n", (int)outputSize);
// Create JWT header: {"alg":"Ed25519","typ":"JWT"}
DynamicJsonDocument doc(256);
doc["alg"] = "Ed25519";
doc["typ"] = "JWT";
// Use temporary buffer for JSON
char jsonBuffer[256];
size_t len = serializeJson(doc, jsonBuffer, sizeof(jsonBuffer));
Serial.printf("JWTHelper: JSON serialized, length: %d\n", (int)len);
if (len == 0 || len >= sizeof(jsonBuffer)) {
Serial.printf("JWTHelper: JSON serialization failed or too large\n");
return 0;
}
// Base64 URL encode from temporary buffer to output
size_t encodedLen = base64UrlEncode((uint8_t*)jsonBuffer, len, output, outputSize);
Serial.printf("JWTHelper: Header base64 encoded, length: %d\n", (int)encodedLen);
return encodedLen;
}
size_t JWTHelper::createPayload(
const char* publicKey,
const char* audience,
unsigned long issuedAt,
unsigned long expiresIn,
char* output,
size_t outputSize
) {
Serial.printf("JWTHelper: createPayload called with outputSize: %d\n", (int)outputSize);
Serial.printf("JWTHelper: publicKey: %s, audience: %s, issuedAt: %lu, expiresIn: %lu\n",
publicKey, audience, issuedAt, expiresIn);
// Create JWT payload
DynamicJsonDocument doc(512);
doc["publicKey"] = publicKey;
doc["aud"] = audience;
doc["iat"] = issuedAt;
if (expiresIn > 0) {
doc["exp"] = issuedAt + expiresIn;
}
// Use temporary buffer for JSON
char jsonBuffer[512];
size_t len = serializeJson(doc, jsonBuffer, sizeof(jsonBuffer));
Serial.printf("JWTHelper: Payload JSON serialized, length: %d\n", (int)len);
if (len == 0 || len >= sizeof(jsonBuffer)) {
Serial.printf("JWTHelper: Payload JSON serialization failed or too large\n");
return 0;
}
// Base64 URL encode from temporary buffer to output
size_t encodedLen = base64UrlEncode((uint8_t*)jsonBuffer, len, output, outputSize);
Serial.printf("JWTHelper: Payload base64 encoded, length: %d\n", (int)encodedLen);
return encodedLen;
}
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include "MeshCore.h"
#include "Identity.h"
/**
* JWT Helper for creating authentication tokens
*
* This class provides functionality to create JWT-style authentication tokens
* signed with Ed25519 private keys for MQTT authentication.
*/
class JWTHelper {
public:
/**
* Create an authentication token for MQTT authentication
*
* @param identity LocalIdentity instance for signing
* @param audience Audience string (e.g., "mqtt-us-v1.letsmesh.net")
* @param issuedAt Unix timestamp (0 for current time)
* @param expiresIn Expiration time in seconds (0 for no expiration)
* @param token Buffer to store the resulting token
* @param tokenSize Size of the token buffer
* @return true if token was created successfully
*/
static bool createAuthToken(
const mesh::LocalIdentity& identity,
const char* audience,
unsigned long issuedAt = 0,
unsigned long expiresIn = 0,
char* token = nullptr,
size_t tokenSize = 0
);
private:
/**
* Base64 URL encode data
*
* @param input Input data
* @param inputLen Length of input data
* @param output Output buffer
* @param outputSize Size of output buffer
* @return Length of encoded data, or 0 on error
*/
static size_t base64UrlEncode(const uint8_t* input, size_t inputLen, char* output, size_t outputSize);
/**
* Create JWT header
*
* @param output Output buffer
* @param outputSize Size of output buffer
* @return Length of header, or 0 on error
*/
static size_t createHeader(char* output, size_t outputSize);
/**
* Create JWT payload
*
* @param publicKey Public key in hex format
* @param audience Audience string
* @param issuedAt Issued at timestamp
* @param expiresIn Expiration time in seconds (0 for no expiration)
* @param output Output buffer
* @param outputSize Size of output buffer
* @return Length of payload, or 0 on error
*/
static size_t createPayload(
const char* publicKey,
const char* audience,
unsigned long issuedAt,
unsigned long expiresIn,
char* output,
size_t outputSize
);
};
+406 -56
View File
@@ -4,14 +4,18 @@
#include <WiFiUdp.h>
#include <Timezone.h>
// Using ESP32's built-in certificate bundle
#ifdef WITH_MQTT_BRIDGE
MQTTBridge::MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc)
: BridgeBase(prefs, mgr, rtc), _mqtt_client(nullptr), _wifi_client(nullptr),
MQTTBridge::MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity)
: BridgeBase(prefs, mgr, rtc), _mqtt_client(nullptr),
_active_brokers(0), _queue_head(0), _queue_tail(0), _queue_count(0),
_last_status_publish(0), _status_interval(300000), // 5 minutes default
_ntp_client(_ntp_udp, "pool.ntp.org", 0, 60000), _last_ntp_sync(0), _ntp_synced(false),
_timezone(nullptr), _last_raw_len(0), _last_snr(0), _last_rssi(0), _last_raw_timestamp(0) {
_ntp_client(_ntp_udp, "pool.ntp.org", 0, 60000), _last_ntp_sync(0), _ntp_synced(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) {
// Initialize default values
strncpy(_origin, "MeshCore-Repeater", sizeof(_origin) - 1);
@@ -102,13 +106,45 @@ void MQTTBridge::begin() {
return;
}
// Initialize WiFi client
_wifi_client = new WiFiClient();
_mqtt_client = new PubSubClient(*_wifi_client);
// Initialize PsychicMqttClient
_mqtt_client = new PsychicMqttClient();
// Set up event callbacks for the main MQTT client
_mqtt_client->onConnect([this](bool sessionPresent) {
MQTT_DEBUG_PRINTLN("MQTT client connected, session present: %s", sessionPresent ? "true" : "false");
// Update broker connection status
for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) {
if (_brokers[i].enabled && !_brokers[i].connected) {
_brokers[i].connected = true;
_active_brokers++;
MQTT_DEBUG_PRINTLN("Broker %d marked as connected", i);
break;
}
}
});
_mqtt_client->onDisconnect([this](bool sessionPresent) {
MQTT_DEBUG_PRINTLN("MQTT client disconnected, session present: %s", sessionPresent ? "true" : "false");
// Update broker connection status
for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) {
if (_brokers[i].connected) {
_brokers[i].connected = false;
_active_brokers--;
MQTT_DEBUG_PRINTLN("Broker %d marked as disconnected", i);
break;
}
}
});
// Set default broker (meshtastic.pugetmesh.org)
setBroker(0, "meshtastic.pugetmesh.org", 1883, "meshdev", "large4cats", true);
// Setup Let's Mesh Analyzer servers
setupAnalyzerServers();
// Setup PsychicMqttClient WebSocket clients for analyzer servers
setupAnalyzerClients();
// Connect to brokers
connectToBrokers();
@@ -127,6 +163,18 @@ void MQTTBridge::end() {
}
}
// 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;
}
// Clear packet queue
_queue_count = 0;
_queue_head = 0;
@@ -137,10 +185,6 @@ void MQTTBridge::end() {
delete _mqtt_client;
_mqtt_client = nullptr;
}
if (_wifi_client) {
delete _wifi_client;
_wifi_client = nullptr;
}
_initialized = false;
MQTT_DEBUG_PRINTLN("MQTT Bridge stopped");
@@ -152,6 +196,9 @@ void MQTTBridge::loop() {
// Maintain broker connections
connectToBrokers();
// Maintain analyzer server connections
maintainAnalyzerConnections();
// Process packet queue
processPacketQueue();
@@ -168,8 +215,13 @@ void MQTTBridge::loop() {
}
void MQTTBridge::onPacketReceived(mesh::Packet *packet) {
if (!_initialized || !_packets_enabled) return;
if (!_initialized || !_packets_enabled) {
MQTT_DEBUG_PRINTLN("Packet received but not processing - initialized: %s, packets_enabled: %s",
_initialized ? "true" : "false", _packets_enabled ? "true" : "false");
return;
}
MQTT_DEBUG_PRINTLN("Packet received, queuing for transmission");
// Queue packet for transmission
queuePacket(packet, false);
}
@@ -182,6 +234,8 @@ void MQTTBridge::sendPacket(mesh::Packet *packet) {
}
void MQTTBridge::connectToBrokers() {
// 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;
@@ -191,42 +245,32 @@ void MQTTBridge::connectToBrokers() {
MQTT_DEBUG_PRINTLN("Connecting to broker %d: %s:%d", i, _brokers[i].host, _brokers[i].port);
// Set broker for this connection
_mqtt_client->setServer(_brokers[i].host, _brokers[i].port);
// Generate unique client ID
char client_id[32];
snprintf(client_id, sizeof(client_id), "%s_%d_%lu", _origin, i, millis());
// Attempt connection
bool connected = _mqtt_client->connect(
client_id,
_brokers[i].username,
_brokers[i].password
);
// 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);
if (connected) {
_brokers[i].connected = true;
_brokers[i].reconnect_interval = 5000; // Reset to 5 seconds
_active_brokers++;
MQTT_DEBUG_PRINTLN("Connected to broker %d", i);
// Publish initial status
if (_status_enabled) {
publishStatus();
}
} else {
_brokers[i].connected = false;
_brokers[i].last_attempt = millis();
// Exponential backoff: 5s, 10s, 20s, 30s max
_brokers[i].reconnect_interval = min(30000UL, _brokers[i].reconnect_interval * 2);
MQTT_DEBUG_PRINTLN("Failed to connect to broker %d", i);
// 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)
_mqtt_client->connect();
// Update attempt timestamp
_brokers[i].last_attempt = millis();
MQTT_DEBUG_PRINTLN("Initiating connection to broker %d", i);
}
// Maintain connection
if (_brokers[i].connected) {
_mqtt_client->loop();
// PsychicMqttClient handles connection maintenance internally
// TODO: Implement proper connection state checking with callbacks
if (!_mqtt_client->connected()) {
_brokers[i].connected = false;
_active_brokers--;
@@ -237,13 +281,22 @@ void MQTTBridge::connectToBrokers() {
}
void MQTTBridge::processPacketQueue() {
if (_queue_count == 0 || !isAnyBrokerConnected()) return;
if (_queue_count == 0 || !isAnyBrokerConnected()) {
if (_queue_count > 0) {
MQTT_DEBUG_PRINTLN("Queue has %d packets but no brokers connected", _queue_count);
}
return;
}
MQTT_DEBUG_PRINTLN("Processing packet queue - count: %d", _queue_count);
// Process up to 5 packets per loop to avoid blocking
int processed = 0;
while (_queue_count > 0 && processed < 5) {
QueuedPacket& queued = _packet_queue[_queue_head];
MQTT_DEBUG_PRINTLN("Processing queued packet (is_tx: %s)", queued.is_tx ? "true" : "false");
// Publish packet
publishPacket(queued.packet, queued.is_tx);
@@ -296,19 +349,27 @@ void MQTTBridge::publishStatus() {
sizeof(json_buffer)
);
if (len > 0) {
// Publish to all connected brokers
for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) {
if (_brokers[i].enabled && _brokers[i].connected) {
char topic[128];
snprintf(topic, sizeof(topic), "meshcore/%s/%s/status", _iata, _device_id);
MQTT_DEBUG_PRINTLN("Publishing status to topic: %s", topic);
_mqtt_client->setServer(_brokers[i].host, _brokers[i].port);
_mqtt_client->publish(topic, json_buffer, true); // retained
}
}
}
if (len > 0) {
// Publish to all connected brokers
for (int i = 0; i < MAX_MQTT_BROKERS_COUNT; i++) {
if (_brokers[i].enabled && _brokers[i].connected) {
char topic[128];
snprintf(topic, sizeof(topic), "meshcore/%s/%s/status", _iata, _device_id);
MQTT_DEBUG_PRINTLN("Publishing status to topic: %s", topic);
// Set broker for this connection (PsychicMqttClient uses URI format)
char broker_uri[128];
snprintf(broker_uri, sizeof(broker_uri), "mqtt://%s:%d", _brokers[i].host, _brokers[i].port);
_mqtt_client->setServer(broker_uri);
_mqtt_client->publish(topic, 1, true, json_buffer, strlen(json_buffer)); // qos=1, retained=true
}
}
// Also publish to Let's Mesh Analyzer servers
char analyzer_topic[128];
snprintf(analyzer_topic, sizeof(analyzer_topic), "meshcore/%s/%s/status", _iata, _device_id);
publishToAnalyzerServers(analyzer_topic, json_buffer, true);
}
}
void MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx) {
@@ -344,10 +405,18 @@ void MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx) {
snprintf(topic, sizeof(topic), "meshcore/%s/%s/packets", _iata, _device_id);
MQTT_DEBUG_PRINTLN("Publishing packet to topic: %s", topic);
_mqtt_client->setServer(_brokers[i].host, _brokers[i].port);
_mqtt_client->publish(topic, json_buffer);
// Set broker for this connection (PsychicMqttClient uses URI format)
char broker_uri[128];
snprintf(broker_uri, sizeof(broker_uri), "mqtt://%s:%d", _brokers[i].host, _brokers[i].port);
_mqtt_client->setServer(broker_uri);
_mqtt_client->publish(topic, 1, false, json_buffer, strlen(json_buffer)); // qos=1, retained=false
}
}
// Also publish to Let's Mesh Analyzer servers
char analyzer_topic[128];
snprintf(analyzer_topic, sizeof(analyzer_topic), "meshcore/%s/%s/packets", _iata, _device_id);
publishToAnalyzerServers(analyzer_topic, json_buffer, false);
}
}
@@ -373,10 +442,18 @@ void MQTTBridge::publishRaw(mesh::Packet* packet) {
char topic[128];
snprintf(topic, sizeof(topic), "meshcore/%s/%s/raw", _iata, _device_id);
_mqtt_client->setServer(_brokers[i].host, _brokers[i].port);
_mqtt_client->publish(topic, json_buffer);
// Set broker for this connection (PsychicMqttClient uses URI format)
char broker_uri[128];
snprintf(broker_uri, sizeof(broker_uri), "mqtt://%s:%d", _brokers[i].host, _brokers[i].port);
_mqtt_client->setServer(broker_uri);
_mqtt_client->publish(topic, 1, false, json_buffer, strlen(json_buffer)); // qos=1, retained=false
}
}
// Also publish to Let's Mesh Analyzer servers
char analyzer_topic[128];
snprintf(analyzer_topic, sizeof(analyzer_topic), "meshcore/%s/%s/raw", _iata, _device_id);
publishToAnalyzerServers(analyzer_topic, json_buffer, false);
}
}
@@ -473,6 +550,279 @@ void MQTTBridge::storeRawRadioData(const uint8_t* raw_data, int len, float snr,
}
}
void MQTTBridge::setupAnalyzerServers() {
// Update analyzer server settings from preferences
_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
if (_analyzer_us_enabled || _analyzer_eu_enabled) {
if (createAuthToken()) {
MQTT_DEBUG_PRINTLN("Created authentication token for analyzer servers");
} else {
MQTT_DEBUG_PRINTLN("Failed to create authentication token");
}
}
}
bool MQTTBridge::createAuthToken() {
if (!_identity) {
MQTT_DEBUG_PRINTLN("No identity available for creating 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);
MQTT_DEBUG_PRINTLN("Creating auth token for username: %s", _analyzer_username);
bool us_token_created = false;
bool eu_token_created = false;
// Create JWT token for US server
if (_analyzer_us_enabled) {
MQTT_DEBUG_PRINTLN("Creating JWT token for US server...");
if (JWTHelper::createAuthToken(
*_identity, "mqtt-us-v1.letsmesh.net",
0, 86400, _auth_token_us, sizeof(_auth_token_us))) {
MQTT_DEBUG_PRINTLN("Created auth token for US server");
us_token_created = true;
} else {
MQTT_DEBUG_PRINTLN("Failed to create auth token for US server");
}
}
// Create JWT token for EU server
if (_analyzer_eu_enabled) {
MQTT_DEBUG_PRINTLN("Creating JWT token for EU server...");
if (JWTHelper::createAuthToken(
*_identity, "mqtt-eu-v1.letsmesh.net",
0, 86400, _auth_token_eu, sizeof(_auth_token_eu))) {
MQTT_DEBUG_PRINTLN("Created auth token for EU server");
eu_token_created = true;
} else {
MQTT_DEBUG_PRINTLN("Failed to create auth token for EU server");
}
}
return us_token_created || eu_token_created;
}
void MQTTBridge::publishToAnalyzerServers(const char* topic, const char* payload, bool retained) {
if (!_analyzer_us_enabled && !_analyzer_eu_enabled) {
MQTT_DEBUG_PRINTLN("No analyzer servers enabled, skipping publish to topic: %s", topic);
return;
}
MQTT_DEBUG_PRINTLN("Publishing to analyzer servers via WebSocket MQTT");
MQTT_DEBUG_PRINTLN("Topic: %s", topic);
MQTT_DEBUG_PRINTLN("Payload length: %d", strlen(payload));
MQTT_DEBUG_PRINTLN("US enabled: %s, EU enabled: %s", _analyzer_us_enabled ? "true" : "false", _analyzer_eu_enabled ? "true" : "false");
// Publish to US server if enabled
if (_analyzer_us_enabled && _analyzer_us_client) {
MQTT_DEBUG_PRINTLN("Publishing to US analyzer server");
publishToAnalyzerClient(_analyzer_us_client, topic, payload, retained);
} else {
MQTT_DEBUG_PRINTLN("US analyzer server not available (enabled: %s, client: %s)",
_analyzer_us_enabled ? "true" : "false", _analyzer_us_client ? "exists" : "null");
}
// Publish to EU server if enabled
if (_analyzer_eu_enabled && _analyzer_eu_client) {
MQTT_DEBUG_PRINTLN("Publishing to EU analyzer server");
publishToAnalyzerClient(_analyzer_eu_client, topic, payload, retained);
} else {
MQTT_DEBUG_PRINTLN("EU analyzer server not available (enabled: %s, client: %s)",
_analyzer_eu_enabled ? "true" : "false", _analyzer_eu_client ? "exists" : "null");
}
}
// 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() {
if (!_analyzer_us_enabled && !_analyzer_eu_enabled) {
MQTT_DEBUG_PRINTLN("No analyzer servers enabled, skipping PsychicMqttClient setup");
return;
}
MQTT_DEBUG_PRINTLN("Setting up PsychicMqttClient WebSocket clients...");
// Setup US server client
if (_analyzer_us_enabled) {
_analyzer_us_client = new PsychicMqttClient();
// Set up event callbacks for US server
_analyzer_us_client->onConnect([this](bool sessionPresent) {
MQTT_DEBUG_PRINTLN("Connected to Let's Mesh US server, session present: %s", sessionPresent ? "true" : "false");
// Publish status message when connected
publishStatusToAnalyzerClient(_analyzer_us_client, "mqtt-us-v1.letsmesh.net");
});
_analyzer_us_client->onDisconnect([this](bool sessionPresent) {
MQTT_DEBUG_PRINTLN("Disconnected from Let's Mesh US server, session present: %s", sessionPresent ? "true" : "false");
});
_analyzer_us_client->onError([this](esp_mqtt_error_codes error) {
MQTT_DEBUG_PRINTLN("Let's Mesh US server error - error_type: %d, connect_return_code: %d",
error.error_type, error.connect_return_code);
});
// Set up WebSocket MQTT over TLS connection to US server
_analyzer_us_client->setServer("wss://mqtt-us-v1.letsmesh.net:443/mqtt");
MQTT_DEBUG_PRINTLN("US Server - Username: %s", _analyzer_username);
MQTT_DEBUG_PRINTLN("US Server - Auth token length: %d", strlen(_auth_token_us));
MQTT_DEBUG_PRINTLN("US Server - Auth token (first 50 chars): %.50s...", _auth_token_us);
_analyzer_us_client->setCredentials(_analyzer_username, _auth_token_us);
// Configure TLS - use specific GTS Root R4 certificate
MQTT_DEBUG_PRINTLN("Using GTS Root R4 certificate for US server");
_analyzer_us_client->setCACert(GTS_ROOT_R4);
// Connect to US server (async connection)
_analyzer_us_client->connect();
MQTT_DEBUG_PRINTLN("Initiating connection to Let's Mesh US server");
}
// Setup EU server client
if (_analyzer_eu_enabled) {
_analyzer_eu_client = new PsychicMqttClient();
// Set up event callbacks for EU server
_analyzer_eu_client->onConnect([this](bool sessionPresent) {
MQTT_DEBUG_PRINTLN("Connected to Let's Mesh EU server, session present: %s", sessionPresent ? "true" : "false");
// Publish status message when connected
publishStatusToAnalyzerClient(_analyzer_eu_client, "mqtt-eu-v1.letsmesh.net");
});
_analyzer_eu_client->onDisconnect([this](bool sessionPresent) {
MQTT_DEBUG_PRINTLN("Disconnected from Let's Mesh EU server, session present: %s", sessionPresent ? "true" : "false");
});
_analyzer_eu_client->onError([this](esp_mqtt_error_codes error) {
MQTT_DEBUG_PRINTLN("Let's Mesh EU server error - error_type: %d, connect_return_code: %d",
error.error_type, error.connect_return_code);
});
// Set up WebSocket MQTT over TLS connection to EU server
_analyzer_eu_client->setServer("wss://mqtt-eu-v1.letsmesh.net:443/mqtt");
MQTT_DEBUG_PRINTLN("EU Server - Username: %s", _analyzer_username);
MQTT_DEBUG_PRINTLN("EU Server - Auth token length: %d", strlen(_auth_token_eu));
MQTT_DEBUG_PRINTLN("EU Server - Auth token (first 50 chars): %.50s...", _auth_token_eu);
_analyzer_eu_client->setCredentials(_analyzer_username, _auth_token_eu);
// Configure TLS - use specific GTS Root R4 certificate
MQTT_DEBUG_PRINTLN("Using GTS Root R4 certificate for EU server");
_analyzer_eu_client->setCACert(GTS_ROOT_R4);
// Connect to EU server (async connection)
_analyzer_eu_client->connect();
MQTT_DEBUG_PRINTLN("Initiating connection to Let's Mesh EU server");
}
}
void MQTTBridge::publishToAnalyzerClient(PsychicMqttClient* client, const char* topic, const char* payload, bool retained) {
if (!client) {
MQTT_DEBUG_PRINTLN("PsychicMqttClient is null");
return;
}
if (!client->connected()) {
MQTT_DEBUG_PRINTLN("PsychicMqttClient not connected - skipping publish to topic: %s", topic);
return;
}
MQTT_DEBUG_PRINTLN("Publishing to analyzer client - topic: %s, payload length: %d, retained: %s",
topic, strlen(payload), retained ? "true" : "false");
// Publish message using PsychicMqttClient API
int result = client->publish(topic, 1, retained, payload, strlen(payload));
if (result > 0) {
MQTT_DEBUG_PRINTLN("PsychicMqttClient message published successfully, result=%d", result);
} else {
MQTT_DEBUG_PRINTLN("PsychicMqttClient publish failed, result=%d", result);
}
}
void MQTTBridge::publishStatusToAnalyzerClient(PsychicMqttClient* client, const char* server_name) {
if (!client || !client->connected()) {
return;
}
// Create status message
char status_topic[128];
snprintf(status_topic, sizeof(status_topic), "meshcore/%s/%s/status", _iata, _device_id);
// Build status JSON
char status_payload[512];
snprintf(status_payload, sizeof(status_payload),
"{"
"\"device_id\":\"%s\","
"\"origin_id\":\"%s\","
"\"origin\":\"%s\","
"\"iata\":\"%s\","
"\"server\":\"%s\","
"\"status\":\"connected\","
"\"timestamp\":%lu,"
"\"uptime\":%lu"
"}",
_device_id,
_device_id, // origin_id should be the device_id (public key)
_origin,
_iata,
server_name,
time(nullptr),
millis() / 1000
);
MQTT_DEBUG_PRINTLN("Publishing status to %s server", server_name);
MQTT_DEBUG_PRINTLN("Status topic: %s", status_topic);
MQTT_DEBUG_PRINTLN("Status payload: %s", status_payload);
// Publish status message (retained)
int result = client->publish(status_topic, 1, true, status_payload, strlen(status_payload));
if (result > 0) {
MQTT_DEBUG_PRINTLN("Status published to %s server successfully, result=%d", server_name, result);
} else {
MQTT_DEBUG_PRINTLN("Status publish to %s server failed, result=%d", server_name, result);
}
}
void MQTTBridge::maintainAnalyzerConnections() {
// PsychicMqttClient handles connection maintenance and reconnection automatically
// No manual maintenance needed - the library manages this internally
// Connection state changes are handled via the onConnect/onDisconnect callbacks
}
void MQTTBridge::setMessageTypes(bool status, bool packets, bool raw) {
_status_enabled = status;
_packets_enabled = packets;
+30 -4
View File
@@ -2,11 +2,12 @@
#include "MeshCore.h"
#include "helpers/bridges/BridgeBase.h"
#include <PubSubClient.h>
#include <PsychicMqttClient.h>
#include <WiFi.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <Timezone.h>
#include "helpers/JWTHelper.h"
#if defined(MQTT_DEBUG) && defined(ARDUINO)
#include <Arduino.h>
@@ -44,8 +45,7 @@
*/
class MQTTBridge : public BridgeBase {
private:
PubSubClient* _mqtt_client;
WiFiClient* _wifi_client;
PsychicMqttClient* _mqtt_client;
// MQTT broker configuration
struct MQTTBroker {
@@ -107,6 +107,20 @@ private:
float _last_rssi;
unsigned long _last_raw_timestamp;
// Let's Mesh Analyzer support
bool _analyzer_us_enabled;
bool _analyzer_eu_enabled;
char _auth_token_us[1024]; // JWT token for US server authentication
char _auth_token_eu[1024]; // JWT token for EU server authentication
char _analyzer_username[70]; // Username in format v1_{UPPERCASE_PUBLIC_KEY}
// Device identity for JWT token creation
mesh::LocalIdentity *_identity;
// PsychicMqttClient instances for different brokers
PsychicMqttClient* _analyzer_us_client;
PsychicMqttClient* _analyzer_eu_client;
// Internal methods
void connectToBrokers();
void processPacketQueue();
@@ -127,8 +141,9 @@ public:
* @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);
MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity);
/**
* Initializes the MQTT bridge
@@ -229,6 +244,17 @@ public:
* @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();
void publishToAnalyzerServers(const char* topic, const char* payload, bool retained = false);
// PsychicMqttClient WebSocket methods
void setupAnalyzerClients();
void maintainAnalyzerConnections();
void publishToAnalyzerClient(PsychicMqttClient* client, const char* topic, const char* payload, bool retained = false);
void publishStatusToAnalyzerClient(PsychicMqttClient* client, const char* server_name);
/**
* Enable/disable message types
+3556
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -119,17 +119,22 @@ build_flags =
-D MQTT_DEBUG=1
-D MESH_PACKET_LOGGING=1
-D MESH_DEBUG=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
# -D WIFI_SSID='"ssid"'
# -D WIFI_PWD='"password"'
; Use ESP32's built-in certificate bundle
build_src_filter = ${Heltec_lora32_v3.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater>
lib_deps =
${Heltec_lora32_v3.lib_deps}
${esp32_ota.lib_deps}
knolleary/PubSubClient @ ^2.8
; PsychicMqttClient - unified MQTT client with WebSocket support
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson @ 6.17.3
arduino-libraries/NTPClient
JChristensen/Timezone
+4 -1
View File
@@ -1,6 +1,7 @@
[Station_G2]
extends = esp32_base
board = station-g2
board_build.partitions = huge_app.csv
build_flags =
${esp32_base.build_flags}
${sensor_base.build_flags}
@@ -254,12 +255,14 @@ build_flags =
build_src_filter = ${Station_G2.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/ui/SH1106Display.cpp>
+<../examples/simple_repeater>
lib_deps =
${Station_G2.lib_deps}
${esp32_ota.lib_deps}
knolleary/PubSubClient @ ^2.8
; PsychicMqttClient - unified MQTT client with WebSocket support
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson @ 6.17.3
arduino-libraries/NTPClient
JChristensen/Timezone