From f339beb22ec54120453df7104da46196a24d73ee Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 18 Oct 2026 15:00:47 -0700 Subject: [PATCH] docs: refresh observer docs and document ota commands Add ota check/update/branch, display, and diagnostic commands to the observer guide; condense MQTT_IMPLEMENTATION.md and ALERTS.md; fix the nonexistent 'set bridge off' instruction; mark the T-LoRa observer envs parked; update the SNMP build-target list; move local-testing notes to MQTT_INTERNALS.md; drop MEMORY_MONITORING.md (its script never existed). --- ALERTS.md | 166 +++--- MEMORY_MONITORING.md | 194 ------- MQTT_IMPLEMENTATION.md | 1186 ++++++++++++++-------------------------- MQTT_INTERNALS.md | 12 + MQTT_SNMP.md | 27 +- README.md | 2 +- docs/cli_commands.md | 18 +- 7 files changed, 508 insertions(+), 1097 deletions(-) delete mode 100644 MEMORY_MONITORING.md diff --git a/ALERTS.md b/ALERTS.md index 383abf31..5698b514 100644 --- a/ALERTS.md +++ b/ALERTS.md @@ -1,112 +1,46 @@ -# Fault Alerts (Group Channel) +# Fault Alerts -This document describes MeshCore repeater fault alerts, including configuration, CLI commands, and operational behavior. +An observer can post a one-line message on a LoRa group channel when its network link or +an MQTT broker stays down too long. Alerts go out over the mesh as a flood `GRP_TXT` +packet, not over MQTT, since MQTT is what has failed. Anyone subscribed to the channel in +a companion app sees them as normal channel messages, sent under the node's name. -The repeater can broadcast a one-line fault notification on a configured group channel when WiFi or any active MQTT slot has been disconnected longer than a configurable threshold. +## Setup -The alert is sent over **LoRa** as a `PAYLOAD_TYPE_GRP_TXT` flood packet on the configured channel (with sender = device name) - *not* over MQTT. This is intentional: the MQTT path is what's broken, so the only working delivery is the mesh itself. Anyone in radio range subscribed to the same channel/hashtag in their companion app will see the alert inline with normal channel chat. +Point alerts at a private channel, test it, then turn them on. -> **A small list of community channels is intentionally NOT supported.** Fault alerts are operator-infrastructure noise - broadcasting them on shared community channels would spam every node in the area (and on `#test` / `#bot` would amplify via well-known auto-responders). The currently banned destinations are: -> -> - The well-known **Public** group PSK (`izOH6cXN6mrJ5e26oRXNcg==`) -> - **`#test`** (`sha256("#test")[0..15]`) -> - **`#bot`** (`sha256("#bot")[0..15]`) -> -> The list lives in `BANNED_ALERT_CHANNELS[]` in [src/helpers/AlertReporter.cpp](src/helpers/AlertReporter.cpp); adding a new entry is one line (label + 32 hex chars). The matcher runs at both the CLI validation step (`set alert.psk`, `set alert.hashtag`) and the alert-send path, so a saved-config bypass is still refused at runtime. You must point alerts at a **private PSK** (`set alert.psk`) or a non-banned **hashtag channel** (`set alert.hashtag`) before alerts can fire. +**Hashtag channel** (easiest for an operator group): -## Scope and routing +```bash +set alert.hashtag ops-alerts # key = sha256("#ops-alerts")[0..15] +alert test +set alert on +``` -Alert floods ride the **repeater's default scope** by default (the same TransportKey used for adverts and channel broadcasts - set via `region default ...`). Operators can override on a per-alert-feature basis with `set alert.region `: +**Private key** (`openssl rand -hex 16`, or paste the companion app's "Share Channel" output): -- If `alert.region` is set and the name resolves via `RegionMap`, that region's TransportKey is used. -- If `alert.region` is unset, or the name doesn't resolve, the repeater's `default_scope` is used. -- If both are null, the alert is sent unscoped (matches the pre-scoped firmware's behavior). +```bash +set alert.psk <32_hex_chars> +alert test +set alert on +``` -`alert.region` is stored as-is - it does **not** create the region. Use `region put ` first if it doesn't exist. +Subscribers add a channel with the same hashtag or key. + +Alerts refuse to use the Public channel, `#test`, or `#bot`, because they would spam every +node in range and trigger auto-responders. The list is `BANNED_ALERT_CHANNELS[]` in +[src/helpers/AlertReporter.cpp](src/helpers/AlertReporter.cpp). It is checked both when you +set the channel and when an alert is sent. ## What triggers an alert -- **WiFi**: continuously down for at least `alert.wifi` minutes (default 30) -- **MQTT slot N**: enabled, has connected at least once since boot, and has been disconnected for at least `alert.mqtt` minutes (default 240, i.e. 4 h) +| Fault | Fires after | Setting | +|-------|-------------|---------| +| Network link (Ethernet or WiFi) down | 30 min | `alert.wifi` | +| MQTT slot down, after connecting at least once since boot | 240 min | `alert.mqtt` | -A "recovered" message is sent once when the underlying connection comes back. After firing, a fault is rate-limited by `alert.interval` (default 60 minutes) before it can re-fire - this prevents flapping links from spamming the channel. - -## OTA milestone alerts - -Key `ota update` milestones are also broadcast on the alert channel (in addition to the Serial log), so an operator who triggered the update via remote management still gets feedback: the real OTA work runs ~2.5 s after the command and reboots on success, both **outside** the command's reply window. - -- **Start** - `OTA update starting`, sent when the update is confirmed and scheduled (during the reply window, so it transmits before the flash blocks the loop). -- **Fail/abort** - `OTA aborted: MQTT stop unclean, bridge resumed` (the teardown barrier withheld flashing) or `OTA aborted: ` (preflight/download error). The bridge is resumed either way. -- **Success** has no dedicated message: a successful flash reboots into the new image, so the node reappearing on the new version is the success signal. - -These share the alert channel, scope (`alert.region`), and the `alert` master switch with fault alerts - if `alert` is `off` or no channel is configured, OTA milestones are silent. Unlike fault alerts they are **not** rate-limited (OTA is operator-initiated and rare), and they are limited to these start/fail milestones - routine MQTT slot connect/disconnect never triggers an OTA alert. - -## Defaults - -| Setting | Default | Notes | -|---------|---------|-------| -| `alert` | `off` | Master enable for automatic fault alerts | -| `alert.psk` | *(unset)* | Private channel secret as **32 hex chars** (16-byte channel key) - the same format the mobile app's "Share Channel" emits, and what every other secret-shaped CLI command (e.g. `prv.key`) uses. | -| `alert.hashtag` | *(unset)* | Informational only; set via `set alert.hashtag` to pre-derive `alert.psk` from `sha256("#name")[0..15]`. Cleared when `alert.psk` is set directly. | -| `alert.region` | *(unset)* | Optional region name; overrides the repeater's `default_scope` for alert sends only. Empty = use `default_scope`. Looked up lazily via `RegionMap`; unknown names silently fall back to `default_scope`. | -| `alert.wifi` | `30` (min) | 0 disables WiFi alerts | -| `alert.mqtt` | `240` (min) | 0 disables MQTT alerts | -| `alert.interval` | `60` (min) | Minutes between repeat alerts of the same fault. **Hard floor of 60 min** so a flapping link can't spam the mesh; the CLI rejects lower values and AlertReporter clamps stale prefs at runtime. | - -> `alert.psk` is unset on a fresh flash. **Alerts cannot fire and `alert test` will refuse to send until you configure either `alert.psk` directly or `alert.hashtag` (which derives one).** The sender shown on outgoing alert messages is always the node name (`set name ...`); there is no separate `alert.name`. - -## CLI - -Get: -- `get alert` - master on/off -- `get alert.psk` - the active 32-hex-char PSK (or `(unset)`) (**serial console only**) -- `get alert.hashtag` - the originating hashtag (or `(unset)`, e.g. after `set alert.psk` overrides the hashtag-derived key) -- `get alert.region` - alert-only scope override (or `(unset, using default scope)`) -- `get alert.wifi` / `get alert.mqtt` / `get alert.interval` - -Set: -- `set alert on` / `set alert off` -- `set alert.psk ` - 32 hex chars (16-byte channel secret); rejects banned channels (Public, `#test`, `#bot`). Paste the mobile app's "Share Channel" output as-is. Clears `alert.hashtag` since the new key is operator-supplied. -- `set alert.psk` (no argument) - clears both `alert.psk` and `alert.hashtag` -- `set alert.hashtag ` - derives the 16-byte key from `sha256("#name")` *once*, stores it as `alert.psk`, and remembers the hashtag for `get alert.hashtag`. `#` prefix is added if omitted (so `alerts` and `#alerts` are equivalent). Refuses banned hashtag names. -- `set alert.hashtag` (no argument) - clears both `alert.psk` and `alert.hashtag` -- `set alert.region ` - alert-only scope override (no region-map mutation; unknown names silently fall back to `default_scope`) -- `set alert.region` (no argument) - clear override, use `default_scope` -- `set alert.wifi ` (0-1440; 0 = disabled) -- `set alert.mqtt ` (0-10080; 0 = disabled) -- `set alert.interval ` (60-10080; 60-minute floor to protect mesh airtime) - -Action: -- `alert test` - send a one-off `[test] alert channel ok` immediately on the configured channel; ignores `alert on/off` so operators can verify the channel before enabling fault firing. Returns an error if no channel is configured. If the send succeeds but the `alert` master switch is still `off`, the reply says so (`OK - test sent, but automatic alerts are OFF`) - a working test alone does **not** mean automatic WiFi/MQTT/OTA alerts will fire. -- `alert test ` - send a custom test message: `[test] `. - -## Example: dedicated hashtag channel (recommended for operator groups) - -```bash -set alert.hashtag ops-alerts # stored as "#ops-alerts"; key = sha256("#ops-alerts")[0..15] -set alert.wifi 10 # tighter for ops monitoring -set alert.mqtt 60 -set alert on -alert test -``` - -Anyone running a companion app and subscribed to the `#ops-alerts` hashtag channel will see the alerts inline. - -## Example: dedicated alerts channel with a private PSK - -Generate a 16-byte random PSK as 32 hex chars (`openssl rand -hex 16`), or use the companion app's "Add channel" feature and copy the "Share Channel" output. Then: - -```bash -set alert.psk <32_hex_chars> # 16-byte channel secret; mobile "Share Channel" pastes in directly -set alert.wifi 10 -set alert.mqtt 60 -set alert on -alert test -``` - -Subscribers running a MeshCore companion app should add a channel with the same PSK; alerts will appear in that channel's chat view. (Pick any local name for it - the sender of incoming alert messages is the repeater's node name.) - -## Sample messages +A recovery message goes out once when the link returns. A fault can't fire again until +`alert.interval` has passed (minimum 60 min). ``` MyObserver: WiFi down 47m (reason 201) @@ -115,11 +49,37 @@ MyObserver: MQTT slot 1 (analyzer-us) down 4h12m MyObserver: MQTT slot 1 (analyzer-us) recovered after 4h45m ``` +**OTA updates** also post on the alert channel, so a remote operator gets feedback after +the command's reply window closes: `OTA update starting`, or `OTA aborted: ` (the +bridge resumes either way). Success has no message; the node comes back on the new +version. OTA messages are not rate-limited. + +## CLI + +| Command | Purpose | Default | +|---------|---------|---------| +| `get/set alert on\|off` | Master switch for fault and OTA alerts | off | +| `get/set alert.psk <32 hex>` | Channel key. `get` is serial only. Clears `alert.hashtag` | unset | +| `get/set alert.hashtag ` | Derive the key from `#name` (`#` optional) | unset | +| `set alert.psk` / `set alert.hashtag` (no value) | Clear the channel | | +| `get/set alert.region ` | Send on this region's scope instead of the default scope; bare `set` clears | unset | +| `get/set alert.wifi <0-1440>` | Minutes before a link alert; `0` disables | 30 | +| `get/set alert.mqtt <0-10080>` | Minutes before a slot alert; `0` disables | 240 | +| `get/set alert.interval <60-10080>` | Minutes between repeats of one fault | 60 | +| `alert test [message]` | Send `[test] ` now, even with `alert off` | | + +`alert test` warns when `alert` is still off: a successful test doesn't mean automatic +alerts will fire. + +## Scope + +Alerts flood on the node's default scope (`region default`). `alert.region` overrides it +for alerts only. It must name an existing region (`region put `); an unknown name +falls back to the default scope. With neither set, alerts go out unscoped. Listeners need +the same scope to hear them. + ## Notes -- A reboot during an outage resets the timer; the alert won't double-fire because `millis()` starts at 0 at boot. The fault must persist `alert.wifi` / `alert.mqtt` minutes from boot. -- Fault state is stored in RAM only - no persistence across reboots. -- The MQTT-slot watcher uses a separate per-slot `current_outage_started_ms` field that is reset on each reconnect, distinct from the `first_disconnect_time` shown in `mqttN.diag` (which remains a "first disconnect since boot" counter for diagnostics). -- WiFi-down alerts can only be delivered if the LoRa radio is up. There is no fallback path. -- Banned channels (Public, `#test`, `#bot`) are **rejected** at both `set alert.psk` / `set alert.hashtag` and at the alert-send path, so even if you somehow set one via a saved config file, the firmware will silently refuse to broadcast on it. To add another banned channel, append a row to `BANNED_ALERT_CHANNELS[]` in [src/helpers/AlertReporter.cpp](src/helpers/AlertReporter.cpp); the format is `{ "label", "32-lowercase-hex-chars" }` (compute as `printf '#name' | openssl dgst -sha256 | cut -c1-32`). -- Alerts are sent via `sendFlood` with the resolved TransportKey codes attached, so they appear on the configured scope just like other broadcast traffic. Operators monitoring a specific region need to be subscribed to that region's scope to hear alerts. +- Outage timers live in RAM. A reboot restarts them, so the fault must last the full + threshold after boot. +- Link alerts need a working LoRa radio; there is no other delivery path. diff --git a/MEMORY_MONITORING.md b/MEMORY_MONITORING.md deleted file mode 100644 index d8979019..00000000 --- a/MEMORY_MONITORING.md +++ /dev/null @@ -1,194 +0,0 @@ -# MeshCore Memory Monitoring Guide - -## Quick Start - -### 1. Find Your Device Port -```bash -# Linux/macOS -ls /dev/tty* | grep -E "(USB|ACM)" - -# Common ports: -# /dev/ttyUSB0 - Linux USB serial -# /dev/ttyACM0 - Linux USB CDC -# /dev/cu.usbserial-* - macOS USB serial -# /dev/cu.usbmodem* - macOS USB CDC -``` - -### 2. Run Monitoring -```bash -# Monitor for 4 hours (default) -python3 monitor_memory.py /dev/ttyUSB0 - -# Monitor for 24 hours -python3 monitor_memory.py /dev/ttyUSB0 24 - -# Monitor for 2 hours with 60-second intervals -python3 monitor_memory.py /dev/ttyUSB0 2 --interval 60 -``` - -## What It Monitors - -### Memory Metrics -- **Free Heap**: Available memory in bytes -- **Min Heap**: Minimum free heap since boot -- **Max Alloc**: Largest allocatable block -- **Queue Size**: Number of queued MQTT packets - -### Calculated Metrics -- **Heap Usage %**: Percentage of total memory used -- **Fragmentation %**: How fragmented the heap is - -### Automatic Alerts -- **LOW_MEMORY**: Free heap < 50KB -- **HIGH_FRAGMENTATION**: Fragmentation > 50% -- **QUEUE_BUILDUP**: Queue size > 20 packets -- **POSSIBLE_LEAK**: Memory decreasing over time - -## Output Files - -### Console Output -``` -[ 30.0m] Free: 102796, Min: 83544, Max: 75764, Queue: 0, Usage: 68.6%, Frag: 26.3% -[ 60.0m] Free: 101234, Min: 82345, Max: 74321, Queue: 2, Usage: 69.1%, Frag: 26.5% -⚠️ WARNING: HIGH_FRAGMENTATION -``` - -### CSV Log File -```csv -Timestamp,Elapsed_Minutes,Free_Heap,Min_Heap,Max_Alloc,Queue_Size,Heap_Usage_Percent,Fragmentation_Percent -2024-01-15T10:30:00,0.0,102796,83544,75764,0,68.6,26.3 -2024-01-15T11:00:00,30.0,101234,82345,74321,2,69.1,26.5 -``` - -## Understanding Results - -### Healthy System -- **Free Heap**: 150KB+ (stable) -- **Min Heap**: 120KB+ (stable) -- **Max Alloc**: 100KB+ (stable) -- **Fragmentation**: < 30% -- **Queue**: 0-10 packets - -### Warning Signs -- **Free Heap**: < 100KB or decreasing -- **Min Heap**: < 80KB or decreasing -- **Fragmentation**: > 50% -- **Queue**: > 20 packets consistently - -### Memory Leak Indicators -- **Consistent decrease** in Free Heap over time -- **Min Heap dropping** below previous minimums -- **Max Alloc shrinking** (fragmentation increasing) -- **POSSIBLE_LEAK** alert triggered - -## Long-Term Monitoring - -### 24-Hour Test -```bash -python3 monitor_memory.py /dev/ttyUSB0 24 -``` -- Tests for memory leaks over extended period -- Monitors system stability under normal load -- Identifies gradual memory degradation - -### 48-Hour Stress Test -```bash -python3 monitor_memory.py /dev/ttyUSB0 48 --interval 60 -``` -- Extended monitoring for critical deployments -- 60-second intervals reduce log file size -- Tests system under continuous operation - -## Troubleshooting - -### Device Not Responding -1. Check port is correct: `ls /dev/tty*` -2. Ensure device is connected and powered -3. Try different baud rate if needed -4. Check device is in correct mode - -### No Data in CSV -1. Verify device responds to `memory` command manually -2. Check serial connection is stable -3. Ensure device has MQTT bridge enabled - -### High Memory Usage -1. Check if it's stable or increasing -2. Look for memory leak patterns -3. Monitor queue size for packet buildup -4. Consider reducing debug logging - -## Analysis Tools - -### Plot Memory Usage -```python -import pandas as pd -import matplotlib.pyplot as plt - -# Load CSV data -df = pd.read_csv('memory_monitor_20240115_103000.csv') - -# Plot memory over time -plt.figure(figsize=(12, 8)) -plt.subplot(2, 2, 1) -plt.plot(df['Elapsed_Minutes'], df['Free_Heap']) -plt.title('Free Heap Over Time') -plt.ylabel('Bytes') - -plt.subplot(2, 2, 2) -plt.plot(df['Elapsed_Minutes'], df['Heap_Usage_Percent']) -plt.title('Heap Usage Percentage') -plt.ylabel('%') - -plt.subplot(2, 2, 3) -plt.plot(df['Elapsed_Minutes'], df['Fragmentation_Percent']) -plt.title('Heap Fragmentation') -plt.ylabel('%') - -plt.subplot(2, 2, 4) -plt.plot(df['Elapsed_Minutes'], df['Queue_Size']) -plt.title('Queue Size') -plt.ylabel('Packets') - -plt.tight_layout() -plt.savefig('memory_analysis.png') -plt.show() -``` - -### Check for Trends -```python -# Calculate memory trend -df['Free_Heap_Trend'] = df['Free_Heap'].rolling(window=10).mean() -df['Trend_Slope'] = df['Free_Heap_Trend'].diff() - -# Identify decreasing trends -decreasing = df[df['Trend_Slope'] < -1000] -if not decreasing.empty: - print("Memory decreasing trend detected!") - print(decreasing[['Elapsed_Minutes', 'Free_Heap', 'Trend_Slope']]) -``` - -## Best Practices - -1. **Start with 4-hour baseline** to establish normal patterns -2. **Monitor during peak usage** times for worst-case scenarios -3. **Run 24-hour tests** before production deployment -4. **Check logs regularly** for warning signs -5. **Keep historical data** for trend analysis -6. **Test after code changes** to verify fixes - -## Emergency Procedures - -### If Memory Leak Detected -1. **Stop monitoring** (Ctrl+C) -2. **Check recent code changes** -3. **Look for unfreed allocations** -4. **Test with reduced functionality** -5. **Deploy memory leak fix** - -### If System Crashes -1. **Check last known good memory values** -2. **Identify crash threshold** -3. **Add more frequent monitoring** -4. **Implement memory safeguards** -5. **Consider hardware upgrade** diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index d466bd37..b3384e22 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -1,405 +1,168 @@ -# MQTT Bridge Implementation for MeshCore +# MQTT Observer Firmware -This document describes the MQTT bridge implementation that allows MeshCore repeaters to uplink packet data to multiple MQTT brokers. +Observer firmware turns a MeshCore repeater or room server into an uplink that publishes +the packets it hears to up to six MQTT brokers. ## Quick Start Guide -**Prerequisites:** a MeshCore device flashed with observer MQTT firmware, WiFi network -credentials, and console access — either serial (115200 baud) or repeater login via the -companion app. +You need the device, WiFi credentials, and a console: USB serial at 115200 baud, or a +repeater admin login from the companion app. -### Essential Commands to Get MQTT Observer Running +**1. Flash.** Use the [MeshCore Observer Flasher](https://observer.gessaman.com/): pick +**MQTT Observer Firmware** and your device, then flash from Chrome or Edge. To build it +yourself, see [Build Configuration](#build-configuration). -**1. Flash the observer firmware to your device** - -The easiest route is the [MeshCore Observer Flasher](https://observer.gessaman.com/) — pick -**MQTT Observer Firmware**, select your device, and flash from the browser (Chrome or Edge). -To build it yourself instead, use one of the observer build targets (e.g. -`heltec_v4_repeater_observer_mqtt`) — see [Build Configuration](#build-configuration). - -After flashing, connect to the device console via serial (115200 baud) or repeater login. - -**2. Configure radio settings** - -If this is a fresh flash or full erase, configure your radio parameters first. These must match other nodes in your mesh: +**2. Set the radio** on a fresh flash. These must match the rest of your mesh: ```bash -set radio 910.525,62.5,7,5 +set radio 910.525,62.5,7,5 # ,,, set tx 22 ``` -Format: `set radio ,,,` - -**3. Configure device identity** +**3. Set the identity:** ```bash set name MyObserver set mqtt.iata SEA +set prv.key <64_hex_chars> # optional: keep the identity of a node you are replacing ``` -If migrating from an existing node (e.g., a Raspberry Pi gateway), restore the private key to keep the same identity: -```bash -set prv.key -``` +**4. Set WiFi.** The value is the rest of the line; do not add quotes. -**4. Configure WiFi credentials** (value is the rest of the line; do not use quotes — see [WiFi Commands](#wifi-commands)) ```bash set wifi.ssid YourWiFiNetwork set wifi.pwd YourWiFiPassword ``` -**5. (Optional) Choose which brokers to publish to** - -Slots 1 and 2 default to Let's Mesh Analyzer US and EU. To add or change a broker, pick a -name from [Broker Presets](#broker-presets): -```bash -set mqtt3.preset meshmapper -``` - -**6. (Optional) Configure timezone** +**5. Optional settings:** ```bash -set timezone America/New_York +set mqtt3.preset meshmapper # add a broker (slots 1 and 2 default to Let's Mesh US and EU) +set timezone America/New_York # published timestamps stay UTC either way +set repeat off # receive-only observer ``` -Or use a plain offset as a fallback: `set timezone.offset -5`. Published timestamps are -always UTC either way — see [Timezone Commands](#timezone-commands). +**6. Reboot and verify:** -**7. (Optional) Disable packet repeating** - -If this observer is receive-only (e.g., using a PCB antenna in a location where repeating would be harmful), disable forwarding: -```bash -set repeat off -``` - -**8. Reboot to connect** ```bash reboot -``` - -**9. Verify configuration** -```bash -get wifi.ssid get link.status -get link.diag -get bridge.enabled -get mqtt.rx -get mqtt.tx -get mqtt.origin -get mqtt.iata -get mqtt1.preset -get mqtt2.preset -get mqtt3.preset get mqtt.status ``` -**That's it!** The device will now: -- Connect to WiFi automatically -- 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) - ---- - -## Overview - -The MQTT bridge implementation provides: -- Up to 6 MQTT connection slots, each holding a built-in preset for a community broker or a custom broker of your own — see [Broker Presets](#broker-presets) -- Per-preset authentication over WSS, MQTT/TLS, or plain MQTT: Ed25519-signed JWT, fixed or per-slot username/password, or none — see [Authentication](#authentication) -- Automatic reconnection with exponential backoff -- JSON message formatting for status, packet, raw, and neighbors data -- Packet queuing during connection issues -- Automatic migration from old configuration format +The node joins WiFi, connects to each configured broker, and starts publishing. Its +MQTT origin is the node name. ## Broker Presets -Each of the 6 slots holds one preset. Point a slot at a community broker with: +Each of the six slots holds one preset: ```bash -set mqtt3.preset meshmapper # slot 3 → MeshMapper +set mqtt3.preset meshmapper +set mqtt3.preset none # disable the slot ``` -Most presets need nothing else — the broker address, transport, and credentials all ship -in the firmware. The **Extra setup** column below lists the exceptions. Presets using the -`meshcore/{iata}/…` topic layout (every built-in except `meshrank`) also need `set mqtt.iata`. +Broker address, transport, and credentials ship in the firmware. The **Extra setup** +column lists the presets that need more. Every preset except `meshrank` uses the +`meshcore/{iata}/…` topic layout, so `mqtt.iata` must be set. -Run `get mqtt.presets` on the device for the list this firmware actually ships; the table -below documents the current build. +`get mqtt.presets` lists what the running firmware ships. The table matches the current build. | Preset | Broker | Auth | Extra setup | |--------|--------|------|-------------| -| `analyzer-us` | `wss://mqtt-us-v1.letsmesh.net:443/mqtt` | JWT | — (default slot 1) | -| `analyzer-eu` | `wss://mqtt-eu-v1.letsmesh.net:443/mqtt` | JWT | — (default slot 2) | -| `nz-analyzer` | `wss://meshcore-mqtt-1.baird.io:443` | JWT | — | -| `meshmapper` | `wss://mqtt.meshmapper.net:443/mqtt` | JWT | — | -| `meshrank` | `mqtts://meshrank.net:8883` | None (token in topic) | `set mqttN.token ` | -| `waev` | `wss://mqtt.waev.app:443/mqtt` | JWT | — | -| `meshomatic` | `wss://us-east.meshomatic.net:443/mqtt` | JWT | — | -| `cascadiamesh` | `wss://mqtt-v1.cascadiamesh.org:443/mqtt` | JWT | — | -| `tennmesh` | `mqtt://mqtt.tennmesh.com:1883` | User/pass (in firmware) | — | -| `nashmesh` | `mqtt://mqtt.nashme.sh:1883` | User/pass (in firmware) | — | -| `ctmesh` | `mqtt://mqtt.ctmesh.org:1883` | User/pass (in firmware) | — | -| `chimesh` | `wss://mqtt.chimesh.org:443` | JWT | — | -| `meshat.se` | `wss://meshcore-mqtt.meshat.se:443` | JWT | — | -| `eastidahomesh` | `mqtt://live.eastidahomesh.com:1883` | None | — | -| `coloradomesh` | `wss://mqtt.meshcore.coloradomesh.org:443` | JWT | — | -| `dutchmeshcore-1` | `wss://collector1.dutchmeshcore.nl:443/mqtt` | JWT | — | -| `dutchmeshcore-2` | `wss://collector2.dutchmeshcore.nl:443/mqtt` | JWT | — | -| `meshcore-ca-1` | `wss://mqtt1.meshcore.ca:443/mqtt` | JWT | — | -| `meshcore-ca-2` | `wss://mqtt2.meshcore.ca:443/mqtt` | JWT | — | -| `meshcore-fi` | `wss://mc-mqtt.meshcore.fi:443/` | JWT | — | -| `okimesh-1` | `wss://mqtt1.okimesh.org:9002/mqtt` | JWT | — | -| `okimesh-2` | `wss://mqtt2.okimesh.org:9002/mqtt` | JWT | — | -| `inwmesh` | `mqtts://scope.inwmesh.org:8883` | User/pass (per slot) | `set mqttN.username` + `set mqttN.password` | -| `bostonmesh` | `wss://mqttmc01.bostonme.sh:443/mqtt` | JWT | — | -| `rflab` | `wss://mqtt.rflab.io:443` | JWT | — | -| `ipnt.uk` | `wss://mqtt.ipnt.uk:443` | JWT | — | -| `flmesh` | `wss://mcmqtt.jntconnections.com:443` | JWT | — | -| `corecomms` | `wss://mqtt.corecomms.net:443/mqtt` | JWT | — | -| `meshtexas` | `wss://mqtt.meshtexas.org:443/mqtt` | JWT | — | -| `mesh-chaun14` | `mqtt://mqtt.mesh.chaun14.fr:1884` | User/pass (username is the device public key) | `set mqttN.password` | -| `wcmesh` | `wss://mqtt.wcmesh.com:443` | JWT | — | -| `atvirastinklas` | `wss://mqtt-mc.atvirastinklas.lt:443` | JWT | — | -| `gomesh` | `wss://mqtt.gomesh.dev:443` | JWT | — | -| `idahomesh` | `wss://mqtt.idahomesh.org:443/mqtt` | JWT | — | -| `ntxmesh` | `wss://ntxmesh.dhovin.me:8883` | JWT | — | -| `bsmesh` | `wss://mqtt.bsmesh.de:8885` | JWT | — | -| `marwoj` | `mqtts://mqtt.marwoj.net:8883` | User/pass (in firmware) | — | -| `custom` | your own broker | User/pass, or JWT when `mqttN.audience` is set | `set mqttN.server` (see [custom broker setup](#custom-brokers)) | -| `none` | (slot disabled) | — | — | +| `analyzer-us` | `wss://mqtt-us-v1.letsmesh.net:443/mqtt` | JWT | default slot 1 | +| `analyzer-eu` | `wss://mqtt-eu-v1.letsmesh.net:443/mqtt` | JWT | default slot 2 | +| `nz-analyzer` | `wss://meshcore-mqtt-1.baird.io:443` | JWT | | +| `meshmapper` | `wss://mqtt.meshmapper.net:443/mqtt` | JWT | | +| `meshrank` | `mqtts://meshrank.net:8883` | token in topic | `set mqttN.token ` | +| `waev` | `wss://mqtt.waev.app:443/mqtt` | JWT | | +| `meshomatic` | `wss://us-east.meshomatic.net:443/mqtt` | JWT | | +| `cascadiamesh` | `wss://mqtt-v1.cascadiamesh.org:443/mqtt` | JWT | | +| `tennmesh` | `mqtt://mqtt.tennmesh.com:1883` | user/pass (built in) | | +| `nashmesh` | `mqtt://mqtt.nashme.sh:1883` | user/pass (built in) | | +| `ctmesh` | `mqtt://mqtt.ctmesh.org:1883` | user/pass (built in) | | +| `chimesh` | `wss://mqtt.chimesh.org:443` | JWT | | +| `meshat.se` | `wss://meshcore-mqtt.meshat.se:443` | JWT | | +| `eastidahomesh` | `mqtt://live.eastidahomesh.com:1883` | none | | +| `coloradomesh` | `wss://mqtt.meshcore.coloradomesh.org:443` | JWT | | +| `dutchmeshcore-1` | `wss://collector1.dutchmeshcore.nl:443/mqtt` | JWT | | +| `dutchmeshcore-2` | `wss://collector2.dutchmeshcore.nl:443/mqtt` | JWT | | +| `meshcore-ca-1` | `wss://mqtt1.meshcore.ca:443/mqtt` | JWT | | +| `meshcore-ca-2` | `wss://mqtt2.meshcore.ca:443/mqtt` | JWT | | +| `meshcore-fi` | `wss://mc-mqtt.meshcore.fi:443/` | JWT | | +| `okimesh-1` | `wss://mqtt1.okimesh.org:9002/mqtt` | JWT | | +| `okimesh-2` | `wss://mqtt2.okimesh.org:9002/mqtt` | JWT | | +| `inwmesh` | `mqtts://scope.inwmesh.org:8883` | user/pass (per slot) | `set mqttN.username`, `set mqttN.password` | +| `bostonmesh` | `wss://mqttmc01.bostonme.sh:443/mqtt` | JWT | | +| `rflab` | `wss://mqtt.rflab.io:443` | JWT | | +| `ipnt.uk` | `wss://mqtt.ipnt.uk:443` | JWT | | +| `flmesh` | `wss://mcmqtt.jntconnections.com:443` | JWT | | +| `corecomms` | `wss://mqtt.corecomms.net:443/mqtt` | JWT | | +| `meshtexas` | `wss://mqtt.meshtexas.org:443/mqtt` | JWT | | +| `mesh-chaun14` | `mqtt://mqtt.mesh.chaun14.fr:1884` | user/pass (username is the public key) | `set mqttN.password` | +| `wcmesh` | `wss://mqtt.wcmesh.com:443` | JWT | | +| `atvirastinklas` | `wss://mqtt-mc.atvirastinklas.lt:443` | JWT | | +| `gomesh` | `wss://mqtt.gomesh.dev:443` | JWT | | +| `idahomesh` | `wss://mqtt.idahomesh.org:443/mqtt` | JWT | | +| `ntxmesh` | `wss://ntxmesh.dhovin.me:8883` | JWT | | +| `bsmesh` | `wss://mqtt.bsmesh.de:8885` | JWT | | +| `marwoj` | `mqtts://mqtt.marwoj.net:8883` | user/pass (built in) | | +| `custom` | your broker | user/pass, or JWT when `mqttN.audience` is set | see [Custom Brokers](#custom-brokers) | +| `none` | slot disabled | | | -Transport is the URL scheme: `wss://` is WebSocket Secure, `mqtts://` is MQTT over TLS, -and `mqtt://` is plain unencrypted MQTT. The two TLS schemes are what count against the -non-PSRAM slot limit below. +`wss://` is WebSocket over TLS, `mqtts://` is MQTT over TLS, and `mqtt://` is unencrypted. ### Slots and Memory Limits -Fresh installs default to slot 1 `analyzer-us`, slot 2 `analyzer-eu`, and slots 3–6 `none`. +Fresh installs use `analyzer-us` in slot 1, `analyzer-eu` in slot 2, and `none` in 3-6. -- **With PSRAM:** all 6 slots can be active simultaneously -- **Without PSRAM:** maximum 2 active TLS/WSS slots (each WSS/TLS connection requires ~40KB internal heap) -- Slots configured beyond what the device supports show as `(inactive)` in `get mqtt.status` -- Slot configuration is preserved in preferences — moving the firmware to a PSRAM device activates the rest +- **With PSRAM:** all six slots can be active. +- **Without PSRAM:** at most two TLS slots (`wss://` or `mqtts://`); each needs about 40 KB + of internal heap. Extra slots show as `(inactive)` in `get mqtt.status` and stay saved, + so they activate if the settings move to a PSRAM board. +- **Classic ESP32 without PSRAM** (Heltec V2): plan on one TLS slot. A second usually + fails with `mbedtls_ssl_setup` / esp-tls `0x8017`. Set the other slot to `none`. -**Neighbors publication without PSRAM.** PSRAM boards get `WITH_MQTT_NEIGHBORS` -automatically. Non-PSRAM boards opt in per variant with -`-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1`, which is set on the ESP32-S3 observer envs -(Heltec V3/WSL3, RAK3112, Heltec Tracker v1.1/v2). It costs ~7.4 KB of static DRAM -(~9.6 KB on room servers) and up to ~13 KB transiently per publish, and caps the -table at 20 entries — so the feature is deliberately **not** enabled on the classic -ESP32 T-LoRa V2.1–1.6 and Heltec V2 observer builds, which are already down to one -active TLS slot. Publishing peaks while the table is built, so keep the slot guidance above -in mind: the peak lands on the same internal heap the TLS stack draws from. - -## Build Configuration - -To build the MQTT bridge firmware: - -```bash -# Heltec V2 (classic ESP32, no PSRAM) -pio run -e Heltec_v2_repeater_observer_mqtt -pio run -e Heltec_v2_room_server_observer_mqtt - -# Heltec V3 -pio run -e Heltec_v3_repeater_observer_mqtt - -# Heltec V4 -pio run -e heltec_v4_repeater_observer_mqtt - -# Heltec Wireless Tracker v1.1 / v2 -pio run -e heltec_tracker_v1_1_repeater_observer_mqtt -pio run -e heltec_tracker_v1_1_room_server_observer_mqtt -pio run -e heltec_tracker_v2_repeater_observer_mqtt -pio run -e heltec_tracker_v2_room_server_observer_mqtt - -# Station G2 -pio run -e Station_G2_repeater_observer_mqtt - -# Station G3 (ESP32) -pio run -e Station_G3_ESP32_repeater_observer_mqtt -pio run -e Station_G3_ESP32_room_server_observer_mqtt -pio run -e Station_G3_ESP32_r2_repeater_observer_mqtt # second RF slot -pio run -e Station_G3_ESP32_r2_room_server_observer_mqtt # second RF slot - -# LilyGo T-LoRa V2.1-1.6 (TTGO LoRa32 V1.0) -pio run -e LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt -pio run -e LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt - -# Elecrow ThinkNode M7 -pio run -e ThinkNode_M7_repeater_observer_mqtt -pio run -e ThinkNode_M7_room_server_observer_mqtt -``` - -**ThinkNode M7 — Ethernet preferred, WiFi fallback:** the observer envs use the onboard -CH390 Ethernet when it has a DHCP lease at boot, otherwise the stored WiFi network, and -switch between the two at runtime (`get link.status`, `get link.diag`). Nodes without a -cable behave like WiFi observers after a brief (about 1 s) Ethernet probe at boot. The DHCP hostname is -`meshcore-` on either medium. A first boot on Ethernet with no WiFi configured -opens WebConfig on the LAN with a one-time login code printed on serial (and shown on -the display), and requires replacing the admin password. The board has PSRAM, so these -builds get neighbors publication (`WITH_MQTT_NEIGHBORS`) automatically. - -**TLora naming:** The env prefix `LilyGo_TLora_V2_1_1_6` is LilyGo’s **T-LoRa V2.1–1.6** board (SX1276); PlatformIO selects **`ttgo-lora32-v1`** (TTGO LoRa32 V1.0). **MQTT observer** envs extend a slim base **without** `sensor_base` so the image fits `min_spiffs`; **all other** `LilyGo_TLora_V2_1_1_6_*` targets still use optional I2C environmental sensors as before. The **`lilygo_tlora_c6`** variant is separate hardware (ESP32-C6). - -**T-LoRa V2.1–1.6 MQTT observer — one WSS broker:** This hardware is **classic ESP32 without PSRAM**. Each WSS preset uses a full TLS stack and large contiguous heap allocations; **two active broker presets at once** typically fails the second connection (`mbedtls_ssl_setup` / `esp-tls` `0x8017`, low `IntMax` in `memory`). **Treat these observer builds as supporting one active cloud preset:** configure the broker you need in `mqtt1` or `mqtt2`, and set the other slot to `none` (e.g. `set mqtt2.preset none`). Use PSRAM-capable boards if you need multiple simultaneous MQTT uplinks. - -**Heltec V2 MQTT observer — one WSS broker:** the Heltec WiFi LoRa 32 V2 is also **classic ESP32 without PSRAM**, so the same one-active-preset guidance applies. Its 8 MB flash fits the image in the board's default `default_8MB.csv` layout, so no partition change or erase is needed. - -### Partition Table Changes — Merged Firmware Required - -Some MQTT observer builds use a non-default partition table to accommodate the larger firmware size (MQTT libraries, TLS, cert bundle, etc.). **When a board's partition table changes, you must flash the merged firmware (`*-merged.bin`) the first time** so the new partition layout and bootloader are written together. After that initial flash, standard OTA or non-merged updates will work normally. - -| Environment | Partition Table | Flash Size | App Slot Size | Notes | -|-------------|----------------|------------|---------------|-------| -| `LilyGo_T3S3_sx1262_repeater_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | Changed from default (1.25 MB) | -| `LilyGo_T3S3_sx1262_room_server_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | Changed from default (1.25 MB) | -| `LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | TTGO LoRa32 V1.0; observer omits `sensor_base`. See the one-WSS-broker note above. | -| `LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | same | -| `Station_G2_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board | -| `Station_G2_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board | -| `Station_G3_ESP32_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board | -| `Station_G3_ESP32_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board | -| `Station_G3_ESP32_r2_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | Second RF slot; same board and layout as the slot-1 env | -| `Station_G3_ESP32_r2_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | same | -| `LilyGo_TBeam_1W_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | Set in `boards/t_beam_1w.json`; required vs implicit `default.csv` | -| `LilyGo_TBeam_1W_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | same | - -Boards absent from this table need no special first flash: their observer envs use the -same partition table as the board's other firmwares. - -**Settings loss when the layout changes** - -A merged image writes a new bootloader **and** partition table at offset `0x0`. If that -layout differs from what the device already has, **NVS is typically wiped** — expect to lose -stored configuration (admin preferences, WiFi, MQTT slots, name) and reconfigure from -scratch. If the layout is unchanged, NVS is usually retained, though Bluetooth pairings may -still be cleared on some upgrade paths. - -Two cases catch people out: the TLora observer builds use the same `min_spiffs.csv` as other -MeshCore TLora builds, so normal repeater ↔ room server ↔ observer moves are fine — but -coming *from* an older TLora observer that used `huge_app.csv` is a layout change. And any -device previously running a non-MeshCore partition map will be wiped by its first merged -flash regardless of target. - -**How to flash the merged firmware:** - -- **Web flasher (recommended):** Use the [MeshCore Observer Flasher](https://observer.gessaman.com/) to flash from your browser — no tools to install. Pick **MQTT Observer Firmware** and your device. Its Download menu also serves the individual `*-merged.bin`, erase, and bootloader files if you would rather flash with something else. Requires Chrome or Edge. -- **Command line:** - ```bash - # Build the merged binary - pio run -t mergebin -e LilyGo_T3S3_sx1262_repeater_observer_mqtt - - # Flash at offset 0x0 (overwrites bootloader + partition table) - esptool.py write_flash 0x0 .pio/build/LilyGo_T3S3_sx1262_repeater_observer_mqtt/firmware-merged.bin - ``` - -### Build Flags -- `WITH_MQTT_BRIDGE=1` - Enable MQTT bridge (required) -- `WITH_SNMP=1` - Enable SNMP agent (optional, see [MQTT_SNMP.md](MQTT_SNMP.md)) -- `MQTT_DEBUG=1` - Enable debug logging (optional) -- `MQTT_WIFI_TX_POWER` - WiFi TX power level (default: `WIFI_POWER_11dBm`) -- ~~`MQTT_WIFI_POWER_SAVE_DEFAULT`~~ - Removed; all builds now default to `none` (no power save) - -#### Compile-time fresh-install defaults (`src/helpers/MQTTDefaults.h`) - -Optional PlatformIO `build_flags` override defaults used when `/mqtt.json` is first created. They do **not** change existing saved prefs on upgrade or reflash (unless `/mqtt.json` is erased). - -| Macro | Default | Notes | -|-------|---------|-------| -| `MQTT_DEFAULT_SLOT1_PRESET` … `MQTT_DEFAULT_SLOT6_PRESET` | slots 1–2: `analyzer-us` / `analyzer-eu`; slots 3–6: `none` | Must be a built-in preset name, `none`, or `custom` | -| `MQTT_DEFAULT_IATA` | (empty) | e.g. `'"YYZ"'` | -| `MQTT_DEFAULT_TIMEZONE` | (empty) | e.g. `'"America/Toronto"'` | -| `MQTT_DEFAULT_TIMEZONE_OFFSET` | `0` | Fallback hours when TZ string is empty | - -Example community build: - -```ini -build_flags = - -D MQTT_DEFAULT_SLOT1_PRESET='"meshcore-ca-1"' - -D MQTT_DEFAULT_SLOT2_PRESET='"meshcore-ca-2"' - -D MQTT_DEFAULT_IATA='"YYZ"' - -D MQTT_DEFAULT_TIMEZONE='"America/Toronto"' - -D MQTT_DEFAULT_TIMEZONE_OFFSET=-5 -``` - -WiFi SSID/password are not compile-time configurable (operators set them per device via CLI). - -Legacy `get mqtt.analyzer_us` / `set mqtt.analyzer_us` still refer to the preset name `analyzer-us`, not “whatever slot 1 default is”. - -## Default Configuration - -The MQTT bridge comes with the following defaults for fresh installs (unless overridden by the macros above): -- **Origin**: Device name (set automatically from `set name`) -- **IATA**: (blank — must be configured for MeshCore-style topic presets such as Analyzer and TennMesh, unless `MQTT_DEFAULT_IATA` is set at build time) -- **Status Messages**: Enabled -- **Packet Messages**: Enabled -- **Raw Messages**: Disabled -- **RX Packets**: Enabled (uplink received packets) -- **TX Packets**: `advert` by default (uplink this node's own adverts; set to `on` for all TX or `off` to disable) -- **Status Interval**: 5 minutes (300000 ms) -- **Slot 1**: `analyzer-us` -- **Slot 2**: `analyzer-eu` -- **Slots 3-6**: `none` (disabled) -- **Per-slot packet filters**: `all` (every payload type is uploaded) -- **WiFi SSID**: (blank — must be configured) -- **WiFi Password**: (blank — optional for open networks) -- **WiFi Power Save**: `none` (no power save) -- **Timezone**: (blank — uses UTC until configured, unless `MQTT_DEFAULT_TIMEZONE` is set at build time) -- **Timezone Offset**: 0 (fallback, no offset, unless `MQTT_DEFAULT_TIMEZONE_OFFSET` is set) -- **Repeat (forwarding)**: On (set `repeat off` for receive-only observers) +**Neighbors publication** (`WITH_MQTT_NEIGHBORS`) is on for every PSRAM board, and for the +ESP32-S3 boards without PSRAM that set `-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1` (Heltec V3/WSL3, +RAK3112, Heltec Tracker v1.1/v2). Those builds cap the table at 20 entries and spend about +7.4 KB of static DRAM (9.6 KB on room servers) plus up to 13 KB per publish, from the same +heap TLS uses. Classic ESP32 builds leave it off. ## CLI Commands -### MQTT Slot Commands +Commands work over serial and over a remote admin login unless marked *serial only*. -Each slot (1-6) supports the following commands: +### MQTT Slots -#### Get Commands -- `get mqtt1.preset` - Get slot 1 preset name -- `get mqtt2.preset` - Get slot 2 preset name -- `get mqttN.preset` - Get slot N preset name (N = 1-6) -- `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 -- `get mqttN.token` - Get per-slot token (e.g., MeshRank account token) -- `get mqttN.topic` - Get custom topic template for slot N -- `get mqttN.audience` - Get JWT audience for slot N (custom slots only) -- `get mqttN.filter` - Get the slot's packet-type allowlist (`all`, `none`, or numeric CSV) +`N` is the slot number, 1-6. -#### Set Commands -- `set mqttN.preset ` - Set slot N to a built-in preset. Use any `name` from [Broker Presets](#broker-presets), which also lists the few presets needing extra setup. -- `set mqttN.preset custom` - Set slot N to custom broker (configure server/port/username/password) -- `set mqttN.preset none` - Disable slot N -- `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 username for slot N (`custom` preset, or presets like `inwmesh` that require per-device credentials) -- `set mqttN.password ` - Set password for slot N (`custom` preset, or presets like `inwmesh` that require per-device credentials) -- `set mqttN.token ` - Set per-slot token (required for MeshRank preset) -- `set mqttN.topic