Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/build-observer-firmwares.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,20 @@ jobs:
"${{ steps.sha.outputs.short }}" \
"$GITHUB_WORKSPACE/firmware-notes.html"

- name: Sync Docs into Flasher
run: |
# docs.html on the flasher site serves these raw .md files and renders
# them client-side; keep this list in sync with LOCAL_DOCS in
# flasher/docs.html.
for f in MQTT_IMPLEMENTATION.md MQTT_SNMP.md ALERTS.md; do
if [ -f "$f" ]; then
cp -f "$f" "flasher/$f"
echo "synced $f"
else
echo "WARNING: source doc $f not found" >&2
fi
done

- name: Commit & Push Flasher Config
working-directory: flasher
run: |
Expand Down
59 changes: 45 additions & 14 deletions MQTT_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ get mqtt.status

The MQTT bridge implementation provides:
- Up to 6 MQTT connection slots with built-in presets
- Built-in presets for LetsMesh Analyzer (US/EU), MeshMapper, MeshRank, Waev, Meshomatic, CascadiaMesh, EastIdahoMesh, ColoradoMesh, and TennMesh
- Built-in presets for many community brokers (see the [preset table](#slot-based-preset-system) for the full list)
- Custom broker support with username/password authentication
- JWT (Ed25519 device signing) authentication for most preset brokers; TennMesh uses a fixed username/password (plain MQTT)
- WSS (WebSocket Secure), direct MQTT/TLS, and plain MQTT (TennMesh) transport
Expand All @@ -99,21 +99,25 @@ The MQTT bridge uses a slot-based architecture with up to 6 concurrent connectio
| `analyzer-us` | mqtt-us-v1.letsmesh.net:443 | JWT (Ed25519) | WSS |
| `analyzer-eu` | mqtt-eu-v1.letsmesh.net:443 | JWT (Ed25519) | WSS |
| `nz-analyzer` | meshcore-mqtt-1.baird.io:443 | JWT (Ed25519) | WSS |
| `meshmapper` | mqtt.meshmapper.cc:443 | JWT (Ed25519) | WSS |
| `meshmapper` | mqtt.meshmapper.net:443 | JWT (Ed25519) | WSS |
| `meshrank` | meshrank.net:8883 | None (token in topic) | MQTT over TLS |
| `waev` | mqtt.waev.app:443 | JWT (Ed25519) | WSS |
| `meshomatic` | us-east.meshomatic.net:443 | JWT (Ed25519) | WSS |
| `cascadiamesh` | mqtt-v1.cascadiamesh.org:443 | JWT (Ed25519) | WSS |
| `tennmesh` | mqtt.tennmesh.com:1883 | Username/password (fixed in firmware) | Plain MQTT |
| `nashmesh` | mqtt://mqtt.nashme.sh:1883 | Username/password (fixed in firmware) | Plain MQTT |
| `ctmesh` | mqtt.ctmesh.org:1883 | Username/password (fixed in firmware) | Plain MQTT |
| `chimesh` | wss://mqtt.chimesh.org:443 | JWT (Ed25519) | WSS |
| `meshat.se` | meshcore-mqtt.meshat.se:443 | JWT (Ed25519) | WSS |
| `eastidahomesh` | wss://broker.eastidahomesh.net:443 | None | WSS |
| `dutchmeshcore-1` | wss://collector1.dutchmeshcore.nl:443 | JWT (Ed25519) | WSS |
| `dutchmeshcore-2` | wss://collector2.dutchmeshcore.nl:443 | JWT (Ed25519) | WSS |
| `coloradomesh` | wss://mqtt.meshcore.coloradomesh.org:1883 | JWT (Ed25519) | WSS |
| `dutchmeshcore-1` | collector1.dutchmeshcore.nl:443 | JWT (Ed25519) | WSS |
| `dutchmeshcore-2` | collector2.dutchmeshcore.nl:443 | JWT (Ed25519) | WSS |
| `meshcore-ca-1` | mqtt1.meshcore.ca:443 | JWT (Ed25519) | WSS |
| `meshcore-ca-2` | mqtt2.meshcore.ca:443 | JWT (Ed25519) | WSS |
| `bostonmesh` | mqttmc01.bostonme.sh:443 | JWT (Ed25519) | WSS |
| `inwmesh` | scope.inwmesh.org:8883 | Username/password (per slot via `mqttN.username` / `mqttN.password`) | MQTT over TLS |
| `custom` | User-configured | Username/Password | MQTT or WSS |
| `none` | (disabled) | — | — |
Expand Down Expand Up @@ -279,6 +283,9 @@ Each slot (1-6) supports the following commands:
- `get mqttN.audience` - Get JWT audience for slot N (custom slots only)

#### Set Commands
- `set mqttN.preset <name>` - Set slot N to a built-in preset. Use any `name` from the [preset table](#slot-based-preset-system) (run `get mqtt.presets` on-device for the full list). Most presets need no further configuration; the exceptions are:
- `meshrank` - requires a per-slot token (`set mqttN.token <token>`)
- `inwmesh` - requires per-slot credentials (`set mqttN.username` / `set mqttN.password`)
- `set mqttN.preset analyzer-us` - Set slot N to LetsMesh Analyzer US
- `set mqttN.preset analyzer-eu` - Set slot N to LetsMesh Analyzer EU
- `set mqttN.preset nz-analyzer` - Set slot N to NZ Analyzer (Baird)
Expand Down Expand Up @@ -307,7 +314,7 @@ Each slot (1-6) supports the following commands:
- `set mqttN.audience <audience>` - Set JWT audience for custom slot (enables Ed25519 JWT auth)
- `set mqttN.audience` - Clear JWT audience (reverts to username/password auth)

**Note:** Custom server/port settings only apply when the slot's preset is `custom`. Username/password also apply to built-in presets that use per-slot credentials (e.g. `inwmesh`); other userpass presets (`tennmesh`, `nashmesh`) ship fixed credentials in firmware.
**Note:** Custom server/port settings only apply when the slot's preset is `custom`. Username/password also apply to built-in presets that use per-slot credentials (e.g. `inwmesh`); other userpass presets (`tennmesh`, `nashmesh`, `ctmesh`) ship fixed credentials in firmware.

#### Example: Configure MeshRank on Slot 3
```bash
Expand Down Expand Up @@ -516,23 +523,41 @@ Minimal raw packet data for map integration.
```json
{
"status": "online|offline",
"timestamp": "2024-01-01T12:00:00.000000",
"timestamp": "2024-01-01T12:00:00.000000+00:00",
"origin": "Device Name",
"origin_id": "DEVICE_PUBLIC_KEY",
"model": "device_model",
"firmware_version": "firmware_version",
"radio": "radio_info",
"client_version": "meshcore-custom-repeater/{build_date}",
"repeat": "on|off"
"client_version": "meshcore/{firmware_version}",
"repeat": "on|off",
"stats": {
"battery_mv": 4100,
"uptime_secs": 3600,
"packets_sent": 42,
"packets_received": 128,
"errors": 0,
"queue_len": 0,
"noise_floor": -110,
"tx_air_secs": 12,
"rx_air_secs": 340,
"recv_errors": 2,
"internal_heap": 102400
}
}
```

**Notes:**
- Timestamps are always emitted in UTC with an explicit `+00:00` offset.
- The `stats` object is only included when at least one stat value is available; individual fields are omitted when their value is unavailable.
- `packets_sent` / `packets_received` are cumulative totals since boot (flood + direct), sourced from the dispatcher counters.

### Packet Message
```json
{
"origin": "MeshCore-HOWL",
"origin_id": "A1B2C3D4E5F67890...",
"timestamp": "2024-01-01T12:00:00.000000",
"timestamp": "2024-01-01T12:00:00.000000+00:00",
"type": "PACKET",
"direction": "rx|tx",
"time": "12:00:00",
Expand All @@ -544,21 +569,25 @@ Minimal raw packet data for map integration.
"raw": "F5930103807E5F1E...",
"SNR": "12.5",
"RSSI": "-65",
"score": "234",
"hash": "A1B2C3D4E5F67890",
"path": "node1,node2,node3"
"path": ["aa", "bb", "cc"]
}
```

**Notes:**
- `SNR` and `RSSI` are only present for RX packets (received from radio). TX packets omit these fields since the packet originates from this node.
- `path` is only present for direct-route packets with path data.
- All numeric fields (`len`, `packet_type`, `payload_len`, `SNR`, `RSSI`, `score`) are formatted as JSON strings.
- `time` and `date` are always UTC (`HH:MM:SS` and `DD/MM/YYYY`); `timestamp` is UTC with an explicit `+00:00` offset.
- `SNR`, `RSSI`, and `score` are only present for RX packets (received from radio). TX packets omit these fields since the packet originates from this node.
- `score` is the firmware's rebroadcast score for the received packet (the same value used to compute flood-rebroadcast delay), scaled ×1000 to match the integer printed in the serial RX log — e.g. a score of `0.234` is emitted as `"234"` (range `0`–`1000`). It is recomputed at publish time from the packet's SNR and length via the radio's `packetScore()`, so it matches what the firmware used on receive. Omitted when unavailable (e.g. the non-PSRAM reconstruction-less fallback path).
- `path` is only present for direct-route packets that carry path data. It is a JSON array of lowercase hex hop tokens, one element per hop — e.g. `["aa","bb","cc"]` for single-byte hashes, or `["aaaa","bbbb"]` for multi-byte hashes. This matches the `path` representation emitted by [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture).

### Raw Message
```json
{
"origin": "MeshCore-HOWL",
"origin_id": "A1B2C3D4E5F67890...",
"timestamp": "2024-01-01T12:00:00.000000",
"timestamp": "2024-01-01T12:00:00.000000+00:00",
"type": "RAW",
"data": "F5930103807E5F1E..."
}
Expand All @@ -568,7 +597,7 @@ Minimal raw packet data for map integration.

### Slot-Based Preset System
- Up to 6 concurrent MQTT connections (with PSRAM), 2 without PSRAM
- Built-in presets for LetsMesh Analyzer (US/EU), MeshMapper, MeshRank, Waev, Meshomatic, CascadiaMesh, EastIdahoMesh, ColoradoMesh, and TennMesh
- Built-in presets for many community brokers (see the [preset table](#slot-based-preset-system))
- Custom broker support with username/password auth and custom topic templates
- JWT (Ed25519) for most preset brokers; MeshRank uses token-in-topic; TennMesh uses fixed username/password over plain MQTT
- WSS (WebSocket Secure), direct MQTT over TLS, and plain MQTT (TennMesh)
Expand Down Expand Up @@ -601,8 +630,10 @@ Minimal raw packet data for map integration.
- Proper UTC system time handling

### Authentication
- **JWT Authentication**: Ed25519-signed tokens for brokers that expect JWT (most built-in presets; not MeshRank or TennMesh). For `custom` slots, JWT is used when `audience` is set.
- **Username/Password**: Custom brokers; TennMesh also uses fixed credentials embedded in the `tennmesh` preset (plain MQTT, no TLS)
The auth mode is fixed per preset (see the [preset table](#slot-based-preset-system)). Three modes are used:
- **JWT Authentication**: Ed25519-signed tokens for brokers that expect JWT (most WSS presets). For `custom` slots, JWT is used when `audience` is set.
- **Username/Password**: Some presets ship fixed credentials embedded in firmware (`tennmesh`, `nashmesh`, `ctmesh` — plain MQTT, no TLS); others (`inwmesh`, `custom`) take per-slot credentials via `mqttN.username` / `mqttN.password`.
- **None**: `meshrank` (account token carried in the topic) and `eastidahomesh` connect without broker auth.
- **Username Format** (JWT): `v1_{UPPERCASE_PUBLIC_KEY}`
- **Automatic Token Renewal**: Tokens are renewed before expiration

Expand Down
75 changes: 55 additions & 20 deletions src/helpers/MQTTMessageBuilder.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "MQTTMessageBuilder.h"
#include <ArduinoJson.h>
#include <cstring>
#include <math.h>
#include <time.h>
#include <Timezone.h>
#include "MeshCore.h"
Expand Down Expand Up @@ -40,6 +41,8 @@ int MQTTMessageBuilder::buildStatusMessage(
int rx_air_secs,
int recv_errors,
int internal_heap,
int packets_sent,
int packets_received,
const char* repeat
) {
// doc is provided by the caller (heap-allocated DynamicJsonDocument in MQTTBridge),
Expand All @@ -62,7 +65,7 @@ int MQTTMessageBuilder::buildStatusMessage(
// Add stats object if any stats are provided
if (battery_mv >= 0 || uptime_secs >= 0 || errors >= 0 || queue_len >= 0 ||
noise_floor > -999 || tx_air_secs >= 0 || rx_air_secs >= 0 || recv_errors >= 0 ||
internal_heap >= 0) {
internal_heap >= 0 || packets_sent >= 0 || packets_received >= 0) {
JsonObject stats = root.createNestedObject("stats");

if (battery_mv >= 0) {
Expand All @@ -71,6 +74,12 @@ int MQTTMessageBuilder::buildStatusMessage(
if (uptime_secs >= 0) {
stats["uptime_secs"] = uptime_secs;
}
if (packets_sent >= 0) {
stats["packets_sent"] = packets_sent;
}
if (packets_received >= 0) {
stats["packets_received"] = packets_received;
}
if (errors >= 0) {
stats["errors"] = errors;
}
Expand Down Expand Up @@ -113,8 +122,11 @@ int MQTTMessageBuilder::buildPacketMessage(
const char* raw,
float snr,
int rssi,
float score,
const char* hash,
const char* path,
const uint8_t* path_bytes,
int path_hop_count,
int path_hash_size,
char* buffer,
size_t buffer_size
) {
Expand All @@ -129,7 +141,8 @@ int MQTTMessageBuilder::buildPacketMessage(
char payload_len_str[16];
char snr_str[16];
char rssi_str[16];

char score_str[16];

snprintf(len_str, sizeof(len_str), "%d", len);
snprintf(packet_type_str, sizeof(packet_type_str), "%d", packet_type);
snprintf(payload_len_str, sizeof(payload_len_str), "%d", payload_len);
Expand All @@ -153,12 +166,33 @@ int MQTTMessageBuilder::buildPacketMessage(
if (strcmp(direction, "rx") == 0) {
root["SNR"] = snr_str;
root["RSSI"] = rssi_str;
// Firmware's rebroadcast "score" for this RX packet, scaled x1000 to match the
// integer form printed in the serial RX log (see Dispatcher::checkRecv()).
if (!isnan(score)) {
snprintf(score_str, sizeof(score_str), "%d", (int)(score * 1000));
root["score"] = score_str;
}
}

if (path && strlen(path) > 0) {
root["path"] = path;
// Routing path as an array of lowercase hex hop tokens, one element per hop
// (e.g. ["aa","bb","cc"], or ["aaaa","bbbb"] for multi-byte hashes). This matches
// meshcore-packet-capture's _split_path_hops() representation.
if (path_bytes && path_hop_count > 0 && path_hash_size > 0) {
JsonArray path_arr = root.createNestedArray("path");
char hop_hex[2 * 4 + 1]; // hop hash is 1-4 bytes -> up to 8 hex chars + null
for (int i = 0; i < path_hop_count; i++) {
size_t pos = 0;
for (int b = 0; b < path_hash_size && b < 4; b++) {
size_t idx = (size_t)i * path_hash_size + b;
if (idx >= MAX_PATH_SIZE) break;
snprintf(hop_hex + pos, 3, "%02x", path_bytes[idx]);
pos += 2;
}
hop_hex[pos] = '\0';
path_arr.add(hop_hex); // char[] (non-const) -> ArduinoJson copies the string
}
}

size_t json_len = serializeJson(root, buffer, buffer_size);
return (json_len > 0 && json_len < buffer_size) ? json_len : 0;
}
Expand Down Expand Up @@ -230,12 +264,9 @@ int MQTTMessageBuilder::buildPacketJSON(
packet->calculatePacketHash(packet_hash);
bytesToHex(packet_hash, MAX_HASH_SIZE, hash_str, sizeof(hash_str));

// Build path string for direct packets (multibyte-path: show hash count, hash size, byte length)
char path_str[128] = "";
if (packet->isRouteDirect() && packet->path_len > 0) {
snprintf(path_str, sizeof(path_str), "path_%dx%d_%db",
(int)packet->getPathHashCount(), (int)packet->getPathHashSize(), (int)packet->getPathByteLen());
}
// Routing path (direct packets only): pass raw hop bytes to buildPacketMessage,
// which emits them as an array of lowercase hex hop tokens.
bool has_path = packet->isRouteDirect() && packet->getPathHashCount() > 0;

return buildPacketMessage(
doc,
Expand All @@ -248,8 +279,11 @@ int MQTTMessageBuilder::buildPacketJSON(
raw_hex,
12.5f, // SNR - using reasonable default
-65, // RSSI - using reasonable default
NAN, // score - unknown on this reconstruction-less fallback path
hash_str,
packet->isRouteDirect() ? path_str : nullptr,
has_path ? packet->path : nullptr,
has_path ? packet->getPathHashCount() : 0,
has_path ? packet->getPathHashSize() : 0,
buffer, buffer_size
);
}
Expand All @@ -264,6 +298,7 @@ int MQTTMessageBuilder::buildPacketJSONFromRaw(
const char* origin_id,
float snr,
float rssi,
float score,
Timezone* timezone,
char* buffer,
size_t buffer_size
Expand Down Expand Up @@ -302,12 +337,9 @@ int MQTTMessageBuilder::buildPacketJSONFromRaw(
packet->calculatePacketHash(packet_hash);
bytesToHex(packet_hash, MAX_HASH_SIZE, hash_str, sizeof(hash_str));

// Build path string for direct packets (multibyte-path: show hash count, hash size, byte length)
char path_str[128] = "";
if (packet->isRouteDirect() && packet->path_len > 0) {
snprintf(path_str, sizeof(path_str), "path_%dx%d_%db",
(int)packet->getPathHashCount(), (int)packet->getPathHashSize(), (int)packet->getPathByteLen());
}
// Routing path (direct packets only): pass raw hop bytes to buildPacketMessage,
// which emits them as an array of lowercase hex hop tokens.
bool has_path = packet->isRouteDirect() && packet->getPathHashCount() > 0;

return buildPacketMessage(
doc,
Expand All @@ -320,8 +352,11 @@ int MQTTMessageBuilder::buildPacketJSONFromRaw(
raw_hex,
snr, // Use actual SNR from radio
rssi, // Use actual RSSI from radio
score, // Firmware rebroadcast score (NaN for tx / when unavailable)
hash_str,
packet->isRouteDirect() ? path_str : nullptr,
has_path ? packet->path : nullptr,
has_path ? packet->getPathHashCount() : 0,
has_path ? packet->getPathHashSize() : 0,
buffer, buffer_size
);
}
Expand Down
12 changes: 10 additions & 2 deletions src/helpers/MQTTMessageBuilder.h
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ class MQTTMessageBuilder {
int rx_air_secs = -1,
int recv_errors = -1,
int internal_heap = -1,
int packets_sent = -1,
int packets_received = -1,
const char* repeat = nullptr
);

Expand All @@ -98,7 +100,9 @@ class MQTTMessageBuilder {
* @param snr Signal-to-noise ratio
* @param rssi Received signal strength
* @param hash Packet hash
* @param path Routing path (for direct packets)
* @param path_bytes Raw routing-path bytes (direct packets only; nullptr to omit)
* @param path_hop_count Number of path hops (0 to omit the path field)
* @param path_hash_size Bytes per hop hash (1-4)
* @param buffer Output buffer for JSON string
* @param buffer_size Size of output buffer
* @return Length of JSON string, or 0 on error
Expand All @@ -118,8 +122,11 @@ class MQTTMessageBuilder {
const char* raw,
float snr,
int rssi,
float score,
const char* hash,
const char* path,
const uint8_t* path_bytes,
int path_hop_count,
int path_hash_size,
char* buffer,
size_t buffer_size
);
Expand Down Expand Up @@ -176,6 +183,7 @@ class MQTTMessageBuilder {
const char* origin_id,
float snr,
float rssi,
float score,
Timezone* timezone,
char* buffer,
size_t buffer_size
Expand Down
Loading
Loading