Merge remote-tracking branch 'review/observer-firmware' into keymindCascade
# Conflicts: # .github/workflows/build-observer-firmwares.yml # .gitignore # MQTT_IMPLEMENTATION.md # MQTT_INTERNALS.md # README.md # STABILITY_TESTABILITY_HANDOFF.md # docs/cli_commands.md # examples/companion_radio/MyMesh.cpp # examples/simple_repeater/MyMesh.cpp # examples/simple_repeater/UITask.cpp # examples/simple_room_server/MyMesh.cpp # examples/simple_room_server/UITask.cpp # examples/simple_sensor/SensorMesh.cpp # platformio.ini # scripts/generate_webconfig_html.py # scripts/webconfig_mock_server.py # src/helpers/CommonCLI.cpp # src/helpers/CommonCLI.h # src/helpers/JWTHelper.cpp # src/helpers/MQTTDefaults.h # src/helpers/MQTTMessageBuilder.cpp # src/helpers/MQTTPayloadBuilder.h # src/helpers/MQTTPresets.h # src/helpers/bridges/MQTTBridge.h # src/helpers/esp32/WebConfigServer.cpp # src/helpers/esp32/WebConfigServer.h # src/helpers/ui/SH1106Display.cpp # variants/lilygo_tbeam_SX1262/platformio.ini # variants/lilygo_tbeam_SX1276/platformio.ini # variants/lilygo_tlora_v2_1/platformio.ini # variants/thinknode_m7/platformio.ini # webui/index.html
This commit is contained in:
commit
a04a284a3f
74 files changed
+7857
-2483
No files matched your search
@@ -38,11 +38,17 @@ on:
|
||||
- 'LICENSE'
|
||||
- '.vscode/**'
|
||||
- '.claude/**'
|
||||
# Throwaway build worktrees never affect the binaries.
|
||||
- '.build-wt-*'
|
||||
- '.build-wt-*/**'
|
||||
|
||||
# Shared with build-observer-firmwares.yml and sync-flasher-content.yml so the
|
||||
# workflows never push to the flasher repo at the same time.
|
||||
# Own group: serialize beta builds against each other only. Concurrency groups are
|
||||
# repo-wide, not per-branch, so sharing one with the production workflows let an
|
||||
# in-flight beta build force a production build into the pending state, where the
|
||||
# next run queued into the group cancelled it outright. Concurrent flasher pushes
|
||||
# are instead made safe by the rebase-retry in "Commit & Push Beta Artifacts".
|
||||
concurrency:
|
||||
group: flasher-publish
|
||||
group: observer-firmware-beta-build
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
@@ -50,7 +56,7 @@ env:
|
||||
# OTA comparison treats a different base version as "always an update", so a
|
||||
# distinct base here would make every beta node think it is permanently behind.
|
||||
# Channels are separated by manifest URL, not by base version.
|
||||
FIRMWARE_VERSION: v1.16.0
|
||||
FIRMWARE_VERSION: v1.17.0
|
||||
|
||||
# Beta-only rolling release. A separate tag is required, not cosmetic: the
|
||||
# publish step prunes all but the KEEP_BUILDS most recent build hashes within
|
||||
@@ -346,4 +352,26 @@ jobs:
|
||||
git config user.name "meshcore-bot"
|
||||
git config user.email "noreply@gessaman.com"
|
||||
git commit -m "Update BETA observer firmware to ${{ steps.sha.outputs.short }} (build ${FIRMWARE_VERSION}.${{ needs.enumerate.outputs.build_number }})"
|
||||
git push
|
||||
# The production workflows can be pushing to the flasher repo right now
|
||||
# (no shared concurrency group any more), so a non-fast-forward is expected
|
||||
# rather than fatal: rebase onto their commit and retry. The scoped add
|
||||
# above keeps this commit inside the beta paths, so a conflict with
|
||||
# production's files is not possible.
|
||||
br=$(git rev-parse --abbrev-ref HEAD)
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin "HEAD:$br"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "push rejected (attempt $attempt); rebasing onto origin/$br"
|
||||
git fetch origin "$br"
|
||||
# --autostash: the scoped add above leaves unrelated edits in the
|
||||
# checkout unstaged on purpose, and plain rebase refuses to run with a
|
||||
# dirty tree. They stay unstaged, so they still never get published.
|
||||
git rebase --autostash "origin/$br" || {
|
||||
git rebase --abort || true
|
||||
echo "::error::flasher rebase conflicted; re-run this workflow to republish"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "::error::could not push beta changes after 3 attempts"
|
||||
exit 1
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- mqtt-bridge-implementation-flex
|
||||
- observer-firmware
|
||||
# Only rebuild firmware when something that affects the binaries changes.
|
||||
# Docs, the changelog, the changelog generator, CI files, and repo-meta
|
||||
# files do not change firmware output - docs/changelog are handled by
|
||||
@@ -25,19 +25,29 @@ on:
|
||||
- 'LICENSE'
|
||||
- '.vscode/**'
|
||||
- '.claude/**'
|
||||
# Throwaway build worktrees never affect the binaries.
|
||||
- '.build-wt-*'
|
||||
- '.build-wt-*/**'
|
||||
|
||||
# Serialize with sync-flasher-content.yml so the two workflows never push to the
|
||||
# flasher repo at the same time (shared group name across both workflows).
|
||||
# Own group: serialize builds against each other only. Do NOT share this group
|
||||
# with sync-flasher-content.yml -- a mixed code+docs commit triggers both, and a
|
||||
# run that is still pending when its sibling is queued gets cancelled, which
|
||||
# silently skipped the firmware build. Concurrent flasher pushes are instead made
|
||||
# safe by the rebase-retry in "Commit & Push Flasher Config".
|
||||
concurrency:
|
||||
group: flasher-publish
|
||||
group: observer-firmware-build
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# Version embedded in firmware filenames; must match the version key in the
|
||||
# flasher's config.json. Bump here when the observer version changes.
|
||||
FIRMWARE_VERSION: v1.16.0
|
||||
FIRMWARE_VERSION: v1.17.0
|
||||
# Rolling release tag that hosts the latest observer binaries.
|
||||
RELEASE_TAG: observer-mqtt-latest
|
||||
# Download host serving RELEASE_TAG's assets (cloudflare-worker in the flasher
|
||||
# repo). Baked into the slim OTA manifests' file URLs; must stay consistent
|
||||
# with config.json's staticPath.
|
||||
STATIC_PATH: https://observer-fw.gessaman.com
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -185,6 +195,13 @@ jobs:
|
||||
# not published as release assets (and they'd break the hash-based prune).
|
||||
gh release upload "$RELEASE_TAG" $(find out -maxdepth 1 -type f ! -name '*.partsig') --clobber
|
||||
|
||||
# Keep the release body in sync with the repo's notes source: the
|
||||
# /releases feed serves it as the flasher dropdown's changelog
|
||||
# (config.json no longer carries per-version notes). Non-fatal --
|
||||
# stale notes beat a red build whose binaries are already live.
|
||||
gh release edit "$RELEASE_TAG" --notes-file firmware-notes.html \
|
||||
|| echo "WARNING: release notes sync failed" >&2
|
||||
|
||||
# Prune old assets, but RETAIN the most recent KEEP_BUILDS build hashes
|
||||
# (not just the current one). The flasher SPA reads config.json once at
|
||||
# page load and the embedded git short-hash is what links it to assets;
|
||||
@@ -222,22 +239,23 @@ jobs:
|
||||
token: ${{ secrets.FLASHER_DISPATCH_TOKEN }}
|
||||
path: flasher
|
||||
|
||||
- name: Update Flasher Config
|
||||
run: |
|
||||
python3 flasher/scripts/update-firmware.py \
|
||||
"${{ steps.sha.outputs.short }}" \
|
||||
"$GITHUB_WORKSPACE/firmware-notes.html"
|
||||
# update-firmware.py is no longer called here: config.json's observer
|
||||
# entries are github release defs (no embedded filenames to rewrite), the
|
||||
# flasher's versions come from the Worker's /releases feed, and the
|
||||
# changelog notes ride the release body (see the publish step).
|
||||
|
||||
- name: Generate Slim Per-Variant Manifests + Persist Build Counter
|
||||
env:
|
||||
BUILD_NUMBER: ${{ needs.enumerate.outputs.build_number }}
|
||||
run: |
|
||||
# Derive the slim per-variant manifests (flasher/v/<env>.json) that the
|
||||
# firmware fetches for `ota check`/`ota update`, from the just-updated
|
||||
# config.json (single source of truth), stamping this build's number.
|
||||
# Then persist the counter so the next run increments from here.
|
||||
# firmware fetches for `ota check`/`ota update`, from the build output
|
||||
# in out/ -- the assets actually uploaded to the release -- stamping this
|
||||
# build's number. Then persist the counter so the next run increments
|
||||
# from here.
|
||||
python3 flasher/scripts/gen-slim-manifests.py \
|
||||
--config flasher/config.json \
|
||||
--bin-dir out \
|
||||
--static-path "$STATIC_PATH" \
|
||||
--out-dir flasher/v \
|
||||
--base-version "$FIRMWARE_VERSION" \
|
||||
--build "$BUILD_NUMBER" \
|
||||
@@ -294,4 +312,24 @@ jobs:
|
||||
git config user.name "meshcore-bot"
|
||||
git config user.email "noreply@gessaman.com"
|
||||
git commit -m "Update observer firmware to ${{ steps.sha.outputs.short }} (build ${FIRMWARE_VERSION}.${{ needs.enumerate.outputs.build_number }})"
|
||||
git push
|
||||
# sync-flasher-content.yml can be pushing the same docs/changelog right
|
||||
# now (a mixed code+docs commit triggers both), so a non-fast-forward is
|
||||
# expected rather than fatal: rebase onto the sibling's commit and retry.
|
||||
# If the rebase leaves nothing, git reports up-to-date and we exit clean.
|
||||
br=$(git rev-parse --abbrev-ref HEAD)
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin "HEAD:$br"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "push rejected (attempt $attempt); rebasing onto origin/$br"
|
||||
git fetch origin "$br"
|
||||
# --autostash so a dirty tree can never make the rebase refuse to run;
|
||||
# nothing unstaged gets published either way.
|
||||
git rebase --autostash "origin/$br" || {
|
||||
git rebase --abort || true
|
||||
echo "::error::flasher rebase conflicted; re-run this workflow to republish"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "::error::could not push flasher changes after 3 attempts"
|
||||
exit 1
|
||||
@@ -14,16 +14,21 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- mqtt-bridge-implementation-flex
|
||||
- observer-firmware
|
||||
paths:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- 'scripts/gen_changelog.py'
|
||||
# /webconfig frames the real portal page against a browser simulator, so
|
||||
# the page itself is a synced doc asset like the .md files above.
|
||||
- 'webui/index.html'
|
||||
|
||||
# Shared with build-observer-firmwares.yml so the two never push to the flasher
|
||||
# repo concurrently (a mixed code+docs commit can trigger both).
|
||||
# Own group: serialize doc syncs against each other only. Deliberately NOT shared
|
||||
# with build-observer-firmwares.yml -- a shared group let one workflow cancel the
|
||||
# other's pending run (a mixed code+docs commit triggers both). Concurrent flasher
|
||||
# pushes are handled by the rebase-retry in "Commit & Push Flasher Content".
|
||||
concurrency:
|
||||
group: flasher-publish
|
||||
group: flasher-docs-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -57,6 +62,21 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Sync the WebConfig Portal Page into Flasher
|
||||
run: |
|
||||
# flasher/webconfig.html embeds the REAL portal page in a device
|
||||
# surround and runs it against lib/webconfig-sim.js, which intercepts
|
||||
# fetch() so no device is needed. Syncing the page is what keeps that
|
||||
# demo honest -- the alternative is screenshots, which go stale without
|
||||
# anyone noticing. The build script only injects a <script> tag; it
|
||||
# refuses a page that already carries the simulator.
|
||||
if [ -f webui/index.html ]; then
|
||||
python3 flasher/scripts/build-webconfig-demo.py webui/index.html
|
||||
echo "rebuilt flasher/webconfig-demo.html from webui/index.html"
|
||||
else
|
||||
echo "WARNING: webui/index.html not found; leaving the demo as-is" >&2
|
||||
fi
|
||||
|
||||
- name: Generate Changelog
|
||||
run: |
|
||||
# Append-only and idempotent; preserves the hand-curated history and
|
||||
@@ -73,4 +93,24 @@ jobs:
|
||||
git config user.name "meshcore-bot"
|
||||
git config user.email "noreply@gessaman.com"
|
||||
git commit -am "Sync docs & changelog from MeshCore ${GITHUB_SHA::7}"
|
||||
git push
|
||||
# build-observer-firmwares.yml can be pushing the same docs/changelog
|
||||
# right now (a mixed code+docs commit triggers both), so a non-fast-forward
|
||||
# is expected rather than fatal: rebase onto its commit and retry. If the
|
||||
# rebase leaves nothing, git reports up-to-date and we exit clean.
|
||||
br=$(git rev-parse --abbrev-ref HEAD)
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin "HEAD:$br"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "push rejected (attempt $attempt); rebasing onto origin/$br"
|
||||
git fetch origin "$br"
|
||||
# --autostash so a dirty tree can never make the rebase refuse to run;
|
||||
# nothing unstaged gets published either way.
|
||||
git rebase --autostash "origin/$br" || {
|
||||
git rebase --abort || true
|
||||
echo "::error::flasher rebase conflicted; re-run this workflow to resync"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "::error::could not push flasher changes after 3 attempts"
|
||||
exit 1
|
||||
@@ -31,3 +31,10 @@ platformio.local.ini
|
||||
.claude/*
|
||||
.cursorrules
|
||||
__pycache__/
|
||||
# Worktrees checked out inside the repo. Committing one adds a stray gitlink
|
||||
# that makes every CI checkout warn "No url found for submodule path ... in
|
||||
# .gitmodules", and leaves `git status` permanently dirty so the next `git add
|
||||
# -A` re-commits it. .wt-* covers the hand-made ones; .build-wt-* the CI ones.
|
||||
.build-wt-*/
|
||||
.wt-*/
|
||||
scripts/__pycache__/*
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# MeshCore Observer - Changelog
|
||||
|
||||
Changes to the MQTT observer / bridge work that powers the firmware offered on
|
||||
[flasher.meshcore.io](https://flasher.meshcore.io). Newest changes are at the top.
|
||||
[observer.gessaman.com](https://observer.gessaman.com/). Newest changes are at the top.
|
||||
|
||||
**Legend:** **New** = new capability * **Fix** = bug fix * **Improvement** = enhancement to
|
||||
existing behavior * **Internal** = refactor / under-the-hood * **Docs** = documentation *
|
||||
|
||||
+291
-218
@@ -63,9 +63,17 @@ LAN page is intentionally unauthenticated; use a trusted WiFi network.
|
||||
|
||||
### CLI setup and fallback
|
||||
|
||||
For CLI setup, you need console access over serial (115200 baud) or a repeater
|
||||
login through the companion app.
|
||||
|
||||
**1. Flash the observer firmware to your device**
|
||||
|
||||
Use one of the observer build targets (e.g., `heltec_v4_repeater_observer_mqtt`). After flashing, connect to the device console via serial (115200 baud) or repeater login.
|
||||
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**
|
||||
|
||||
@@ -96,21 +104,39 @@ set wifi.ssid YourWiFiNetwork
|
||||
set wifi.pwd YourWiFiPassword
|
||||
```
|
||||
|
||||
**5. (Optional) Disable packet repeating**
|
||||
**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**
|
||||
|
||||
```bash
|
||||
set timezone America/New_York
|
||||
```
|
||||
|
||||
Or use a plain offset as a fallback: `set timezone.offset -5`. Published timestamps are
|
||||
always UTC either way -- see [Timezone Commands](#timezone-commands).
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
**6. Reboot to connect**
|
||||
**8. Reboot to connect**
|
||||
```bash
|
||||
reboot
|
||||
```
|
||||
|
||||
**7. Verify configuration**
|
||||
**9. Verify configuration**
|
||||
```bash
|
||||
get wifi.ssid
|
||||
get wifi.status
|
||||
get bridge.enabled
|
||||
get mqtt.rx
|
||||
get mqtt.tx
|
||||
@@ -133,63 +159,89 @@ get mqtt.status
|
||||
## Overview
|
||||
|
||||
The MQTT bridge implementation provides:
|
||||
- Up to 6 MQTT connection slots with built-in presets
|
||||
- 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
|
||||
- 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, packets, and raw data
|
||||
- JSON message formatting for status, packet, raw, and neighbors data
|
||||
- Packet queuing during connection issues
|
||||
- Automatic migration from old configuration format
|
||||
|
||||
## Architecture
|
||||
## Broker Presets
|
||||
|
||||
### Slot-Based Preset System
|
||||
Each of the 6 slots holds one preset. Point a slot at a community broker with:
|
||||
|
||||
The MQTT bridge uses a slot-based architecture with up to 6 concurrent connections. Each slot can be configured with a built-in preset or custom broker settings.
|
||||
```bash
|
||||
set mqtt3.preset meshmapper # slot 3 -> MeshMapper
|
||||
```
|
||||
|
||||
**Built-in Presets:**
|
||||
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`.
|
||||
|
||||
| Preset | Server | Auth | Transport |
|
||||
|--------|--------|------|-----------|
|
||||
| `analyzer-us` | mqtt-us-v1.letsmesh.net:443 | JWT (Ed25519) | WSS |
|
||||
| `analyzer-eu` | mqtt-eu-v1.letsmesh.net:443 | JWT (Ed25519) | WSS |
|
||||
| `nz-analyzer` | meshcore-mqtt-1.baird.io: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 |
|
||||
| `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 |
|
||||
| `ipnt.uk` | mqtt.ipnt.uk:443 | JWT (Ed25519) | WSS |
|
||||
| `flmesh` | mcmqtt.jntconnections.com:443 | JWT (Ed25519) | WSS |
|
||||
| `inwmesh` | scope.inwmesh.org:8883 | Username/password (per slot via `mqttN.username` / `mqttN.password`) | MQTT over TLS |
|
||||
| `rflab` | mqtt.rflab.io:443 | JWT (Ed25519) | WSS |
|
||||
| `custom` | User-configured | Username/Password | MQTT or WSS |
|
||||
| `none` | (disabled) | - | - |
|
||||
Run `get mqtt.presets` on the device for the list this firmware actually ships; the table
|
||||
below documents the current build.
|
||||
|
||||
**Default Configuration:**
|
||||
- Slot 1: `analyzer-us`
|
||||
- Slot 2: `analyzer-eu`
|
||||
- Slots 3-6: `none`
|
||||
| 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 <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 | -- |
|
||||
| `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) | -- | -- |
|
||||
|
||||
**Memory Limits:**
|
||||
- With PSRAM: All slots can be active simultaneously
|
||||
- Without PSRAM: Maximum 2 active TLS/WSS slots (each WSS/TLS connection requires ~40KB internal heap)
|
||||
- If more slots are configured than the device supports, excess slots show as `(inactive)` in `get mqtt.status`
|
||||
- Slot configurations are preserved in preferences - moving firmware to a PSRAM device activates all slots
|
||||
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.
|
||||
|
||||
### Slots and Memory Limits
|
||||
|
||||
Fresh installs default to slot 1 `analyzer-us`, slot 2 `analyzer-eu`, and slots 3-6 `none`.
|
||||
|
||||
- **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
|
||||
|
||||
**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 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
|
||||
|
||||
@@ -211,11 +263,25 @@ 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
|
||||
|
||||
# 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 -- WiFi only:** the M7 has an onboard CH390 Ethernet controller,
|
||||
and `ThinkNode_M7_companion_radio_ethernet` uses it, but the MQTT bridge link
|
||||
management is bound to the WiFi station API, so observer environments uplink
|
||||
over WiFi. See `UPSTREAM_BUGS.md` for the Ethernet gap. 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 they retain dual-app OTA on the 4 MB flash; **all other** `LilyGo_TLora_V2_1_1_6_*` targets still use optional I2C environmental sensors as before. The repeater observer also keeps 256 recent-repeater entries instead of the normal ESP32 default of 2,048. 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.
|
||||
@@ -232,10 +298,13 @@ Some MQTT observer builds use a non-default partition table to accommodate the l
|
||||
| `LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | TTGO LoRa32 V1.0; observer omits `sensor_base`; one active WSS broker recommended. |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
**NVS / settings when the partition layout changes**
|
||||
Boards absent from this table need no special first flash: their observer envs use the
|
||||
same partition table as the board's other firmwares.
|
||||
|
||||
Flashing a **full merged image** (`*-merged.bin` at offset `0x0`) writes a new bootloader **and** partition table. If that layout **differs** from what is already on the device, **NVS is typically wiped or invalidated** - expect to lose stored configuration (admin preferences, WiFi, MQTT slots, name, etc.) and reconfigure from scratch.
|
||||
|
||||
@@ -247,7 +316,7 @@ Flashing a **full merged image** (`*-merged.bin` at offset `0x0`) writes a new b
|
||||
|
||||
You can flash the merged firmware using either the web flasher or the command line:
|
||||
|
||||
- **Web flasher (recommended):** Use the [MeshCore Web Flasher](https://meshcore.io/flasher) to flash the `*-merged.bin` file directly from your browser - no tools to install.
|
||||
- **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. Requires Chrome or Edge.
|
||||
- **Command line:**
|
||||
```bash
|
||||
# Build the merged binary
|
||||
@@ -258,7 +327,6 @@ You can flash the merged firmware using either the web flasher or the command li
|
||||
```
|
||||
|
||||
> **Note:** If the **partition layout is unchanged** (e.g. updating the MQTT observer build in place), device configuration in NVS is usually retained; Bluetooth pairings may still be cleared on some upgrade paths. If the **partition table is new to the device**, see **NVS / settings when the partition layout changes** above - stored settings are typically lost. After the first merged flash **for a given layout**, subsequent updates on that board can use OTA or the standard non-merged binary when applicable.
|
||||
|
||||
### Build Flags
|
||||
- `WITH_MQTT_BRIDGE=1` - Enable MQTT bridge (required)
|
||||
- `WITH_SNMP=1` - Enable SNMP agent (optional, see [MQTT_SNMP.md](MQTT_SNMP.md))
|
||||
@@ -306,6 +374,7 @@ The MQTT bridge comes with the following defaults for fresh installs (unless ove
|
||||
- **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)
|
||||
@@ -330,11 +399,10 @@ Each slot (1-6) supports the following commands:
|
||||
- `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)
|
||||
|
||||
#### 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 <name>` - 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 <hostname>` - Set custom server hostname for slot N
|
||||
@@ -345,23 +413,92 @@ Each slot (1-6) supports the following commands:
|
||||
- `set mqttN.topic <template>` - Set custom topic template (custom preset only, see below)
|
||||
- `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)
|
||||
- `set mqttN.filter <all|none|list>` - Select payload types uploaded to this slot
|
||||
|
||||
**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
|
||||
#### Per-broker packet filters
|
||||
|
||||
Each slot has an independent allowlist. List entries may be payload-type names
|
||||
or numbers, and the two can be mixed. These are all equivalent, sending only
|
||||
text messages and adverts to slot 1:
|
||||
|
||||
```bash
|
||||
set mqtt1.filter txt_msg,advert
|
||||
set mqtt1.filter 2,4
|
||||
set mqtt1.filter advert, 2
|
||||
```
|
||||
|
||||
Use `none` when a broker should remain connected for status/neighbors but
|
||||
receive no packet traffic. A bare `set mqttN.filter` resets the slot to `all`.
|
||||
`get mqttN.filter` always answers in the canonical numeric form (`all`, `none`,
|
||||
or an ascending CSV), whichever spelling was used to set it.
|
||||
|
||||
| Type | Name | Type | Name |
|
||||
|------|------|------|------|
|
||||
| 0 | `req` | 8 | `path` |
|
||||
| 1 | `response` | 9 | `trace` |
|
||||
| 2 | `txt_msg` | 10 | `multipart` |
|
||||
| 3 | `ack` | 11 | `control` |
|
||||
| 4 | `advert` | 12-14 | reserved (number only) |
|
||||
| 5 | `grp_txt` | 15 | `raw_custom` |
|
||||
| 6 | `grp_data` | | |
|
||||
| 7 | `anon_req` | | |
|
||||
|
||||
Names are lowercase and exact; types 12-14 are reserved upstream and have no
|
||||
name, so they are selectable by number only.
|
||||
|
||||
The filter applies to both structured `packets` and `raw` publications for RX
|
||||
packets and for TX packets permitted by `mqtt.tx`. It does not affect local
|
||||
packet processing, forwarding, capture logs, status, or neighbors. Changes
|
||||
apply live without reconnecting the broker.
|
||||
|
||||
A rejected packet is dropped before it is copied into the publish queue, so a
|
||||
narrow filter saves the queue slot and the per-packet work, not just the
|
||||
upload. `get mqtt.stats` reports the running count as `filt=<n>`, and a slot
|
||||
whose filter is not `all` shows it in `get mqttN.diag` and on the WebConfig
|
||||
Stats tab -- a filtered slot otherwise looks identical to an idle healthy one.
|
||||
|
||||
The diag reply is capped at 160 characters. When a slot is also reporting a
|
||||
long error tail, the filter is summarised as `filter:<n>/16` rather than
|
||||
listed, because a list clipped mid-way would read as a different, valid
|
||||
allowlist. `get mqttN.filter` always gives the exact value.
|
||||
|
||||
In WebConfig the allowlist is a checkbox per type under each configured slot,
|
||||
with **All** / **None** shortcuts. Clearing every box is `none` (nothing
|
||||
uploaded).
|
||||
|
||||
**Downgrade note:** rolling back to any build from this release onward is safe --
|
||||
the older firmware reads the settings it understands and simply ignores the
|
||||
packet filters, which revert to `all` if it saves.
|
||||
|
||||
Rolling back to a build released *before* this one is the case to watch: that
|
||||
firmware rejects the longer settings file outright and falls back to defaults,
|
||||
losing the stored WiFi credentials along with the broker config. Slots left at
|
||||
the `all` default keep the file in the shorter layout those builds can read, so
|
||||
if you may need to roll a node back that far, reset every slot to `all` first.
|
||||
|
||||
|
||||
#### Example: MeshRank
|
||||
|
||||
MeshRank needs an account token, generated on the MeshRank website and tied to your account:
|
||||
```bash
|
||||
set mqtt3.preset meshrank
|
||||
set mqtt3.token FE1B34242C5938C39225310081FD6718
|
||||
```
|
||||
|
||||
The token is generated on the MeshRank website and is tied to your account. MeshRank only receives packet data (no status or raw messages).
|
||||
It receives status, packets, and neighbors under `meshrank/uplink/{token}/{device}/`, using
|
||||
the same type suffixes as the MeshCore layout. Raw is **not** sent to MeshRank -- it is the
|
||||
highest-volume topic and the broker does not consume it -- so `set mqtt.raw on` has no effect
|
||||
on a MeshRank slot. Its broker does not accept the retain flag, so those publishes go out
|
||||
unretained.
|
||||
|
||||
#### Example: Configure MeshMapper on Slot 3
|
||||
```bash
|
||||
set mqtt3.preset meshmapper
|
||||
```
|
||||
### Custom Brokers
|
||||
|
||||
#### Example: Configure Custom Broker on Slot 3
|
||||
Set the preset to `custom` and supply the broker address, plus credentials in whichever style
|
||||
the broker expects.
|
||||
|
||||
**Username/password:**
|
||||
```bash
|
||||
set mqtt3.preset custom
|
||||
set mqtt3.server your-broker.example.com
|
||||
@@ -370,9 +507,8 @@ set mqtt3.username your-username
|
||||
set mqtt3.password your-password
|
||||
```
|
||||
|
||||
#### Example: Custom Broker with JWT Authentication (Ed25519)
|
||||
|
||||
For community brokers that support the MeshCore JWT auth protocol (same as the built-in presets), set the `audience` field to enable Ed25519-signed JWT authentication:
|
||||
**Ed25519 JWT** -- for community brokers implementing the same JWT auth protocol as the
|
||||
built-in presets. Setting `audience` is what switches the slot to JWT:
|
||||
```bash
|
||||
set mqtt3.preset custom
|
||||
set mqtt3.server wss://my-broker.example.com:443/mqtt
|
||||
@@ -381,35 +517,29 @@ set mqtt3.audience my-broker.example.com
|
||||
|
||||
When the server is given as a full URL with a scheme (`mqtt://`, `mqtts://`, `ws://`, `wss://`), `set mqttN.port` is optional - an explicit port in the URL is used as-is, and without one the scheme's default port applies.
|
||||
|
||||
When `audience` is set, the device will:
|
||||
- Connect with username `v1_{PUBLIC_KEY}` and an Ed25519-signed JWT as the password
|
||||
- Automatically renew tokens before expiry (default 24h lifetime)
|
||||
- Include owner public key and email in the JWT payload (if configured via `set mqtt.owner` / `set mqtt.email`)
|
||||
With `audience` set, the device connects as `v1_{PUBLIC_KEY}` with an Ed25519-signed JWT as
|
||||
the password, renews tokens before expiry (default 24h lifetime), and includes the owner
|
||||
public key and email in the JWT payload if `set mqtt.owner` / `set mqtt.email` are
|
||||
configured. Clear it with a bare `set mqtt3.audience` to revert to username/password.
|
||||
|
||||
To revert a slot back to username/password auth, clear the audience:
|
||||
```bash
|
||||
set mqtt3.audience
|
||||
```
|
||||
|
||||
#### Example: Local Development Broker (plain WebSocket, no TLS)
|
||||
|
||||
For local development (e.g. a LAN broker without SSL termination), use a full `ws://` URL. Non-TLS transports (`ws://`, `mqtt://`) skip certificate verification entirely:
|
||||
**Local development broker** -- a LAN broker with no SSL termination. Non-TLS transports
|
||||
(`ws://`, `mqtt://`) skip certificate verification entirely:
|
||||
```bash
|
||||
set mqtt3.preset custom
|
||||
set mqtt3.server ws://192.168.1.50:9001/mqtt
|
||||
set mqtt3.audience my-local-broker
|
||||
```
|
||||
|
||||
The `audience` line is optional - set it if your local broker uses the same JWT auth as the production presets, or use `set mqtt3.username` / `set mqtt3.password` instead.
|
||||
|
||||
#### Example: Custom Broker with Custom Topic Template
|
||||
```bash
|
||||
set mqtt3.preset custom
|
||||
set mqtt3.server my-broker.local
|
||||
set mqtt3.port 1883
|
||||
set mqtt3.topic mynetwork/{device}/{type}
|
||||
```
|
||||
|
||||
When the server is given as a full URL with a scheme (`mqtt://`, `mqtts://`, `ws://`,
|
||||
`wss://`), `set mqttN.port` is optional -- an explicit port in the URL is used as-is, and
|
||||
without one the scheme's default port applies.
|
||||
|
||||
### Custom Topic Templates
|
||||
|
||||
When a slot's preset is `custom`, you can define a custom topic template using placeholders:
|
||||
@@ -434,14 +564,14 @@ These settings apply across all MQTT slots:
|
||||
- `get mqtt.iata` - Get IATA code
|
||||
- `get mqtt.presets` - List available MQTT presets (paginated, comma-separated)
|
||||
- `get mqtt.presets <start>` - Continue list from index shown in `... next:<idx>`
|
||||
- `get mqtt.status` - Get MQTT status summary (connection info per slot)
|
||||
- `get mqtt.status` - Get MQTT status summary (connection info per slot, plus the periodic neighbors schedule when `mqtt.neighbors` is on)
|
||||
- `get mqtt.packets` - Get packet message setting (on/off)
|
||||
- `get mqtt.raw` - Get raw message setting (on/off)
|
||||
- `get mqtt.rx` - Get RX packet uplinking setting (on/off)
|
||||
- `get mqtt.tx` - Get TX packet uplinking setting (on/off/advert)
|
||||
- `get mqtt.interval` - Get status publish interval
|
||||
- `get mqtt.neighbors` - Get periodic neighbors publishing setting (on/off; PSRAM only)
|
||||
- `get mqtt.neighbors.interval` - Get neighbors publish interval in hours (PSRAM only)
|
||||
- `get mqtt.neighbors` - Get periodic neighbors publishing setting (on/off; neighbors-enabled builds)
|
||||
- `get mqtt.neighbors.interval` - Get neighbors publish interval in hours (neighbors-enabled builds)
|
||||
- `get mqtt.ntp` - Get effective NTP server hostname
|
||||
- `get mqtt.ntp.diag` - Probe every configured NTP server for connectivity (does not change the clock; serial console shows each server's reported time, LoRa shows a compact `<server> ok|fail` list)
|
||||
- `get mqtt.owner` - Get owner public key (serial console only)
|
||||
@@ -459,8 +589,8 @@ These settings apply across all MQTT slots:
|
||||
- `advert` - Uplink only this node's own advert packets (self-originated)
|
||||
- `off` - Disable TX packet uplinking
|
||||
- `set mqtt.interval <minutes>` - Set status publish interval (1-60 minutes)
|
||||
- `set mqtt.neighbors on|off` - Enable/disable periodic neighbors publishing (PSRAM only; read live, no restart)
|
||||
- `set mqtt.neighbors.interval <hours>` - Set neighbors publish interval (12-336 hours, default 24; PSRAM only)
|
||||
- `set mqtt.neighbors on|off` - Enable/disable periodic neighbors publishing (neighbors-enabled builds; read live, no restart)
|
||||
- `set mqtt.neighbors.interval <hours>` - Set neighbors publish interval (12-336 hours, default 24; neighbors-enabled builds)
|
||||
- `set mqtt.ntp <hostname>` - Set custom NTP server (validated with immediate sync); `none` reverts to default
|
||||
- `set mqtt.owner <64-hex-char-public-key>` - Set owner public key
|
||||
- `set mqtt.email <email>` - Set owner email address
|
||||
@@ -524,8 +654,15 @@ These are standard MeshCore commands, not MQTT-specific, but important for obser
|
||||
- `set bridge.source rx|tx` - Set packet source (rx for received, tx for transmitted)
|
||||
- `set bridge.enabled on|off` - Enable/disable bridge
|
||||
|
||||
> **Note:** `bridge.enabled` is the master switch for the whole bridge system. `bridge.source`
|
||||
> applies to non-MQTT bridges (RS232, ESP-NOW) only -- for MQTT use `mqtt.rx` and `mqtt.tx`,
|
||||
> which control each direction independently.
|
||||
|
||||
### SNMP Commands
|
||||
|
||||
Observer nodes include an optional SNMP v2c agent that exposes radio stats, MQTT
|
||||
connectivity, memory usage, and network information to standard monitoring tools.
|
||||
|
||||
#### Get Commands
|
||||
- `get snmp` - Get SNMP agent status (on/off)
|
||||
- `get snmp.community` - Get SNMP community string
|
||||
@@ -534,7 +671,7 @@ These are standard MeshCore commands, not MQTT-specific, but important for obser
|
||||
- `set snmp on|off` - Enable/disable SNMP agent (restart required)
|
||||
- `set snmp.community <string>` - Set SNMP community string (restart required, default: `public`)
|
||||
|
||||
See [MQTT_SNMP.md](MQTT_SNMP.md) for full SNMP documentation.
|
||||
See [MQTT_SNMP.md](MQTT_SNMP.md) for setup and the full OID reference.
|
||||
|
||||
### Web Configuration Portal
|
||||
|
||||
@@ -616,29 +753,13 @@ Two ways to iterate on observer/WiFi functionality without flashing a device:
|
||||
Backend handler logic is covered by host unit tests under `test/` (`pio test -e
|
||||
native`); see [test/README.md](test/README.md) for the suites and how to run them.
|
||||
|
||||
## Command Architecture
|
||||
|
||||
The CLI commands are organized into two levels:
|
||||
|
||||
### Bridge Commands (`bridge.*`)
|
||||
**Low-level bridge control** - These settings apply to all bridge types (MQTT, RS232, ESP-NOW, etc.):
|
||||
- `bridge.enabled` - Master switch for the entire bridge system
|
||||
- `bridge.source` - Controls which packet events to capture for non-MQTT bridges (RS232, ESP-NOW). For MQTT, use `mqtt.rx` and `mqtt.tx` instead.
|
||||
|
||||
### Bridge-Specific Commands (`mqtt.*`, `mqttN.*`, `wifi.*`, `timezone.*`)
|
||||
**Implementation-specific settings** - These only apply to the MQTT bridge:
|
||||
- `mqtt.rx` / `mqtt.tx` - Independent per-direction packet uplinking control
|
||||
- `mqttN.*` - Per-slot MQTT broker configuration (N = 1-6)
|
||||
- `mqtt.*` - Shared MQTT settings (message types, origin, IATA, etc.)
|
||||
- `wifi.*` - WiFi connection settings for MQTT connectivity
|
||||
- `timezone.*` - Timezone configuration for accurate timestamps
|
||||
|
||||
## MQTT Topics
|
||||
|
||||
The bridge publishes to three main topics with the following structure:
|
||||
The bridge publishes to four main topics with the following structure:
|
||||
|
||||
### Status Topic: `meshcore/{IATA}/{DEVICE_PUBLIC_KEY}/status`
|
||||
Device connection status and metadata (retained messages).
|
||||
Device connection status and metadata, QoS 1. Retained, except on presets whose broker rejects the retain flag (`meshrank`, `waev`).
|
||||
|
||||
### Packets Topic: `meshcore/{IATA}/{DEVICE_PUBLIC_KEY}/packets`
|
||||
Full packet data with RF characteristics and metadata.
|
||||
@@ -647,12 +768,15 @@ Full packet data with RF characteristics and metadata.
|
||||
Minimal raw packet data for map integration.
|
||||
|
||||
### Neighbors Topic: `meshcore/{IATA}/{DEVICE_PUBLIC_KEY}/neighbors`
|
||||
Periodic snapshot of this node's zero-hop neighbor table plus each neighbor's
|
||||
region scopes (PSRAM boards only; disabled by default). Published non-retained at
|
||||
QoS 1 on the interval set by `mqtt.neighbors.interval` (12-336 h, default 24 h).
|
||||
Like status/raw, this topic is **not** sent to MeshRank slots (packets-only contract).
|
||||
Cached zero-hop repeater neighbors with SNR, last-heard age, and flood-allowed scopes. Published on `discover.scopes` or periodically when `mqtt.neighbors` is enabled (observer builds with neighbors compiled in; non-PSRAM builds cap the table at 20 entries and set `truncated`). Goes to every configured slot's `neighbors` topic at QoS 0, retained only where the preset allows it.
|
||||
|
||||
**Note**: `{DEVICE_PUBLIC_KEY}` is the device's public key in hexadecimal format (64 characters).
|
||||
Periodic publishing first runs a 60-second zero-hop neighbor refresh equivalent to `discover.neighbors`, then queries the refreshed table for scopes and publishes when the scope-query phase completes.
|
||||
|
||||
Manual `discover.scopes` normally queries the current cache in one shot. If a `discover.neighbors` refresh is already collecting responses -- whether started from the CLI or by the periodic timer -- the scope queries are queued behind its 60-second window instead, so they run against the refreshed table. The reply reports the wait, e.g. `OK - scopes queued (47s discovery remaining)`. A queued one-shot request survives `set mqtt.neighbors off`; only the periodic timer's own refresh is cancelled by it.
|
||||
|
||||
While `mqtt.neighbors` is on, `get mqtt.status` appends `nbr: <next>/<last>` -- time to the next automatic publish (`3h12m`, `12m`, `45s`, or `active`/`due`) and the last publish result (`ok`, `failed`, or `none`).
|
||||
|
||||
**Note**: `{DEVICE_PUBLIC_KEY}` is the device's public key in hexadecimal format (64 characters). MeshRank slots use `meshrank/uplink/{token}/{DEVICE_PUBLIC_KEY}/neighbors` instead.
|
||||
|
||||
## JSON Message Formats
|
||||
|
||||
@@ -730,13 +854,16 @@ Like status/raw, this topic is **not** sent to MeshRank slots (packets-only cont
|
||||
}
|
||||
```
|
||||
|
||||
### Neighbors Message
|
||||
### Neighbors Message (neighbors-enabled observer builds)
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-01T12:00:00.000000+00:00",
|
||||
"origin": "MeshCore-HOWL",
|
||||
"origin_id": "A1B2C3D4E5F67890...",
|
||||
"self": { "scopes": "DEN,APRS" },
|
||||
"total_neighbors": 2,
|
||||
"queried_neighbors": 2,
|
||||
"truncated": false,
|
||||
"self": { "scopes": "DEN,APRS", "default_scope": "*" },
|
||||
"neighbors": [
|
||||
{
|
||||
"pubkey": "0011223344556677...",
|
||||
@@ -744,39 +871,58 @@ Like status/raw, this topic is **not** sent to MeshRank slots (packets-only cont
|
||||
"heard_secs_ago": 42,
|
||||
"scopes": "DEN,APRS",
|
||||
"status": "responded"
|
||||
},
|
||||
{
|
||||
"pubkey": "8899AABBCCDDEEFF...",
|
||||
"snr": 12.5,
|
||||
"heard_secs_ago": null,
|
||||
"scopes": "DEN",
|
||||
"status": "responded"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Entries are ordered most- to least-useful (most recently heard, then stronger
|
||||
SNR); the tail is dropped if the payload would exceed the 10 KB publish buffer.
|
||||
`status` is `responded`, `timeout`, or `send_failed` per neighbor.
|
||||
Entries are ordered most- to least-useful (usable age first, then most recently
|
||||
heard, then stronger SNR); the tail is dropped if the payload would exceed the
|
||||
10 KB publish buffer. `status` is `responded`, `timeout`, or `send_failed` per
|
||||
neighbor.
|
||||
|
||||
`total_neighbors` is the size of the neighbor-table snapshot the cycle started
|
||||
from, `queried_neighbors` how many scope requests were confirmed transmitted, and
|
||||
`truncated` whether the buffer filled before every entry fit. All three are
|
||||
always present. The `neighbors` array can therefore be shorter than
|
||||
`total_neighbors` -- compare its length against that field rather than assuming
|
||||
the table is complete.
|
||||
|
||||
`heard_secs_ago` is `null`, as in the second entry above, when the age cannot be
|
||||
computed: the neighbor was last heard before the clock was set, so the stored
|
||||
stamp and the current clock come from different epochs and their difference is
|
||||
meaningless (see `UPSTREAM_BUGS.md` #1). **Consumers must treat `null` as
|
||||
unknown, not as zero** -- the key is always present, so a missing key means an
|
||||
older firmware, and a `null` never means "heard just now". A neighbor that
|
||||
answers the scope query has its stamp refreshed, so a `null` age normally clears
|
||||
itself on the next publish cycle.
|
||||
|
||||
`self.default_scope` is the region name this node floods to by default (`region
|
||||
default`); it is `*` when no default region is set, matching the unscoped flood
|
||||
the radio actually performs in that case.
|
||||
|
||||
## Key Features
|
||||
|
||||
### Slot-Based Preset System
|
||||
- Up to 6 concurrent MQTT connections (with PSRAM), 2 without PSRAM
|
||||
- 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)
|
||||
- Automatic reconnection with exponential backoff per slot
|
||||
- Circuit breaker pattern with periodic probes for recovery from prolonged outages
|
||||
- JWT token buffers only allocated for JWT-auth slots (memory efficient)
|
||||
- Deferred construction: MQTTBridge is heap-allocated in `begin()` to avoid ESP32 static init crashes
|
||||
### Connection Handling
|
||||
- Automatic reconnection with exponential backoff per slot; a slot that stays down through
|
||||
the full backoff ladder is retried on a slow periodic probe instead of hammering the broker
|
||||
- Packets are queued while a slot is disconnected and flushed when it recovers
|
||||
|
||||
### Raw Radio Data Capture
|
||||
- Captures actual raw radio transmission data (including radio headers)
|
||||
- Uses proper MeshCore packet hashing (SHA256-based)
|
||||
- Provides accurate SNR/RSSI values from actual radio reception (RX packets only)
|
||||
- Independent RX and TX packet uplinking - both can be active simultaneously
|
||||
- TX advert mode: selectively uplink only this node's own advert packets
|
||||
|
||||
### Timezone Support
|
||||
- Full timezone support with automatic DST handling
|
||||
- Supports IANA timezone strings, common abbreviations, and UTC offsets
|
||||
- Separates local time (for timestamps) and UTC time (for time/date fields)
|
||||
- Uses JChristensen/Timezone library for accurate timezone conversions
|
||||
- Accepts IANA timezone strings, common abbreviations, and UTC offsets, with automatic DST handling
|
||||
- Note: all published MQTT timestamps are UTC regardless of the configured timezone
|
||||
|
||||
### WiFi Configuration
|
||||
- Runtime WiFi credential management via CLI
|
||||
@@ -794,7 +940,7 @@ SNR); the tail is dropped if the payload would exceed the 10 KB publish buffer.
|
||||
- Proper UTC system time handling
|
||||
|
||||
### Authentication
|
||||
The auth mode is fixed per preset (see the [preset table](#slot-based-preset-system)). Three modes are used:
|
||||
The auth mode is fixed per preset (see [Broker Presets](#broker-presets)). 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.
|
||||
@@ -803,90 +949,22 @@ The auth mode is fixed per preset (see the [preset table](#slot-based-preset-sys
|
||||
|
||||
## Migration from Old Configuration
|
||||
|
||||
When upgrading from a firmware version that used the old MQTT configuration format (`mqtt.analyzer.us`, `mqtt.analyzer.eu`, `mqtt.server`, `mqtt.port`, `mqtt.username`, `mqtt.password`), the device automatically migrates settings:
|
||||
Upgrading from firmware that used an older settings layout -- including the pre-slot format
|
||||
(`mqtt.analyzer.us`, `mqtt.server`, ...) -- needs no manual intervention: the device converts
|
||||
its stored configuration on the first boot after the update and keeps your brokers, origin,
|
||||
IATA, message types, WiFi, and timezone. Verify with `get mqtt.status` afterwards.
|
||||
|
||||
- `mqtt.analyzer.us = on` -> Slot 1 preset: `analyzer-us`
|
||||
- `mqtt.analyzer.eu = on` -> Slot 2 preset: `analyzer-eu`
|
||||
- Custom server configured -> Slot 3 preset: `custom` with host/port/username/password preserved
|
||||
- All other settings (origin, IATA, message types, WiFi, timezone) are preserved as-is
|
||||
|
||||
The migration happens automatically on first boot after firmware update. No manual intervention is needed.
|
||||
The one exception is firmware old enough to predate the separate observer settings file: on
|
||||
that upgrade path the MQTT slot and WiFi configuration cannot be recovered and must be
|
||||
re-entered. For the per-format details, see
|
||||
[MQTT_INTERNALS.md](MQTT_INTERNALS.md#settings-upgrade--migration).
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
### Prerequisites
|
||||
- MeshCore device with observer MQTT firmware flashed
|
||||
- WiFi network credentials
|
||||
- Serial console access (115200 baud) or repeater login via companion app
|
||||
|
||||
### Step 1: Configure Radio (after fresh flash/full erase)
|
||||
|
||||
If this is a fresh flash, radio parameters must be set to match your mesh network:
|
||||
```
|
||||
set radio 910.525,62.5,7,5
|
||||
set tx 22
|
||||
```
|
||||
|
||||
### Step 2: Configure Device Identity
|
||||
```
|
||||
set name MyObserver
|
||||
set mqtt.iata SEA
|
||||
```
|
||||
|
||||
If migrating from an existing device, restore the private key to keep the same identity:
|
||||
```
|
||||
set prv.key <your_64_hex_char_private_key>
|
||||
```
|
||||
|
||||
### Step 3: Configure WiFi
|
||||
|
||||
Use the rest of the line as the value (spaces allowed; no quotes). See [WiFi Commands](#wifi-commands).
|
||||
|
||||
```
|
||||
set wifi.ssid YourWiFiNetwork
|
||||
set wifi.pwd YourWiFiPassword
|
||||
reboot
|
||||
```
|
||||
|
||||
### Step 4: Configure Timezone (optional)
|
||||
```
|
||||
set timezone America/New_York
|
||||
```
|
||||
|
||||
Or use an offset as a fallback:
|
||||
```
|
||||
set timezone.offset -5
|
||||
```
|
||||
|
||||
### Step 5: (Optional) Disable Repeating
|
||||
|
||||
For receive-only observers (e.g., using a PCB antenna or in a location where repeating is not desired):
|
||||
```
|
||||
set repeat off
|
||||
```
|
||||
|
||||
### Step 6: Verify Slot Configuration
|
||||
```
|
||||
get mqtt1.preset # Should show: analyzer-us
|
||||
get mqtt2.preset # Should show: analyzer-eu
|
||||
get mqtt3.preset # Should show: none
|
||||
```
|
||||
|
||||
### Step 7: (Optional) Add Additional Presets
|
||||
```
|
||||
set mqtt3.preset meshmapper
|
||||
```
|
||||
|
||||
### Step 8: Verify Connection
|
||||
```
|
||||
get bridge.enabled
|
||||
get mqtt.rx
|
||||
get mqtt.tx
|
||||
get mqtt.status
|
||||
get wifi.status
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
## Troubleshooting
|
||||
|
||||
#### Device Won't Connect to WiFi
|
||||
```
|
||||
@@ -913,15 +991,10 @@ get mqtt.iata # IATA must be set for MeshCore-topic presets (e.g. A
|
||||
#### Timezone Issues
|
||||
```
|
||||
get timezone
|
||||
set timezone America/New_York # IANA format
|
||||
set timezone EST # Abbreviation
|
||||
set timezone UTC-5 # UTC offset
|
||||
get timezone.offset
|
||||
```
|
||||
|
||||
## SNMP Monitoring
|
||||
|
||||
Observer nodes include an optional SNMP v2c agent that exposes radio stats, MQTT connectivity, memory usage, and network information to standard monitoring tools. See [MQTT_SNMP.md](MQTT_SNMP.md) for setup and OID reference.
|
||||
|
||||
See [Supported Timezone Formats](#supported-timezone-formats) for the accepted values.
|
||||
Note that published timestamps are UTC regardless of this setting.
|
||||
|
||||
## Fault Alerts
|
||||
|
||||
|
||||
+131
-11
@@ -69,17 +69,33 @@ defined in `MQTTBridge.h`). It spans two subsystems and two cores:
|
||||
(`startNeighborDiscover`) fires one anon-regions scope query per heard neighbor,
|
||||
overlaying them onto the peer-index space at `NEIGHBOR_DISCOVER_PEER_BASE` so their
|
||||
`PAYLOAD_TYPE_RESPONSE` packets decrypt via `searchPeersByHash`/`getPeerSharedSecret`/
|
||||
`onPeerDataRecv` even when the neighbor is not an ACL client. After all responses land
|
||||
or a 30 s window expires, `finishNeighborDiscover()` builds the JSON with
|
||||
`MQTTMessageBuilder::buildNeighborsMessage` into a transient PSRAM buffer and hands it
|
||||
to the bridge.
|
||||
- **Bridge side, handoff**: `requestPublishNeighbors(json, len)` (Core 1) memcpys into a
|
||||
persistent ~10 KB PSRAM buffer (`NEIGHBORS_JSON_BUFFER_SIZE`) and sets
|
||||
`onPeerDataRecv` even when the neighbor is not an ACL client. A reply is zero-hop by
|
||||
request, so `handleNeighborDiscoverResponse` also re-stamps `heard_timestamp` in both
|
||||
the snapshot and `neighbours[]` -- proof of reception, and the only thing that heals a
|
||||
stamp taken before the clock was set. After all responses land or a 30 s window
|
||||
expires, `finishNeighborDiscover()` builds the JSON with
|
||||
`MQTTMessageBuilder::buildNeighborsMessage` into a transient buffer (PSRAM where
|
||||
available, internal DRAM otherwise) and hands it to the bridge. The entry table and its
|
||||
hex strings share one heap block sized to the pass -- at `MAX_NEIGHBOURS` they reach
|
||||
~4.5 KB, which does not fit the mesh loop task's 8 KB stack. Ages that still span a clock epoch publish as `null` rather than a
|
||||
fabricated delta (`neighborHeardAgeUsable`, see `UPSTREAM_BUGS.md` #1).
|
||||
- **Buffer sizing**: `NEIGHBORS_JSON_BUFFER_SIZE` is 10 KB with PSRAM and 4 KB without,
|
||||
since a non-PSRAM board pays for the persistent buffer, the transient build buffer and
|
||||
the ArduinoJson pool out of the same internal DRAM each TLS slot needs ~40 KB of
|
||||
(~13 KB peak instead of ~35 KB). The pool has its own budget
|
||||
(`NEIGHBORS_DOC_POOL_BUDGET`) because ArduinoJson v7 hands out pool blocks in fixed
|
||||
4096-byte chunks, so a table that just fits the text buffer can still need well over
|
||||
it in pool -- and a starved pool sets `doc.overflowed()`, which drops the entire publish
|
||||
instead of truncating it. `NEIGHBORS_MAX_PUBLISH_ENTRIES` (20 without PSRAM) keeps the
|
||||
pool inside a single block.
|
||||
- **Bridge side, handoff**: `requestPublishNeighbors(json, len)` (Core 1) memcpys into the
|
||||
persistent buffer (`NEIGHBORS_JSON_BUFFER_SIZE`, PSRAM where available) and sets
|
||||
`_neighbors_publish_pending` with a release store; the MQTT task (`mqttTaskLoop`, Core 0)
|
||||
consumes it with an acquire load, calls `publishNeighbors()`, and clears the flag. A
|
||||
second snapshot is dropped while one is in flight. `publishNeighbors()` sends QoS 1,
|
||||
retain = `preset->allow_retain` (custom slots non-retained). MeshRank slots are skipped
|
||||
(the topic router rejects non-packets for MeshRank).
|
||||
retain = `preset->allow_retain` (custom slots non-retained). MeshRank slots are included,
|
||||
publishing to `meshrank/uplink/{token}/{device}/neighbors` (non-retained, since the
|
||||
preset sets `allow_retain = false`).
|
||||
- **Status reporting**: `MyMesh` reports the schedule each loop via
|
||||
`setNeighborsSchedule(phase, secs)`; `formatMqttStatusReply` renders it as the trailing
|
||||
`nbr: <when>/<last>` field in `get mqtt.status` while the feature is enabled.
|
||||
@@ -88,6 +104,66 @@ The JSON builder lives in the pure, host-tested `MQTTPayloadBuilder`
|
||||
(`test/test_mqtt_payload_builder`); the topic type in `MQTTTopicRouter`
|
||||
(`test/test_mqtt_topic_router`). The mesh<->bridge orchestration above is on-target only.
|
||||
|
||||
### Runtime construction and slot memory
|
||||
|
||||
- **Deferred construction** -- `MQTTBridge` is heap-allocated in each app's `begin()`
|
||||
(`bridge = new MQTTBridge(...)` in `MyMesh.cpp`) rather than held as a static member,
|
||||
because constructing it at static-init time crashes on ESP32 classic.
|
||||
- **Runtime slot array** -- `RUNTIME_MQTT_SLOTS` (`MQTTPresets.h`) is 6 with PSRAM and 3
|
||||
without, saving ~1.2 KB of heap on non-PSRAM boards. `MAX_MQTT_SLOTS` stays 6 on every
|
||||
build because it fixes the persisted `MQTTPrefs` layout, so slot config survives moving
|
||||
firmware between board classes. Three runtime slots suffice without PSRAM:
|
||||
`_max_active_slots` caps those boards at 2 live connections, leaving one spare for
|
||||
reconfiguration. Configured slots past the cap report `(inactive)`.
|
||||
- **Buffers** -- the 768-byte JWT `auth_token` is inline in every `MQTTSlot`, not allocated
|
||||
per JWT-auth slot. What varies is the MQTT client's TX/RX buffer: 896 bytes (the minimum
|
||||
that fits a CONNECT plus a 768-byte JWT) uniformly on PSRAM boards to limit
|
||||
fragmentation from mixed allocations, and 896 or 512 per slot on non-PSRAM boards so
|
||||
non-JWT slots leave smaller holes across teardown/recreate cycles. The large
|
||||
JSON/raw-packet buffers go through `psram_malloc()`, which prefers PSRAM and falls back
|
||||
to internal heap.
|
||||
|
||||
### Reconnection, backoff, and circuit breaker
|
||||
|
||||
The client's own auto-reconnect is disabled (`setAutoReconnect(false)`); the bridge drives
|
||||
reconnection per slot.
|
||||
|
||||
- Backoff ladder: 10 s -> 30 s -> 60 s -> 120 s -> 300 s, staggered by 3 s x slot index so
|
||||
slots don't all handshake at once.
|
||||
- The ladder resets only after a connection has held for 2 minutes
|
||||
(`BACKOFF_STABLE_RESET_MS`), which is longer than the 75 s keepalive -- a link that can't
|
||||
survive one keepalive round-trip keeps its earned rung instead of hammering TLS
|
||||
handshakes at the 10 s rung. CONNACK alone does not reset it.
|
||||
- After 3 more failures at the top rung (~15 min) the slot's circuit breaker trips and
|
||||
routine reconnects stop. A tripped slot is probed once every 30 minutes (with a fresh
|
||||
JWT where applicable); a successful connect clears the breaker, as does reconfiguring
|
||||
the slot.
|
||||
- Message retransmit timeout is 15 s -- one retry inside esp-mqtt's 30 s outbox expiry,
|
||||
preserving at-least-once delivery while capping duplicates at one.
|
||||
|
||||
### Message building
|
||||
|
||||
- The `hash` field in `packets` messages is MeshCore's own packet hash,
|
||||
`Packet::calculatePacketHash()` -- SHA256 over the payload type and payload (plus
|
||||
`path_len` for TRACE), truncated to `MAX_HASH_SIZE`. It is the same value the dispatcher
|
||||
uses, so uplinked hashes match the mesh.
|
||||
- `score` is recomputed at publish time from the packet's SNR and length via the radio's
|
||||
`packetScore()`, so it matches the value the firmware used on receive.
|
||||
- Timezone: the JChristensen/Timezone object (`_timezone_storage`, inline since the
|
||||
memory-defrag work) is kept current from `timezone_string` via `setRules()`, but
|
||||
`formatIsoTimestampForMqtt()` explicitly ignores it -- every published timestamp, time,
|
||||
and date field is UTC off `gmtime()`, matching Python's
|
||||
`datetime.now(timezone.utc).isoformat()`. The timezone prefs therefore do not affect
|
||||
MQTT message content.
|
||||
|
||||
### Command namespacing
|
||||
|
||||
CLI commands sit at two levels. `bridge.*` is low-level and shared by all bridge types
|
||||
(MQTT, RS232, ESP-NOW): `bridge.enabled` is the master switch, and `bridge.source` selects
|
||||
which packet events non-MQTT bridges capture. The MQTT bridge ignores `bridge.source` in
|
||||
favour of independent `mqtt.rx` / `mqtt.tx` controls. Everything MQTT-specific lives under
|
||||
`mqtt.*` (shared settings), `mqttN.*` (per-slot broker config), `wifi.*`, and `timezone.*`.
|
||||
|
||||
### `/mqtt_prefs` file format
|
||||
|
||||
`/mqtt_prefs` is written with an 8-byte `MQTTPrefsHeader` (`magic`, `version`,
|
||||
@@ -100,11 +176,50 @@ fall back to defaults (no downgrade, no misread). `saveMQTTPrefs()` also refuses
|
||||
write while such a file is present (`_mqtt_prefs_hold`), so a `set` command after a
|
||||
firmware downgrade can't clobber the newer config - observer settings changed in that
|
||||
state simply don't persist. The frozen legacy layouts are pinned with `static_assert`s
|
||||
in `CommonCLI.h`, so every target build re-verifies the fleet's file offsets.
|
||||
in `MQTTPrefsStorage.h`, so every target build re-verifies the fleet's file offsets.
|
||||
|
||||
Adding a field to the current version stays backward compatible: append it to the end
|
||||
of `MQTTPrefs`. An older, shorter payload still loads and the missing tail keeps its
|
||||
default; a newer, longer one is truncated harmlessly.
|
||||
of `MQTTPrefs`, give the older exact payload length an explicit decoder boundary, and
|
||||
leave the missing tail at its default. The packet-filter addition follows that rule:
|
||||
the prior 2864-byte v1 payload loads with all six filters set to `all`, while the
|
||||
full payload is 2876 bytes.
|
||||
|
||||
#### The downgrade contract
|
||||
|
||||
**Within a version tag the layout is append-only, and a longer payload is always
|
||||
readable.** A file written by a later build starts with this binary's exact baseline,
|
||||
so `classify()` reads that prefix and ignores the tail. A downgraded node keeps its
|
||||
WiFi credentials, broker slots, and every other setting it understands; the only thing
|
||||
it loses is the settings the newer build added.
|
||||
|
||||
That asymmetry is the whole point. Refusing the file costs the operator the network
|
||||
itself -- `/mqtt_prefs` holds `wifi_ssid`/`wifi_password` as well as the broker config,
|
||||
so a node that falls back to defaults has no WiFi, no portal, and no OTA, recoverable
|
||||
only over serial. Reading it costs a feature's settings. Losing later settings is the
|
||||
acceptable half of that trade; losing the node is not.
|
||||
|
||||
The tail survives until something actually writes. `saveMQTTPrefs()` rewrites at this
|
||||
binary's own length, so a rollback that changes no observer setting and is later rolled
|
||||
forward keeps the newer fields intact -- only an explicit `set` while downgraded drops
|
||||
them. The boot log says so when it happens.
|
||||
|
||||
**A change that is not a pure append MUST bump `MQTT_PREFS_VERSION`.** The version
|
||||
check is what makes the rule above safe: a different tag is still refused outright and
|
||||
the file preserved, because the bytes may no longer mean what this binary thinks. Never
|
||||
reorder, resize, or repurpose an existing field within a version.
|
||||
|
||||
Note the rule is only as old as the build that implements it. Firmware already deployed
|
||||
carries the *previous* decoder, which rejects any longer v1 payload -- so rolling back
|
||||
from this build to one shipped before it still falls back to defaults.
|
||||
`MQTTPrefsCodec::payloadLenFor()` covers that gap from the writing side: it returns the
|
||||
shortest length that still round-trips the configuration, so a node keeps writing 2864
|
||||
bytes until a packet filter actually holds something, and clearing the last non-default
|
||||
filter puts it back. That mitigation can be retired once no supported downgrade target
|
||||
predates the contract; the contract itself is the durable half.
|
||||
|
||||
Shorter payloads keep their existing, stricter treatment: a short length must match a
|
||||
boundary that really shipped (`MQTT_PREFS_V1_*_PAYLOAD_SIZE`), because raw prefs have
|
||||
no checksum and an arbitrary short size cannot be trusted to mean anything.
|
||||
|
||||
### Settings upgrade / migration
|
||||
|
||||
@@ -118,6 +233,11 @@ default; a newer, longer one is truncated harmlessly.
|
||||
drops the vestigial `_legacy_*` fields the flex layout carried mid-struct. This is a
|
||||
one-time rewrite; every deployed device performs it on its first boot of versioned
|
||||
firmware, after which all reads take the header path.
|
||||
The pre-slot (`OldMQTTPrefs`) copy maps the old single-broker keys onto slots:
|
||||
`mqtt.analyzer.us = on` maps to slot 1 `analyzer-us`, `mqtt.analyzer.eu = on` maps to slot 2
|
||||
`analyzer-eu`, and a configured `mqtt.server` / `mqtt.port` / `mqtt.username` /
|
||||
`mqtt.password` maps to slot 3 `custom` with those values preserved. Origin, IATA, message
|
||||
types, WiFi, and timezone carry over as-is.
|
||||
- **`/com_prefs`** - a file written by fork firmware that predates the `MQTTPrefs` split
|
||||
(a zero-filled MQTT gap plus a trailing observer block) is detected by size; the
|
||||
trailing SNMP / radio-watchdog / fault-alert settings and the `rx_boosted_gain` /
|
||||
|
||||
@@ -7,7 +7,9 @@ MeshCore is a lightweight, portable C++ library that enables multi-hop packet ro
|
||||
MeshCore now supports a range of LoRa devices, allowing for easy flashing without the need to compile firmware manually. Users can flash a pre-built binary using tools like Adafruit ESPTool and interact with the network through a serial console.
|
||||
MeshCore provides the ability to create wireless mesh networks, similar to Meshtastic and Reticulum but with a focus on lightweight multi-hop packet routing for embedded projects. Unlike Meshtastic, which is tailored for casual LoRa communication, or Reticulum, which offers advanced networking, MeshCore balances simplicity with scalability, making it ideal for custom embedded solutions, where devices (nodes) can communicate over long distances by relaying messages through intermediate nodes. This is especially useful in off-grid, emergency, or tactical situations where traditional communication infrastructure is unavailable.
|
||||
|
||||
> **WiFi and MQTT** - See [WiFi and MQTT by Firmware Type](./docs/WiFi.md) for the
|
||||
> **WiFi and MQTT** - Prebuilt observer firmware, release notes, and browser-based
|
||||
> flashing are available at [observer.gessaman.com](https://observer.gessaman.com/).
|
||||
> See [WiFi and MQTT by Firmware Type](./docs/WiFi.md) for the
|
||||
> role/build matrix and setup overview. For the complete MQTT command and
|
||||
> broker reference, see the [MQTT Implementation Guide](./MQTT_IMPLEMENTATION.md).
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ index.
|
||||
| -- | OTA teardown barrier | Implemented -- flash gated on a clean MQTT stop in `simple_repeater`; not hardware-validated |
|
||||
| 6 | Request/queue/connection/publication integration tests | Partial: WiFi-backoff + publish-outcome + enum-alignment gaps extracted and host-tested; WebConfig batch/reboot/stop spec (`WebConfigBatch.h`) **now wired into `WebConfigServer.cpp`** (2026-07-19) so the host tests cover production; queue-orchestration coverage still open, and the wired path is not yet exercised over real HTTP |
|
||||
| 7 | Uptime, memory, and fault-injection gates | Representative HW matrix run 2026-07-19: V3 non-PSRAM + V4 PSRAM done (no leak/crash; forced-path OTA-withhold + ~15-27 s loop stall observed). Multi-day soak + stack-HWM build pending |
|
||||
| -- | Non-PSRAM neighbors publication | Enabled on the ESP32-S3 non-PSRAM observer envs via `MQTT_NEIGHBORS_WITHOUT_PSRAM` (`feat/non-psram-neighbors`). Bench-verified 2026-08-03 at the 2-wss-slot non-PSRAM maximum (see "Hardware Characterization -- Non-PSRAM Neighbors"). Found and fixed a pre-existing PSRAM bug on the dev/beta channel: the JSON pool budget starved at ~40+ neighbours and dropped the whole publish (`34037f20`). Production is on ArduinoJson v6 and unaffected. Truncation above 20 neighbours is still unverified on hardware. |
|
||||
| -- | Upstream merge (latest) | `upstream/dev` `9d902e63` merged 2026-08-03 as `126a2564`: 13 commits, 6 files, zero conflicts. Carries an LR1110 RX-timeout fix affecting the ThinkNode M7 observer envs and switches all nRF52 boards to CC310 hardware Ed25519. See "Upstream Merge Record -- 2026-08-03". It exposed that nRF52/RP2040 had been unbuildable since 2026-04-10; nRF52 was fixed in `45379ad7`, while RP2040 remained open. |
|
||||
| -- | Upstream merge | `upstream/dev` merged 2026-07-19 on `observer-firmware-dev` (191 commits, 14 conflicted files). See "Upstream Merge Record". Not yet promoted to `webconfig` |
|
||||
| -- | Dev release channel | `observer-firmware-dev` publishes the dev/beta firmware channel (see "Release Channels"). Manual dispatch; separate from production |
|
||||
|
||||
@@ -199,6 +201,86 @@ exceeds 8 s -> forced/dirty):
|
||||
time injection and were not driven over serial.
|
||||
- Both devices left restored to their original slot config with the bridge on.
|
||||
|
||||
## Hardware Characterization -- Non-PSRAM Neighbors (2026-08-03)
|
||||
|
||||
Records the bench verification of neighbors publication on a non-PSRAM board
|
||||
(branch `feat/non-psram-neighbors`). Previously the feature was gated on
|
||||
`BOARD_HAS_PSRAM`; it is now available on the ESP32-S3 non-PSRAM observer envs
|
||||
via the per-variant `MQTT_NEIGHBORS_WITHOUT_PSRAM` opt-in.
|
||||
|
||||
### Setup
|
||||
|
||||
- Non-PSRAM ESP32-S3 observer node (`memory` reports `PSRAM: 0/0`); one of the
|
||||
newly enabled envs -- exact board not recorded. Origin `MQTT Observer 63`.
|
||||
- **2 active preset slots, both connected**: `meshmapper` and `waev`, both
|
||||
`wss://` -- i.e. the documented non-PSRAM design maximum of 2 TLS/WSS slots.
|
||||
- Driven over the serial CLI (`memory`, `neighbors`, `get mqtt.status`) with the
|
||||
published payload captured off the broker's `neighbors` topic.
|
||||
|
||||
### Result -- publishes cleanly at the 2-slot maximum
|
||||
|
||||
| Signal | Observed | Note |
|
||||
|--------|----------|------|
|
||||
| `get mqtt.status` | `nbr: 23h54m/ok` | periodic publish succeeded (`NBR_RESULT_OK`) |
|
||||
| Free / min-ever internal heap | 70136 / **53776** | worst witnessed free still ~53.8 KB |
|
||||
| Largest contiguous block | 55284 | vs a 4096 B largest single request |
|
||||
| Payload size | **1252 B of 4096** (31%) | 6 neighbours, 272 B base |
|
||||
| `total` / `queried` / `truncated` | 6 / 6 / `false` | self-consistent with array length |
|
||||
|
||||
Publish peak is **near entry-count-independent** below the cap, because three of
|
||||
the four allocations are fixed size: persistent buffer 4096 + transient build
|
||||
buffer 4096 + one 4096 ArduinoJson pool block, plus only 89 B/entry of scratch.
|
||||
So ~13.0 KB at 6 entries vs ~14.3 KB at the 20-entry cap -- this run already
|
||||
covered most of the worst-case peak.
|
||||
|
||||
Correctness cross-checks, all passing: every pubkey distinct and correctly
|
||||
prefixed (validates the shared-scratch `&pubkey_hex[i * hex_size]` indexing that
|
||||
replaced the old stack arrays); all six SNRs match the `neighbors` CLI raw x4
|
||||
values (29->7.25, 50->12.5, 48->12, 42->10.5, 46->11.5, 48->12); ordering newest-first
|
||||
(0/1/2/4/11/398 s); the 398 s non-responder rendered `scopes: ""` +
|
||||
`status: "timeout"` rather than a fabricated "responded"; `heard_secs_ago: 0` on
|
||||
the zero-hop responder confirms `neighborHeardAgeUsable` is not misfiring into
|
||||
the `null` path.
|
||||
|
||||
**Capacity for this mesh** (scopes ranged `""`/`"*"` up to 49 chars): the 4 KB
|
||||
text budget admits 22 entries, so the 20-entry
|
||||
`NEIGHBORS_MAX_PUBLISH_ENTRIES` cap binds first -- as designed, keeping the pool
|
||||
to a single block. No retuning needed.
|
||||
|
||||
### Pre-existing bug found while verifying -- dev/beta channel only
|
||||
|
||||
Sizing the ArduinoJson pool budget at `NEIGHBORS_JSON_BUFFER_SIZE` starved it:
|
||||
v7 hands out pool blocks in fixed 4096-byte chunks, so a 50-entry table needs
|
||||
12541 B of pool against the 10240 B cap. A starved pool sets `doc.overflowed()`,
|
||||
which makes `buildNeighborsMessage` return 0 and drop the **entire** publish
|
||||
rather than truncating. PSRAM repeaters running a `observer-firmware-dev` build
|
||||
with roughly 40+ neighbours were therefore publishing no neighbors message at
|
||||
all, silently. Fixed in `34037f20` with a separate `NEIGHBORS_DOC_POOL_BUDGET`.
|
||||
|
||||
**Production (`observer-firmware`) is NOT affected, so this does not need a
|
||||
hand-port.** The failure requires ArduinoJson v7's fixed 4096-byte pool blocks
|
||||
plus dev's custom budget-capped `NeighborsDocAllocator`. Prod is still on v6 --
|
||||
`MQTTMessageBuilder.cpp` uses `createNestedArray`, removed in 7.0 -- where
|
||||
`DynamicJsonDocument(10240)` is a real compact slot pool needing only ~3.8 KB
|
||||
for 50 entries. **Action:** check `get mqtt.status` for `nbr: <next>/fail` only
|
||||
on dev/beta-channel PSRAM nodes.
|
||||
|
||||
Noted in passing: prod's `platformio.ini` has **no ArduinoJson pin** (it resolves
|
||||
transitively), so a clean dependency resolve could pull v7 and fail to compile
|
||||
the v6-only API. That is the same gap Phase 1's "ArduinoJson pin enforcement"
|
||||
covers on dev.
|
||||
|
||||
### Limitations / not covered
|
||||
|
||||
- **The truncation path is untested on hardware.** This site has 6 neighbours;
|
||||
`truncated: true` with `total_neighbors` exceeding the array length needs a
|
||||
site with >20. The arithmetic is host-verified against the real builder.
|
||||
- Both slots were steady-connected throughout, so a publish coinciding with a
|
||||
**slot reconnect** -- the case the reduced buffer sizing exists for -- was not
|
||||
driven. That is the remaining memory-pressure scenario.
|
||||
- Single bench run; no multi-day soak, and no stack high-water-mark build. The
|
||||
4752->304 byte stack-frame reduction was verified by disassembly, not at runtime.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
The current branch has:
|
||||
@@ -763,6 +845,75 @@ deliberately share `FIRMWARE_VERSION`: the OTA logic treats a differing base
|
||||
version as "always an update", so channels must separate by manifest URL, never
|
||||
by base version.
|
||||
|
||||
## Upstream Merge Record -- 2026-08-03 (`observer-firmware-dev`)
|
||||
|
||||
`upstream/dev` `9d902e63` merged into `observer-firmware-dev` as `126a2564`,
|
||||
starting from `db232808` (the 2026-07-30 merge). **13 upstream commits, 6 files,
|
||||
zero conflicts** -- no `rerere` resolutions needed. This is what merging
|
||||
frequently buys: the whole merge is attributable at a glance.
|
||||
|
||||
Baseline before and after, on the two named smoke builds, is **byte-identical**:
|
||||
|
||||
| Target | RAM | Flash |
|
||||
|--------|-----|-------|
|
||||
| `Heltec_v3_repeater_observer_mqtt` | 74656 -> 74656 | 1592513 -> 1592513 |
|
||||
| `T_Beam_S3_Supreme_SX1262_repeater_observer_mqtt` | 71780 -> 71780 | 1596977 -> 1596977 |
|
||||
|
||||
Native suite 267/267 before and after. The nRF52 crypto below is fully
|
||||
`#ifdef`-guarded, which is why ESP32 output does not move a byte.
|
||||
|
||||
### The finding that matters most: the smoke pair is blind to this merge
|
||||
|
||||
Both named smoke builds are ESP32; this merge is entirely nRF52 crypto and
|
||||
LR1110 radio. A green smoke run said nothing about any of it. Targets that
|
||||
actually exercise the change had to be chosen by hand.
|
||||
|
||||
**LR1110 RX fix -- this is the one that reaches shipped hardware.**
|
||||
`CustomLR1110::startReceive()` was passing `RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED`
|
||||
(`0x01 << 4` = 16) as the **RX timeout** argument instead of
|
||||
`RADIOLIB_LR11X0_RX_TIMEOUT_INF` (`0xFFFFFF`, RX continuous). At the LR11x0's
|
||||
1/32768 s tick, that is a ~0.49 ms receive window rather than continuous receive.
|
||||
Fork LR1110 variants: `thinknode_m7`, `thinknode_m3`, `thinknode_m9`,
|
||||
`wio_wm1110`, `t1000-e`, `minewsemi_me25ls01` -- including the ThinkNode M7
|
||||
observer envs added in `37444be7`. Confirmed the fix compiles into a shipped
|
||||
artifact: `ThinkNode_M7_repeater_observer_mqtt` flash 1554917 -> 1554921 (+4 B).
|
||||
Constants and arithmetic verified; the practical RX improvement is **inferred,
|
||||
not measured** -- worth a before/after on M7 hardware.
|
||||
|
||||
**All nRF52 boards silently switched to CC310 hardware Ed25519.** Upstream
|
||||
`783b21bb` moved `USE_CC310_HW_CRYPTO=1` from 4 individual variants into
|
||||
`nrf52_base`. Pre-merge no fork nRF52 build had it (the fork's copies of those 4
|
||||
variants already lacked the flag); post-merge all 35 nRF52 variants do.
|
||||
`Identity::verify()` now runs on CryptoCell. Upstream's rationale is sound -- the
|
||||
software path needs ~3 KB of stack and can overflow the Adafruit core's 4 KB loop
|
||||
task from the advert receive path, versus ~600-700 B for hardware. Note it uses a
|
||||
**`static CRYS_ECEDW_TempBuff_t` workspace**, i.e. shared mutable state that
|
||||
assumes `verify()` is never called concurrently. Disable per-board with
|
||||
`-U USE_CC310_HW_CRYPTO` if a board proves quirky.
|
||||
|
||||
### Verification
|
||||
|
||||
- ESP32: both smoke builds plus `ThinkNode_M7_repeater_observer_mqtt` (LR1110)
|
||||
and `Heltec_v3_room_server_observer_mqtt`. All green, sizes unmoved.
|
||||
- Native: 267/267.
|
||||
- nRF52: **not buildable at merge time.** Every nRF52/RP2040 target had been
|
||||
broken since `7e4f75c9` (2026-04-10) by observer-only dependencies leaking into
|
||||
shared files -- confirmed pre-existing by building `RAK_4631_repeater` at the
|
||||
pre-merge commit. Fixed immediately after in `45379ad7`, after which RAK4631
|
||||
(both roles), Heltec T114, T1000-E, Wio WM1110, Xiao nRF52 and ThinkNode M1 all
|
||||
build. The incoming CC310 path therefore compiles, though it is still untested
|
||||
on nRF52 hardware.
|
||||
- RP2040 remains broken for an unrelated reason: `PicoWBoard`, `WaveshareBoard`,
|
||||
`XiaoRP2040Board` and `RAK11310Board` still declare the pre-`force_ap`
|
||||
`startOTAUpdate` signature and no longer override the base.
|
||||
|
||||
### Recommendation
|
||||
|
||||
Add an LR1110 target (`ThinkNode_M7_repeater_observer_mqtt`) and one nRF52
|
||||
target to the PR-CI smoke set. The current ESP32-only pair cannot see radio or
|
||||
platform-crypto changes, and it did not notice that nRF52 had been unbuildable
|
||||
for nearly four months.
|
||||
|
||||
## Upstream Merge Record -- 2026-07-19 (`observer-firmware-dev`)
|
||||
|
||||
First `upstream/dev` merge since the v1.16.0 base (`8c0d5c5b`, 2026-06-06):
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Upstream Bug Candidates
|
||||
|
||||
Bugs in code this fork inherits from upstream (`meshcore-dev/dev`), each with the
|
||||
fix we would propose upstream. Any fork-local workaround is recorded so it can be
|
||||
dropped once upstream lands a real fix.
|
||||
|
||||
Status: **open** (not reported) * **reported** (issue/PR filed) * **landed**
|
||||
(upstream fixed, fork workaround removable).
|
||||
|
||||
---
|
||||
|
||||
## 1. Neighbour "heard" ages are derived from a steppable wall clock
|
||||
|
||||
**Status:** open
|
||||
**Files:** `examples/simple_repeater/MyMesh.cpp`, `examples/simple_room_server/MyMesh.cpp`
|
||||
**Impact:** wrong ages reported to companion apps, CLI, and (in this fork) MQTT. No crash.
|
||||
|
||||
### Symptom
|
||||
|
||||
A repeater published a neighbour age of 69681995 seconds -- 806 days -- for two of
|
||||
three neighbours that had just answered a live query (capture abridged; see
|
||||
`MQTT_IMPLEMENTATION.md` for the full payload shape):
|
||||
|
||||
```json
|
||||
{"timestamp":"2026-07-30T22:59:09.000000+00:00","total_neighbors":3,
|
||||
"neighbors":[
|
||||
{"pubkey":"0CE5EA7C...","snr":12.5,"heard_secs_ago":57,"status":"responded"},
|
||||
{"pubkey":"B0D17C59...","snr":12.5,"heard_secs_ago":69681995,"status":"responded"},
|
||||
{"pubkey":"D19BEE16...","snr":12.25,"heard_secs_ago":69681995,"status":"responded"}]}
|
||||
```
|
||||
|
||||
### Root cause
|
||||
|
||||
`heard_secs_ago` is a *relative* quantity computed by subtracting two samples of
|
||||
an *absolute* wall clock that can step by years mid-session.
|
||||
|
||||
`putNeighbour()` stamps `heard_timestamp = getRTCClock()->getCurrentTime()` when a
|
||||
zero-hop advert or node-discover reply arrives (`MyMesh.cpp:93`). On a cold boot
|
||||
the radio is receiving within a second or two, while the clock is still at the
|
||||
firmware's unset default -- `1715770351`, 15 May 2024 (`src/helpers/ArduinoHelpers.h`,
|
||||
`src/helpers/ESP32Board.h`). NTP (or an admin over the CLI) then steps the clock
|
||||
forward by years, and every later reader subtracts the two:
|
||||
|
||||
```
|
||||
publish epoch 1785452349 (2026-07-30T22:59:09Z)
|
||||
- reported age 69681995
|
||||
= stored heard_timestamp 1715770354 = unset-clock default + 3 s of uptime
|
||||
```
|
||||
|
||||
So both stale entries were stamped 3 seconds after boot. The entries do not
|
||||
self-heal: `putNeighbour()` is the only writer of `heard_timestamp`, so an entry
|
||||
stays wrong until that specific neighbour is physically heard again. In the capture
|
||||
above the two stale neighbours missed the node-discover refresh (responder-side
|
||||
rate limit `discover_limiter`, a reply collision, or firmware without
|
||||
`CTL_TYPE_NODE_DISCOVER_RESP`) yet still answered a direct query -- hence
|
||||
`"status":"responded"` next to a 2-year age.
|
||||
|
||||
Deferring the *read* until after time sync does not fix this. The bad value is
|
||||
written at packet-reception time, which on any cold boot precedes WiFi and NTP.
|
||||
|
||||
### Affected readers
|
||||
|
||||
| Path | Location |
|
||||
| --- | --- |
|
||||
| Companion `REQ_TYPE_GET_NEIGHBOURS` binary reply | `MyMesh.cpp:404` |
|
||||
| CLI `neighbors` text output | `MyMesh.cpp:1350` |
|
||||
| MQTT neighbors JSON (fork-only) | `MyMesh.cpp:2036` |
|
||||
|
||||
### Proposed upstream fix
|
||||
|
||||
Stamp neighbours from a monotonic source and derive every age from that:
|
||||
|
||||
- add `uint32_t heard_millis` to `NeighbourInfo`, set from `millis()` in
|
||||
`putNeighbour()` alongside the existing wall-clock stamp;
|
||||
- compute every "seconds ago" readout as `(now_millis - heard_millis) / 1000`
|
||||
using the unsigned-subtraction idiom already used by `millisHasNowPassed()` /
|
||||
`futureMillis()`, which is immune to clock steps and stays correct across one
|
||||
32-bit rollover;
|
||||
- clamp the result to a documented maximum so an entry untouched for longer than
|
||||
the 49.7-day `millis()` period cannot alias back to a small age;
|
||||
- keep `heard_timestamp` for anything that needs an absolute instant -- sort order
|
||||
is equivalent under either field.
|
||||
|
||||
Cost: 4 bytes x `MAX_NEIGHBOURS` of RAM.
|
||||
|
||||
### Fork-local containment (in place)
|
||||
|
||||
Not a substitute for the above; it stops the fork from publishing a fabricated
|
||||
number while upstream still stamps from the wall clock.
|
||||
|
||||
- `finishNeighborDiscover()` reports the age as unknown -- `"heard_secs_ago":null`
|
||||
-- when the stored stamp predates `MQTTConnectionPolicy::kSyncedClockEpoch`
|
||||
(2025-01-01, above the unset-clock default) while the current clock is past it,
|
||||
or when the clock has stepped backwards. Previously a backwards clock reported
|
||||
`0`, i.e. "heard just now".
|
||||
- `handleNeighborDiscoverResponse()` re-stamps `heard_timestamp` on a zero-hop
|
||||
scope reply, in both the snapshot and the live table. The reply is proof of
|
||||
reception, so entries now heal once per discovery cycle instead of waiting for
|
||||
the neighbour's next advert. This would have made 2 of the 3 entries above
|
||||
correct.
|
||||
- Publish ordering places known ages before unknown ones, so an unusable stamp
|
||||
cannot masquerade as the freshest entry when the JSON buffer truncates.
|
||||
|
||||
---
|
||||
|
||||
## 2. Neighbour age subtraction is unclamped in the companion and CLI readouts
|
||||
|
||||
**Status:** open
|
||||
**Files:** `examples/simple_repeater/MyMesh.cpp:404`, `:1350` (and the room-server twins)
|
||||
**Impact:** age underflows to ~4.29e9 instead of a sane value.
|
||||
|
||||
Both readers subtract without checking ordering:
|
||||
|
||||
```cpp
|
||||
uint32_t heard_seconds_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp;
|
||||
```
|
||||
|
||||
Any state where `heard_timestamp > now` -- clock stepped backwards by NTP or an
|
||||
admin, or a stamp taken before a backwards step -- wraps the unsigned result to
|
||||
near `UINT32_MAX`. Distinct from #1 in that it is a missing guard rather than the
|
||||
choice of clock: worth fixing on its own even before the monotonic change, by
|
||||
clamping to `0` (or reporting "unknown", which is what the age actually is).
|
||||
+11
-6
@@ -268,9 +268,10 @@ scopes and publishes the assembled table to the MQTT `neighbors` topic once.
|
||||
**Usage:**
|
||||
- `discover.scopes`
|
||||
|
||||
**Note:** Requires a PSRAM board with the MQTT bridge running. On non-PSRAM MQTT
|
||||
builds it replies `Err - not supported (requires PSRAM)`. If a `discover.neighbors`
|
||||
refresh is already in flight, the scope pass is queued behind it.
|
||||
**Note:** Requires an MQTT observer build with the neighbors feature compiled in
|
||||
(all PSRAM boards, plus non-PSRAM boards built with `MQTT_NEIGHBORS_WITHOUT_PSRAM`).
|
||||
Elsewhere it replies `Err - neighbors not enabled in this build`. If a
|
||||
`discover.neighbors` refresh is already in flight, the scope pass is queued behind it.
|
||||
|
||||
---
|
||||
|
||||
@@ -3012,9 +3013,13 @@ sleep, this command schedules a sync and wakes it; after `gps off`, it reports
|
||||
|
||||
**Default:** `off`
|
||||
|
||||
> **Note:** Requires a PSRAM board. On non-PSRAM MQTT builds this replies
|
||||
> `Err - not supported (requires PSRAM)`. The setting is read live by the mesh
|
||||
> loop -- no restart required; enabling it triggers a discovery on the next pass.
|
||||
> **Note:** Requires a build with the neighbors feature compiled in (all PSRAM
|
||||
> boards, plus non-PSRAM boards built with `MQTT_NEIGHBORS_WITHOUT_PSRAM`);
|
||||
> elsewhere this replies `Err - neighbors not enabled in this build`. Non-PSRAM
|
||||
> builds publish at most 20 neighbours per pass to bound internal-DRAM use, and
|
||||
> set `truncated` with the true `total_neighbors` when the table is larger.
|
||||
> The setting is read live by the mesh
|
||||
> loop - no restart required; enabling it triggers a discovery on the next pass.
|
||||
> While enabled, `get mqtt.status` gains a trailing `nbr: <next>/<last>` field
|
||||
> (time to next publish, and how the last publish went).
|
||||
|
||||
|
||||
@@ -1353,7 +1353,6 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
send_unscoped = false;
|
||||
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
_prefs.airtime_factor = 1.0; // one half
|
||||
strcpy(_prefs.node_name, "NONAME");
|
||||
_prefs.freq = LORA_FREQ;
|
||||
@@ -1540,6 +1539,7 @@ void MyMesh::begin(bool has_display) {
|
||||
#endif
|
||||
_webconfig = new WebConfigServer(this, web_mqtt_prefs, false,
|
||||
self_id.pub_key, FIRMWARE_VERSION,
|
||||
FIRMWARE_BUILD_DATE,
|
||||
"companion", board.getManufacturerName());
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <cstdint> // For uint8_t, uint32_t
|
||||
|
||||
#define TELEM_MODE_DENY 0
|
||||
|
||||
+361
-140
@@ -18,6 +18,9 @@
|
||||
#include <sys/types.h>
|
||||
extern "C" caddr_t _sbrk(int increment);
|
||||
#endif
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
#include <helpers/MQTTConnectionPolicy.h> // kSyncedClockEpoch
|
||||
#endif
|
||||
|
||||
/* ------------------------------ Config -------------------------------- */
|
||||
|
||||
@@ -1057,6 +1060,19 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) {
|
||||
}
|
||||
|
||||
void MyMesh::logTx(mesh::Packet *pkt, int len) {
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
if (neighbor_discover_active && pkt == neighbor_discover_request
|
||||
&& neighbor_discover_next < neighbor_discover_count) {
|
||||
NeighborDiscoverEntry& entry = neighbor_discover[neighbor_discover_next];
|
||||
if (entry.status == ND_QUEUED) {
|
||||
entry.status = ND_PENDING;
|
||||
neighbor_discover_queried_count++;
|
||||
neighbor_discover_request = NULL;
|
||||
neighbor_discover_until = futureMillis(neighborDiscoverQueryTimeoutMs());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
// MQTT bridge: always feed TX packets - bridge decides based on mqtt.tx setting
|
||||
if (mqtt_bridge) mqtt_bridge->sendPacket(pkt);
|
||||
@@ -1086,6 +1102,18 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) {
|
||||
}
|
||||
|
||||
void MyMesh::logTxFail(mesh::Packet *pkt, int len) {
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
if (neighbor_discover_active && pkt == neighbor_discover_request
|
||||
&& neighbor_discover_next < neighbor_discover_count) {
|
||||
NeighborDiscoverEntry& entry = neighbor_discover[neighbor_discover_next];
|
||||
if (entry.status == ND_QUEUED) {
|
||||
entry.status = ND_SEND_FAILED;
|
||||
neighbor_discover_request = NULL;
|
||||
neighbor_discover_until = 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (_logging) {
|
||||
File f = openAppend(PACKET_LOG_FILE);
|
||||
if (f) {
|
||||
@@ -2473,9 +2501,9 @@ int MyMesh::searchPeersByHash(const uint8_t *hash) {
|
||||
// distinct from real ACL indices.
|
||||
if (neighbor_discover_active) {
|
||||
for (int i = 0; i < neighbor_discover_count && n < MAX_CLIENTS; i++) {
|
||||
auto& nb = neighbours[neighbor_discover[i].neighbour_idx];
|
||||
if (acl.getClient(nb.id.pub_key, PUB_KEY_SIZE) != nullptr) continue;
|
||||
if (nb.heard_timestamp > 0 && nb.id.isHashMatch(hash)) {
|
||||
auto& entry = neighbor_discover[i];
|
||||
if (acl.getClient(entry.id.pub_key, PUB_KEY_SIZE) != nullptr) continue;
|
||||
if (entry.heard_timestamp > 0 && entry.id.isHashMatch(hash)) {
|
||||
matching_peer_indexes[n++] = NEIGHBOR_DISCOVER_PEER_BASE + i;
|
||||
}
|
||||
}
|
||||
@@ -2496,7 +2524,7 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) {
|
||||
if (neighbor_discover_active && i >= NEIGHBOR_DISCOVER_PEER_BASE) {
|
||||
int oi = i - NEIGHBOR_DISCOVER_PEER_BASE;
|
||||
if (oi >= 0 && oi < neighbor_discover_count) {
|
||||
self_id.calcSharedSecret(dest_secret, neighbours[neighbor_discover[oi].neighbour_idx].id);
|
||||
self_id.calcSharedSecret(dest_secret, neighbor_discover[oi].id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2579,8 +2607,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
|
||||
// scope-query response from it lands here -- match it against the overlay.
|
||||
if (neighbor_discover_active && type == PAYLOAD_TYPE_RESPONSE) {
|
||||
for (int oi = 0; oi < neighbor_discover_count; oi++) {
|
||||
auto& nb = neighbours[neighbor_discover[oi].neighbour_idx];
|
||||
if (client->id.matches(nb.id) && handleNeighborDiscoverResponse(oi, data, len)) {
|
||||
if (client->id.matches(neighbor_discover[oi].id)
|
||||
&& handleNeighborDiscoverResponse(oi, data, len)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3086,12 +3114,20 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
|
||||
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
neighbor_discover_count = 0;
|
||||
neighbor_discover_next = 0;
|
||||
neighbor_discover_publish_count = 0;
|
||||
neighbor_discover_queried_count = 0;
|
||||
neighbor_discover_json_size = 0;
|
||||
neighbor_discover_truncated = false;
|
||||
neighbor_discover_active = false;
|
||||
neighbor_table_refresh_active = false;
|
||||
neighbor_table_refresh_periodic = false;
|
||||
neighbor_discover_until = 0;
|
||||
neighbor_discover_request = NULL;
|
||||
next_neighbors_publish = 0;
|
||||
self_scopes_buf[0] = 0;
|
||||
self_default_scope_buf[0] = 0;
|
||||
neighbor_discover_origin[0] = 0;
|
||||
#endif
|
||||
|
||||
memset(default_scope.key, 0, sizeof(default_scope.key));
|
||||
@@ -9554,7 +9590,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) {
|
||||
owns_wifi = false;
|
||||
#endif
|
||||
_webconfig = new WebConfigServer(this, mqtt_prefs, owns_wifi,
|
||||
self_id.pub_key, getFirmwareVer(), getRole(),
|
||||
self_id.pub_key, getFirmwareVer(), getBuildDate(), getRole(),
|
||||
_cli.getBoard()->getManufacturerName());
|
||||
if (!_webconfig) {
|
||||
strcpy(reply, "Err: not enough memory for webconfig");
|
||||
@@ -9742,16 +9778,21 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
||||
MQTTBridge::SlotStatusSnapshot status;
|
||||
if (!MQTTBridge::getSlotStatusSnapshot(i, &status)) continue;
|
||||
char filter[24] = "";
|
||||
if (status.filter_mask != MQTTPacketFilter::kAllPacketTypes) {
|
||||
snprintf(filter, sizeof(filter), ",\"filt\":%u",
|
||||
(unsigned)status.filter_mask);
|
||||
}
|
||||
int written;
|
||||
if (status.has_publish_counts) {
|
||||
written = snprintf(buf + pos, buf_size - pos,
|
||||
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\",\"ok\":%lu,\"err\":%lu}",
|
||||
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\",\"ok\":%lu,\"err\":%lu%s}",
|
||||
first ? "" : ",", i + 1, status.name, status.state,
|
||||
status.publish_ok, status.publish_err);
|
||||
status.publish_ok, status.publish_err, filter);
|
||||
} else {
|
||||
written = snprintf(buf + pos, buf_size - pos,
|
||||
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\"}",
|
||||
first ? "" : ",", i + 1, status.name, status.state);
|
||||
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\"%s}",
|
||||
first ? "" : ",", i + 1, status.name, status.state, filter);
|
||||
}
|
||||
if (written < 0 || written >= static_cast<int>(buf_size - pos)) break;
|
||||
pos += written;
|
||||
@@ -10757,7 +10798,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
}
|
||||
#elif defined(WITH_MQTT_BRIDGE)
|
||||
} else if (memcmp(command, "discover.scopes", 15) == 0) {
|
||||
strcpy(reply, "Err - not supported (requires PSRAM)");
|
||||
strcpy(reply, "Err - neighbors not enabled in this build");
|
||||
#endif
|
||||
} else{
|
||||
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
|
||||
@@ -11184,7 +11225,11 @@ void MyMesh::getLocalScopes(char* buf, size_t len) {
|
||||
|
||||
// Client side of the anon-regions request (the server side is handleAnonRegionsReq).
|
||||
// Inner payload: {tag(4)}{ANON_REQ_TYPE_REGIONS}{0x00 = zero-hop reply path}.
|
||||
bool MyMesh::sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag) {
|
||||
mesh::Packet* MyMesh::sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag) {
|
||||
// RxReservePacketManager keeps a four-packet emergency floor. Preflight one
|
||||
// extra free packet so its void queue API cannot silently shed this request.
|
||||
if (_mgr->getFreeCount() < NEIGHBOR_DISCOVER_MIN_FREE_PACKETS) return NULL;
|
||||
|
||||
uint8_t secret[PUB_KEY_SIZE];
|
||||
self_id.calcSharedSecret(secret, target);
|
||||
|
||||
@@ -11195,8 +11240,92 @@ bool MyMesh::sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag) {
|
||||
inner[5] = 0x00; // request a zero-hop reply path
|
||||
|
||||
mesh::Packet* pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, target, secret, inner, sizeof(inner));
|
||||
if (!pkt) return false;
|
||||
if (!pkt) return NULL;
|
||||
sendDirect(pkt, NULL, 0, 0);
|
||||
return pkt;
|
||||
}
|
||||
|
||||
bool MyMesh::cancelNeighborDiscoverRequest() {
|
||||
if (!neighbor_discover_request) return false;
|
||||
for (int i = _mgr->getOutboundTotal() - 1; i >= 0; i--) {
|
||||
if (_mgr->getOutboundByIdx(i) == neighbor_discover_request) {
|
||||
mesh::Packet* pkt = _mgr->removeOutboundByIdx(i);
|
||||
if (pkt) releasePacket(pkt);
|
||||
neighbor_discover_request = NULL;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// This timer starts after the request finishes transmitting. Allow the server
|
||||
// delay, the responder's full CAD deferral window plus one maximum retry
|
||||
// overshoot, and airtime for one priority-0 packet ahead of the response plus
|
||||
// the response itself. The radio estimate scales with SF, bandwidth, coding
|
||||
// rate, and preamble.
|
||||
uint32_t MyMesh::neighborDiscoverQueryTimeoutMs() const {
|
||||
uint32_t response_airtime = _radio->getEstAirtimeFor(MAX_PACKET_PAYLOAD + 2);
|
||||
return SERVER_RESPONSE_DELAY + getCADFailMaxDuration() + 360UL
|
||||
+ response_airtime * 2UL;
|
||||
}
|
||||
|
||||
void MyMesh::resetNeighborDiscoverJsonBudget() {
|
||||
getLocalScopes(self_scopes_buf, sizeof(self_scopes_buf));
|
||||
{
|
||||
// No default region means this node floods unscoped, i.e. the wildcard.
|
||||
RegionEntry* def = region_map.getDefaultRegion();
|
||||
const char* def_name = (def && def->name[0]) ? def->name : "*";
|
||||
if (*def_name == '#') def_name++; // match how self.scopes renders names
|
||||
strncpy(self_default_scope_buf, def_name, sizeof(self_default_scope_buf) - 1);
|
||||
self_default_scope_buf[sizeof(self_default_scope_buf) - 1] = 0;
|
||||
}
|
||||
MQTTBridge::getEffectiveMqttOrigin(
|
||||
_prefs.node_name, _cli.getObserverPrefs(),
|
||||
neighbor_discover_origin, sizeof(neighbor_discover_origin));
|
||||
|
||||
char self_pubkey_hex[65];
|
||||
mesh::Utils::toHex(self_pubkey_hex, self_id.pub_key, PUB_KEY_SIZE);
|
||||
char timestamp[40];
|
||||
MQTTMessageBuilder::formatIsoTimestampForMqtt(
|
||||
getRTCClock()->getCurrentTime(), 0, nullptr, timestamp, sizeof(timestamp));
|
||||
|
||||
neighbor_discover_publish_count = 0;
|
||||
neighbor_discover_queried_count = 0;
|
||||
neighbor_discover_truncated = false;
|
||||
neighbor_discover_json_size = MQTTMessageBuilder::measureNeighborsMessageBase(
|
||||
neighbor_discover_origin, self_pubkey_hex, timestamp, self_scopes_buf,
|
||||
self_default_scope_buf, neighbor_discover_count);
|
||||
}
|
||||
|
||||
// Account for one terminal result. The base measurement reserves maximum-width
|
||||
// progress metadata; UINT32_MAX likewise reserves the widest heard-age value.
|
||||
// If this result cannot fit, stop before transmitting another scope request.
|
||||
bool MyMesh::completeNeighborDiscoverEntry() {
|
||||
NeighborDiscoverEntry& entry = neighbor_discover[neighbor_discover_next];
|
||||
char pubkey_hex[65];
|
||||
mesh::Utils::toHex(pubkey_hex, entry.id.pub_key, PUB_KEY_SIZE);
|
||||
|
||||
MQTTMessageBuilder::NeighborsMessageEntry measured = {
|
||||
pubkey_hex,
|
||||
entry.snr / 4.0f,
|
||||
UINT32_MAX,
|
||||
entry.scopes,
|
||||
entry.status == ND_RESPONDED ? "responded"
|
||||
: (entry.status == ND_SEND_FAILED ? "send_failed" : "timeout")
|
||||
};
|
||||
size_t added = MQTTMessageBuilder::measureNeighborsMessageEntry(measured);
|
||||
if (neighbor_discover_publish_count > 0) added++; // array comma
|
||||
|
||||
if (neighbor_discover_json_size + added >= MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE ||
|
||||
neighbor_discover_publish_count >= MQTTBridge::NEIGHBORS_MAX_PUBLISH_ENTRIES) {
|
||||
neighbor_discover_truncated = true;
|
||||
finishNeighborDiscover();
|
||||
return false;
|
||||
}
|
||||
|
||||
neighbor_discover_json_size += added;
|
||||
neighbor_discover_publish_count++;
|
||||
neighbor_discover_next++;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -11218,15 +11347,46 @@ bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data
|
||||
memcpy(entry.scopes, &data[8], scope_len);
|
||||
entry.scopes[scope_len] = 0;
|
||||
entry.status = ND_RESPONDED;
|
||||
// A zero-hop reply is proof we heard this neighbour now, so re-stamp both the
|
||||
// snapshot and the live table; a stamp taken before time sync heals here.
|
||||
entry.heard_timestamp = getRTCClock()->getCurrentTime();
|
||||
touchNeighbourHeard(entry.id, entry.heard_timestamp);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Publish-ordering: most recently heard first, then stronger SNR, then pubkey.
|
||||
// The JSON builder drops the tail if the buffer fills, so the head must be the
|
||||
// most useful entries.
|
||||
// Refresh a live neighbour's heard time only: a scope reply carries no advert
|
||||
// timestamp or SNR to update.
|
||||
void MyMesh::touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp) {
|
||||
#if MAX_NEIGHBOURS
|
||||
for (int i = 0; i < MAX_NEIGHBOURS; i++) {
|
||||
if (id.matches(neighbours[i].id)) {
|
||||
neighbours[i].heard_timestamp = heard_timestamp;
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// A heard age is a wall-clock delta, so it only means something when both stamps
|
||||
// share a clock epoch. An entry heard before the clock was set holds the unset
|
||||
// default, which a synced clock turns into a ~2-year age; report those as
|
||||
// unknown instead. See UPSTREAM_BUGS.md for the monotonic fix.
|
||||
static bool neighborHeardAgeUsable(uint32_t heard_timestamp, uint32_t now_secs) {
|
||||
if (heard_timestamp == 0 || now_secs < heard_timestamp) return false;
|
||||
// Never synced: the stamp shares this clock's boot epoch, so the delta holds.
|
||||
if (now_secs < MQTTConnectionPolicy::kSyncedClockEpoch) return true;
|
||||
return heard_timestamp >= MQTTConnectionPolicy::kSyncedClockEpoch;
|
||||
}
|
||||
|
||||
// Publish-ordering: usable ages first, then most recently heard, then stronger
|
||||
// SNR, then pubkey. The JSON builder drops the tail if the buffer fills, so the
|
||||
// head must be the most useful entries.
|
||||
static bool neighborPublishEntryComesBefore(
|
||||
const MQTTMessageBuilder::NeighborsMessageEntry& lhs,
|
||||
const MQTTMessageBuilder::NeighborsMessageEntry& rhs) {
|
||||
if (lhs.heard_unknown != rhs.heard_unknown) {
|
||||
return !lhs.heard_unknown;
|
||||
}
|
||||
if (lhs.heard_secs_ago != rhs.heard_secs_ago) {
|
||||
return lhs.heard_secs_ago < rhs.heard_secs_ago; // newer first
|
||||
}
|
||||
@@ -11237,176 +11397,219 @@ static bool neighborPublishEntryComesBefore(
|
||||
}
|
||||
|
||||
#if defined(ESP_PLATFORM)
|
||||
// Neighbors allocations prefer PSRAM where it exists and otherwise come from
|
||||
// internal DRAM, so MQTT_NEIGHBORS_WITHOUT_PSRAM boards can build the table too.
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
static const uint32_t kNeighborsAllocCaps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT;
|
||||
#else
|
||||
static const uint32_t kNeighborsAllocCaps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
|
||||
#endif
|
||||
|
||||
static void* neighborsAlloc(size_t size) {
|
||||
if (size == 0) return nullptr;
|
||||
void* p = heap_caps_malloc(size, kNeighborsAllocCaps);
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||
#endif
|
||||
return p;
|
||||
}
|
||||
|
||||
static void neighborsFree(void* ptr) {
|
||||
if (ptr) heap_caps_free(ptr);
|
||||
}
|
||||
|
||||
// ArduinoJson v7 JsonDocument has no real capacity cap (DynamicJsonDocument(N)
|
||||
// is a no-op shim). Keep the pool off internal DRAM and soft-cap peak growth to
|
||||
// the publish buffer size. used only rises on allocate -- conservative for this
|
||||
// single-shot doc (overflow path removes+breaks, so no further growth after free).
|
||||
// is a no-op shim), so soft-cap peak pool growth to NEIGHBORS_DOC_POOL_BUDGET.
|
||||
// used only rises on allocate - conservative for this single-shot doc (overflow
|
||||
// path removes+breaks, so no further growth after free).
|
||||
struct NeighborsDocAllocator : ArduinoJson::Allocator {
|
||||
size_t used = 0;
|
||||
static const size_t kBudget = MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE;
|
||||
static const size_t kBudget = MQTTBridge::NEIGHBORS_DOC_POOL_BUDGET;
|
||||
|
||||
void* allocate(size_t size) override {
|
||||
if (used >= kBudget || size > kBudget - used) return nullptr;
|
||||
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
void* p = neighborsAlloc(size);
|
||||
if (p) used += size;
|
||||
return p;
|
||||
}
|
||||
|
||||
void deallocate(void* ptr) override {
|
||||
heap_caps_free(ptr);
|
||||
neighborsFree(ptr);
|
||||
}
|
||||
|
||||
void* reallocate(void* ptr, size_t new_size) override {
|
||||
size_t old_size = ptr ? heap_caps_get_allocated_size(ptr) : 0;
|
||||
size_t next_used = (used >= old_size) ? (used - old_size) : 0;
|
||||
if (next_used >= kBudget || new_size > kBudget - next_used) return nullptr;
|
||||
void* p = heap_caps_realloc(ptr, new_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
void* p = heap_caps_realloc(ptr, new_size, kNeighborsAllocCaps);
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
if (!p) p = heap_caps_realloc(ptr, new_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||
#endif
|
||||
if (p) used = next_used + new_size;
|
||||
return p;
|
||||
}
|
||||
};
|
||||
#else
|
||||
static void* neighborsAlloc(size_t size) { return size ? malloc(size) : nullptr; }
|
||||
static void neighborsFree(void* ptr) { free(ptr); }
|
||||
#endif
|
||||
|
||||
// Build the neighbors-table JSON and hand it to the bridge, then reschedule.
|
||||
void MyMesh::finishNeighborDiscover() {
|
||||
getLocalScopes(self_scopes_buf, sizeof(self_scopes_buf));
|
||||
|
||||
char self_pubkey_hex[65];
|
||||
mesh::Utils::toHex(self_pubkey_hex, self_id.pub_key, PUB_KEY_SIZE);
|
||||
|
||||
char origin[32];
|
||||
MQTTBridge::getEffectiveMqttOrigin(_prefs.node_name, _cli.getObserverPrefs(), origin, sizeof(origin));
|
||||
|
||||
char timestamp[40];
|
||||
MQTTMessageBuilder::formatIsoTimestampForMqtt(getRTCClock()->getCurrentTime(), 0, nullptr, timestamp, sizeof(timestamp));
|
||||
|
||||
struct NeighborPublishWorkspace {
|
||||
char pubkey_hex[MAX_NEIGHBOURS][65];
|
||||
MQTTMessageBuilder::NeighborsMessageEntry entries[MAX_NEIGHBOURS];
|
||||
};
|
||||
#if defined(ESP_PLATFORM)
|
||||
auto* publish_workspace = static_cast<NeighborPublishWorkspace*>(
|
||||
heap_caps_malloc(sizeof(NeighborPublishWorkspace), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
|
||||
#else
|
||||
auto* publish_workspace = static_cast<NeighborPublishWorkspace*>(
|
||||
malloc(sizeof(NeighborPublishWorkspace)));
|
||||
#endif
|
||||
if (!publish_workspace) {
|
||||
neighbor_discover_active = false;
|
||||
neighbor_discover_count = 0;
|
||||
if (_cli.getObserverPrefs()->mqtt_neighbors_enabled) {
|
||||
next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval);
|
||||
}
|
||||
return;
|
||||
}
|
||||
auto& pubkey_hex = publish_workspace->pubkey_hex;
|
||||
auto& entries = publish_workspace->entries;
|
||||
uint32_t now_secs = getRTCClock()->getCurrentTime();
|
||||
// The entry table plus one hex string each reaches ~4.5 KB at MAX_NEIGHBOURS,
|
||||
// which does not fit the mesh loop task's 8 KB stack, so both share a single
|
||||
// heap block sized to this pass. Publishing is skipped if either alloc fails.
|
||||
const int publish_count = neighbor_discover_publish_count;
|
||||
const size_t hex_size = PUB_KEY_SIZE * 2 + 1;
|
||||
const size_t entries_bytes =
|
||||
sizeof(MQTTMessageBuilder::NeighborsMessageEntry) * publish_count;
|
||||
void* scratch = neighborsAlloc(entries_bytes + hex_size * publish_count);
|
||||
char* json_buf = (char*)neighborsAlloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE);
|
||||
|
||||
for (int i = 0; i < neighbor_discover_count; i++) {
|
||||
auto& nb = neighbours[neighbor_discover[i].neighbour_idx];
|
||||
mesh::Utils::toHex(pubkey_hex[i], nb.id.pub_key, PUB_KEY_SIZE);
|
||||
entries[i].pubkey_hex = pubkey_hex[i];
|
||||
entries[i].snr = nb.snr / 4.0f;
|
||||
entries[i].heard_secs_ago = (nb.heard_timestamp > 0 && now_secs >= nb.heard_timestamp)
|
||||
? (now_secs - nb.heard_timestamp) : 0;
|
||||
entries[i].scopes = neighbor_discover[i].scopes;
|
||||
switch (neighbor_discover[i].status) {
|
||||
case ND_RESPONDED: entries[i].status = "responded"; break;
|
||||
case ND_SEND_FAILED: entries[i].status = "send_failed"; break;
|
||||
default: entries[i].status = "timeout"; break;
|
||||
if (json_buf && (scratch || publish_count == 0)) {
|
||||
auto* entries = (MQTTMessageBuilder::NeighborsMessageEntry*)scratch;
|
||||
char* pubkey_hex = (char*)scratch + entries_bytes;
|
||||
uint32_t now_secs = getRTCClock()->getCurrentTime();
|
||||
|
||||
for (int i = 0; i < publish_count; i++) {
|
||||
auto& entry = neighbor_discover[i];
|
||||
char* hex = &pubkey_hex[i * hex_size];
|
||||
mesh::Utils::toHex(hex, entry.id.pub_key, PUB_KEY_SIZE);
|
||||
entries[i].pubkey_hex = hex;
|
||||
entries[i].snr = entry.snr / 4.0f;
|
||||
bool heard_known = neighborHeardAgeUsable(entry.heard_timestamp, now_secs);
|
||||
entries[i].heard_unknown = !heard_known;
|
||||
entries[i].heard_secs_ago = heard_known ? (now_secs - entry.heard_timestamp) : 0;
|
||||
entries[i].scopes = entry.scopes;
|
||||
switch (entry.status) {
|
||||
case ND_RESPONDED: entries[i].status = "responded"; break;
|
||||
case ND_SEND_FAILED: entries[i].status = "send_failed"; break;
|
||||
default: entries[i].status = "timeout"; break;
|
||||
}
|
||||
}
|
||||
|
||||
// insertion sort: most useful first (JSON builder drops the tail on overflow)
|
||||
for (int i = 1; i < publish_count; i++) {
|
||||
MQTTMessageBuilder::NeighborsMessageEntry entry = entries[i];
|
||||
int j = i;
|
||||
while (j > 0 && neighborPublishEntryComesBefore(entry, entries[j - 1])) {
|
||||
entries[j] = entries[j - 1];
|
||||
j--;
|
||||
}
|
||||
entries[j] = entry;
|
||||
}
|
||||
|
||||
#if defined(ESP_PLATFORM)
|
||||
NeighborsDocAllocator doc_alloc;
|
||||
JsonDocument doc(&doc_alloc);
|
||||
#else
|
||||
JsonDocument doc;
|
||||
#endif
|
||||
int json_len = MQTTMessageBuilder::buildNeighborsMessage(
|
||||
doc, neighbor_discover_origin, self_pubkey_hex, timestamp, self_scopes_buf,
|
||||
self_default_scope_buf, entries, publish_count,
|
||||
json_buf, MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE,
|
||||
neighbor_discover_count, neighbor_discover_queried_count,
|
||||
neighbor_discover_truncated);
|
||||
|
||||
if (json_len > 0 && mqtt_bridge) {
|
||||
mqtt_bridge->requestPublishNeighbors(json_buf, (size_t)json_len);
|
||||
}
|
||||
}
|
||||
|
||||
// insertion sort: most useful first (JSON builder drops the tail on overflow)
|
||||
for (int i = 1; i < neighbor_discover_count; i++) {
|
||||
MQTTMessageBuilder::NeighborsMessageEntry entry = entries[i];
|
||||
int j = i;
|
||||
while (j > 0 && neighborPublishEntryComesBefore(entry, entries[j - 1])) {
|
||||
entries[j] = entries[j - 1];
|
||||
j--;
|
||||
}
|
||||
entries[j] = entry;
|
||||
}
|
||||
|
||||
#if defined(ESP_PLATFORM)
|
||||
char* json_buf = (char*)heap_caps_malloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE, MALLOC_CAP_SPIRAM);
|
||||
#else
|
||||
char* json_buf = (char*)malloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE);
|
||||
#endif
|
||||
if (!json_buf) {
|
||||
#if defined(ESP_PLATFORM)
|
||||
heap_caps_free(publish_workspace);
|
||||
#else
|
||||
free(publish_workspace);
|
||||
#endif
|
||||
neighbor_discover_active = false;
|
||||
neighbor_discover_count = 0;
|
||||
if (_cli.getObserverPrefs()->mqtt_neighbors_enabled) {
|
||||
next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(ESP_PLATFORM)
|
||||
NeighborsDocAllocator doc_alloc;
|
||||
JsonDocument doc(&doc_alloc);
|
||||
#else
|
||||
JsonDocument doc;
|
||||
#endif
|
||||
int json_len = MQTTMessageBuilder::buildNeighborsMessage(
|
||||
doc, origin, self_pubkey_hex, timestamp, self_scopes_buf,
|
||||
entries, neighbor_discover_count,
|
||||
json_buf, MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE);
|
||||
|
||||
if (json_len > 0 && mqtt_bridge) {
|
||||
mqtt_bridge->requestPublishNeighbors(json_buf, (size_t)json_len);
|
||||
}
|
||||
|
||||
#if defined(ESP_PLATFORM)
|
||||
heap_caps_free(json_buf);
|
||||
heap_caps_free(publish_workspace);
|
||||
#else
|
||||
free(json_buf);
|
||||
free(publish_workspace);
|
||||
#endif
|
||||
neighborsFree(scratch);
|
||||
neighborsFree(json_buf);
|
||||
|
||||
neighbor_discover_active = false;
|
||||
neighbor_discover_count = 0;
|
||||
neighbor_discover_next = 0;
|
||||
neighbor_discover_publish_count = 0;
|
||||
neighbor_discover_queried_count = 0;
|
||||
neighbor_discover_json_size = 0;
|
||||
neighbor_discover_truncated = false;
|
||||
neighbor_discover_until = 0;
|
||||
neighbor_discover_request = NULL;
|
||||
if (_cli.getObserverPrefs()->mqtt_neighbors_enabled) {
|
||||
next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the scope-query phase; publish once all entries resolve or the window
|
||||
// times out (stragglers marked ND_TIMEOUT).
|
||||
// Advance the newest-first scope-query phase. Keep only one request in flight so
|
||||
// its responder gets a clear reply opportunity and the packet pool stays free.
|
||||
void MyMesh::loopNeighborDiscover() {
|
||||
if (!neighbor_discover_active) return;
|
||||
|
||||
bool all_done = true;
|
||||
for (int i = 0; i < neighbor_discover_count; i++) {
|
||||
if (neighbor_discover[i].status == ND_PENDING) { all_done = false; break; }
|
||||
if (neighbor_discover_next >= neighbor_discover_count) {
|
||||
finishNeighborDiscover();
|
||||
return;
|
||||
}
|
||||
if (!all_done && !millisHasNowPassed(neighbor_discover_until)) return;
|
||||
if (!all_done) {
|
||||
for (int i = 0; i < neighbor_discover_count; i++) {
|
||||
if (neighbor_discover[i].status == ND_PENDING) neighbor_discover[i].status = ND_TIMEOUT;
|
||||
|
||||
NeighborDiscoverEntry& entry = neighbor_discover[neighbor_discover_next];
|
||||
if (entry.status == ND_QUEUED) {
|
||||
if (!millisHasNowPassed(neighbor_discover_until)) return;
|
||||
if (cancelNeighborDiscoverRequest()) {
|
||||
entry.status = ND_SEND_FAILED;
|
||||
completeNeighborDiscoverEntry();
|
||||
return;
|
||||
}
|
||||
if (isCurrentOutbound(neighbor_discover_request)) {
|
||||
neighbor_discover_until = futureMillis(neighborDiscoverQueryTimeoutMs());
|
||||
return;
|
||||
}
|
||||
neighbor_discover_request = NULL; // packet manager already shed it
|
||||
entry.status = ND_SEND_FAILED;
|
||||
completeNeighborDiscoverEntry();
|
||||
return;
|
||||
}
|
||||
if (entry.status == ND_PENDING) {
|
||||
if (!millisHasNowPassed(neighbor_discover_until)) return;
|
||||
entry.status = ND_TIMEOUT;
|
||||
completeNeighborDiscoverEntry();
|
||||
return;
|
||||
}
|
||||
if (entry.status == ND_RESPONDED || entry.status == ND_SEND_FAILED
|
||||
|| entry.status == ND_TIMEOUT) {
|
||||
completeNeighborDiscoverEntry();
|
||||
return;
|
||||
}
|
||||
if (entry.status != ND_UNSENT) {
|
||||
neighbor_discover_next++;
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t tag;
|
||||
mesh::Packet* request = sendAnonRegionsReq(entry.id, tag);
|
||||
if (request) {
|
||||
entry.tag = tag;
|
||||
entry.status = ND_QUEUED;
|
||||
neighbor_discover_request = request;
|
||||
neighbor_discover_until = futureMillis(NEIGHBOR_DISCOVER_QUEUE_TIMEOUT_MS);
|
||||
} else {
|
||||
entry.status = ND_SEND_FAILED;
|
||||
completeNeighborDiscoverEntry();
|
||||
}
|
||||
finishNeighborDiscover();
|
||||
}
|
||||
|
||||
// Shared precondition for starting a discovery: PSRAM present + bridge running.
|
||||
// Shared precondition for starting a discovery: usable buffers + bridge running.
|
||||
// PSRAM builds size their neighbors buffers for PSRAM, so a board whose PSRAM
|
||||
// failed to init must not silently spend that much internal DRAM here.
|
||||
// MQTT_NEIGHBORS_WITHOUT_PSRAM builds are already sized for internal DRAM.
|
||||
bool MyMesh::neighborDiscoverReady(char* reply) {
|
||||
#if defined(ESP_PLATFORM)
|
||||
#if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM)
|
||||
if (!psramFound()) { strcpy(reply, "Err - PSRAM not available"); return false; }
|
||||
#endif
|
||||
if (!mqtt_bridge || !mqtt_bridge->isRunning()) { strcpy(reply, "Err - MQTT bridge not running"); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
// Snapshot the neighbor table into the overlay and fire one anon-regions query
|
||||
// per heard neighbour; arm the 30s scope-query window.
|
||||
// Snapshot the neighbor table newest-first. loopNeighborDiscover() emits one
|
||||
// anon-regions query at a time so hidden responders do not reply as a burst.
|
||||
bool MyMesh::startNeighborDiscover(char* reply) {
|
||||
if (neighbor_discover_active) {
|
||||
strcpy(reply, "Err - neighbor discover already active");
|
||||
@@ -11416,30 +11619,48 @@ bool MyMesh::startNeighborDiscover(char* reply) {
|
||||
return false; // reply already set
|
||||
}
|
||||
|
||||
getLocalScopes(self_scopes_buf, sizeof(self_scopes_buf));
|
||||
neighbor_discover_count = 0;
|
||||
for (int i = 0; i < MAX_NEIGHBOURS; i++) {
|
||||
if (neighbours[i].heard_timestamp > 0) {
|
||||
neighbor_discover[neighbor_discover_count].neighbour_idx = (uint8_t)i;
|
||||
neighbor_discover[neighbor_discover_count].scopes[0] = 0;
|
||||
neighbor_discover[neighbor_discover_count].status = ND_PENDING;
|
||||
uint32_t tag;
|
||||
if (sendAnonRegionsReq(neighbours[i].id, tag)) {
|
||||
neighbor_discover[neighbor_discover_count].tag = tag;
|
||||
} else {
|
||||
neighbor_discover[neighbor_discover_count].status = ND_SEND_FAILED;
|
||||
}
|
||||
NeighborDiscoverEntry& entry = neighbor_discover[neighbor_discover_count];
|
||||
entry.id = neighbours[i].id;
|
||||
entry.heard_timestamp = neighbours[i].heard_timestamp;
|
||||
entry.snr = neighbours[i].snr;
|
||||
entry.scopes[0] = 0;
|
||||
entry.tag = 0;
|
||||
entry.status = ND_UNSENT;
|
||||
neighbor_discover_count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Query the freshest/strongest entries first; pubkey makes ties deterministic.
|
||||
for (int i = 1; i < neighbor_discover_count; i++) {
|
||||
NeighborDiscoverEntry entry = neighbor_discover[i];
|
||||
int j = i;
|
||||
while (j > 0) {
|
||||
auto& rhs = neighbor_discover[j - 1];
|
||||
bool before = entry.heard_timestamp > rhs.heard_timestamp
|
||||
|| (entry.heard_timestamp == rhs.heard_timestamp && entry.snr > rhs.snr)
|
||||
|| (entry.heard_timestamp == rhs.heard_timestamp && entry.snr == rhs.snr
|
||||
&& memcmp(entry.id.pub_key, rhs.id.pub_key, PUB_KEY_SIZE) < 0);
|
||||
if (!before) break;
|
||||
neighbor_discover[j] = neighbor_discover[j - 1];
|
||||
j--;
|
||||
}
|
||||
neighbor_discover[j] = entry;
|
||||
}
|
||||
|
||||
neighbor_discover_next = 0;
|
||||
resetNeighborDiscoverJsonBudget();
|
||||
neighbor_discover_active = true;
|
||||
neighbor_discover_until = futureMillis(NEIGHBOR_DISCOVER_TIMEOUT_MS);
|
||||
neighbor_discover_until = 0;
|
||||
neighbor_discover_request = NULL;
|
||||
|
||||
if (neighbor_discover_count == 0) {
|
||||
finishNeighborDiscover();
|
||||
strcpy(reply, "OK - neighbor discover started (0 neighbors, self only)");
|
||||
} else {
|
||||
loopNeighborDiscover(); // queue the first request now
|
||||
sprintf(reply, "OK - neighbor discover started (%u neighbors)", (unsigned)neighbor_discover_count);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -499,40 +499,58 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
// Neighbor-scope discovery: a snapshot of the neighbor table overlaid with an
|
||||
// in-flight anon-regions query per neighbor, published to the MQTT neighbors
|
||||
// topic once every neighbor has responded or the window times out.
|
||||
// anon-regions query per neighbor, published to the MQTT neighbors topic once
|
||||
// every neighbor has responded or timed out.
|
||||
enum NeighborDiscoverStatus : uint8_t {
|
||||
ND_PENDING = 1,
|
||||
ND_RESPONDED = 2,
|
||||
ND_TIMEOUT = 3,
|
||||
ND_SEND_FAILED = 4,
|
||||
ND_UNSENT = 0,
|
||||
ND_QUEUED = 1,
|
||||
ND_PENDING = 2,
|
||||
ND_RESPONDED = 3,
|
||||
ND_TIMEOUT = 4,
|
||||
ND_SEND_FAILED = 5,
|
||||
};
|
||||
struct NeighborDiscoverEntry {
|
||||
uint8_t neighbour_idx; // index into neighbours[]
|
||||
mesh::Identity id; // immutable snapshot: neighbour table can change mid-pass
|
||||
uint32_t heard_timestamp;
|
||||
int8_t snr; // multiplied by 4
|
||||
uint32_t tag; // anon-regions request tag we're waiting on
|
||||
char scopes[96]; // scope names from the response
|
||||
uint8_t status; // NeighborDiscoverStatus
|
||||
};
|
||||
NeighborDiscoverEntry neighbor_discover[MAX_NEIGHBOURS];
|
||||
uint8_t neighbor_discover_count;
|
||||
uint8_t neighbor_discover_next; // newest-first entry currently being queried
|
||||
uint8_t neighbor_discover_publish_count; // completed prefix that fits the JSON buffer
|
||||
uint8_t neighbor_discover_queried_count; // requests confirmed transmitted
|
||||
size_t neighbor_discover_json_size;
|
||||
bool neighbor_discover_truncated;
|
||||
bool neighbor_discover_active; // scope-query phase in flight
|
||||
bool neighbor_table_refresh_active; // zero-hop table refresh (stage 1) in flight
|
||||
bool neighbor_table_refresh_periodic; // that refresh was kicked by the periodic timer
|
||||
unsigned long neighbor_discover_until; // scope-query timeout deadline
|
||||
unsigned long neighbor_discover_until; // current queue or response deadline
|
||||
mesh::Packet* neighbor_discover_request; // request awaiting TX completion
|
||||
unsigned long next_neighbors_publish; // periodic publish deadline (0 = fire ASAP)
|
||||
char self_scopes_buf[96];
|
||||
char self_default_scope_buf[31];
|
||||
char neighbor_discover_origin[32];
|
||||
|
||||
bool sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag);
|
||||
mesh::Packet* sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag);
|
||||
bool cancelNeighborDiscoverRequest();
|
||||
uint32_t neighborDiscoverQueryTimeoutMs() const;
|
||||
bool completeNeighborDiscoverEntry();
|
||||
void resetNeighborDiscoverJsonBudget();
|
||||
bool neighborDiscoverReady(char* reply);
|
||||
bool startNeighborDiscover(char* reply);
|
||||
void loopNeighborDiscover();
|
||||
void finishNeighborDiscover();
|
||||
bool handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len);
|
||||
void touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp);
|
||||
void getLocalScopes(char* buf, size_t len);
|
||||
// Overlay peer indices are offset by this base so onPeerDataRecv can tell a
|
||||
// discovery response apart from a normal ACL-client index.
|
||||
static const int NEIGHBOR_DISCOVER_PEER_BASE = 1000;
|
||||
static const unsigned long NEIGHBOR_DISCOVER_TIMEOUT_MS = 30000;
|
||||
static const unsigned long NEIGHBOR_DISCOVER_QUEUE_TIMEOUT_MS = 29000;
|
||||
static const int NEIGHBOR_DISCOVER_MIN_FREE_PACKETS = 5;
|
||||
#endif
|
||||
|
||||
bool extractDirectRetryPrefix(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include "UITask.h"
|
||||
static UITask ui_task(board, display);
|
||||
static bool display_ready = false;
|
||||
#endif
|
||||
|
||||
#ifdef ETHERNET_ENABLED
|
||||
@@ -76,7 +77,8 @@ void setup() {
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (display.begin()) {
|
||||
display_ready = display.begin();
|
||||
if (display_ready) {
|
||||
display.startFrame();
|
||||
display.setCursor(0, 0);
|
||||
display.print("Please wait...");
|
||||
@@ -157,7 +159,9 @@ void setup() {
|
||||
the_mesh.begin(fs);
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
|
||||
if (display_ready) {
|
||||
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef ETHERNET_ENABLED
|
||||
@@ -245,7 +249,7 @@ void loop() {
|
||||
the_mesh.loop();
|
||||
sensors.loop();
|
||||
#ifdef DISPLAY_CLASS
|
||||
ui_task.loop();
|
||||
if (display_ready) ui_task.loop();
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
|
||||
uint32_t delay_ms = 1000UL << shift;
|
||||
return delay_ms > 30000UL ? 30000UL : delay_ms;
|
||||
}
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
#include <helpers/MQTTConnectionPolicy.h> // kSyncedClockEpoch
|
||||
#endif
|
||||
|
||||
#define ANON_REQ_TYPE_REGIONS 0x01 // client side of the anon-regions scope query (neighbors feature)
|
||||
|
||||
@@ -301,6 +304,19 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) {
|
||||
}
|
||||
}
|
||||
void MyMesh::logTx(mesh::Packet *pkt, int len) {
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
if (neighbor_discover_active && pkt == neighbor_discover_request
|
||||
&& neighbor_discover_next < neighbor_discover_count) {
|
||||
NeighborDiscoverEntry& entry = neighbor_discover[neighbor_discover_next];
|
||||
if (entry.status == ND_QUEUED) {
|
||||
entry.status = ND_PENDING;
|
||||
neighbor_discover_queried_count++;
|
||||
neighbor_discover_request = NULL;
|
||||
neighbor_discover_until = futureMillis(neighborDiscoverQueryTimeoutMs());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
// MQTT bridge: always feed TX packets - bridge decides based on mqtt.tx setting
|
||||
if (_prefs.bridge_enabled && bridge) bridge->sendPacket(pkt);
|
||||
@@ -324,6 +340,18 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) {
|
||||
}
|
||||
}
|
||||
void MyMesh::logTxFail(mesh::Packet *pkt, int len) {
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
if (neighbor_discover_active && pkt == neighbor_discover_request
|
||||
&& neighbor_discover_next < neighbor_discover_count) {
|
||||
NeighborDiscoverEntry& entry = neighbor_discover[neighbor_discover_next];
|
||||
if (entry.status == ND_QUEUED) {
|
||||
entry.status = ND_SEND_FAILED;
|
||||
neighbor_discover_request = NULL;
|
||||
neighbor_discover_until = 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (_logging) {
|
||||
File f = openAppend(PACKET_LOG_FILE);
|
||||
if (f) {
|
||||
@@ -681,12 +709,12 @@ int MyMesh::searchPeersByHash(const uint8_t *hash) {
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
if (neighbor_discover_active) {
|
||||
for (int i = 0; i < neighbor_discover_count && n < MAX_CLIENTS; i++) {
|
||||
auto& nb = neighbours[neighbor_discover[i].neighbour_idx];
|
||||
auto& entry = neighbor_discover[i];
|
||||
// ACL clients already have a matching peer entry and shared secret. Adding
|
||||
// a second overlay entry would decrypt first and intercept their normal
|
||||
// CLI/request traffic for the duration of discovery.
|
||||
if (acl.getClient(nb.id.pub_key, PUB_KEY_SIZE) != nullptr) continue;
|
||||
if (nb.heard_timestamp > 0 && nb.id.isHashMatch(hash)) {
|
||||
if (acl.getClient(entry.id.pub_key, PUB_KEY_SIZE) != nullptr) continue;
|
||||
if (entry.heard_timestamp > 0 && entry.id.isHashMatch(hash)) {
|
||||
matching_peer_indexes[n++] = NEIGHBOR_DISCOVER_PEER_BASE + i;
|
||||
}
|
||||
}
|
||||
@@ -706,7 +734,7 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) {
|
||||
if (neighbor_discover_active && i >= NEIGHBOR_DISCOVER_PEER_BASE) {
|
||||
int oi = i - NEIGHBOR_DISCOVER_PEER_BASE;
|
||||
if (oi >= 0 && oi < neighbor_discover_count) {
|
||||
self_id.calcSharedSecret(dest_secret, neighbours[neighbor_discover[oi].neighbour_idx].id);
|
||||
self_id.calcSharedSecret(dest_secret, neighbor_discover[oi].id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -743,8 +771,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
|
||||
// scope-query response from it lands here -- match it against the overlay.
|
||||
if (neighbor_discover_active && type == PAYLOAD_TYPE_RESPONSE) {
|
||||
for (int oi = 0; oi < neighbor_discover_count; oi++) {
|
||||
auto& nb = neighbours[neighbor_discover[oi].neighbour_idx];
|
||||
if (client->id.matches(nb.id) && handleNeighborDiscoverResponse(oi, data, len)) {
|
||||
if (client->id.matches(neighbor_discover[oi].id)
|
||||
&& handleNeighborDiscoverResponse(oi, data, len)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1130,7 +1158,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
|
||||
#endif
|
||||
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
_prefs.airtime_factor = 1.0; // one half
|
||||
_prefs.rx_delay_base = 0.0f; // off by default, was 10.0
|
||||
_prefs.tx_delay_factor = 0.5f; // was 0.25f;
|
||||
@@ -1195,12 +1222,20 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
|
||||
pending_discover_tag = 0;
|
||||
pending_discover_until = 0;
|
||||
neighbor_discover_count = 0;
|
||||
neighbor_discover_next = 0;
|
||||
neighbor_discover_publish_count = 0;
|
||||
neighbor_discover_queried_count = 0;
|
||||
neighbor_discover_json_size = 0;
|
||||
neighbor_discover_truncated = false;
|
||||
neighbor_discover_active = false;
|
||||
neighbor_table_refresh_active = false;
|
||||
neighbor_table_refresh_periodic = false;
|
||||
neighbor_discover_until = 0;
|
||||
neighbor_discover_request = NULL;
|
||||
next_neighbors_publish = 0;
|
||||
self_scopes_buf[0] = 0;
|
||||
self_default_scope_buf[0] = 0;
|
||||
neighbor_discover_origin[0] = 0;
|
||||
memset(neighbours, 0, sizeof(neighbours));
|
||||
#endif
|
||||
|
||||
@@ -1563,7 +1598,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) {
|
||||
owns_wifi = false;
|
||||
#endif
|
||||
_webconfig = new WebConfigServer(this, mqtt_prefs, owns_wifi,
|
||||
self_id.pub_key, getFirmwareVer(), getRole(),
|
||||
self_id.pub_key, getFirmwareVer(), getBuildDate(), getRole(),
|
||||
_cli.getBoard()->getManufacturerName());
|
||||
if (!_webconfig) {
|
||||
strcpy(reply, "Err: not enough memory for webconfig");
|
||||
@@ -2038,7 +2073,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
|
||||
}
|
||||
#elif defined(WITH_MQTT_BRIDGE)
|
||||
} else if (memcmp(command, "discover.scopes", 15) == 0) {
|
||||
strcpy(reply, "Err - not supported (requires PSRAM)");
|
||||
strcpy(reply, "Err - neighbors not enabled in this build");
|
||||
#endif
|
||||
} else if (strncmp(command, "room.post", 9) == 0) {
|
||||
char* msg = command + 9;
|
||||
@@ -2430,7 +2465,11 @@ void MyMesh::getLocalScopes(char* buf, size_t len) {
|
||||
|
||||
// Client side of the anon-regions request (the server side is handleAnonRegionsReq).
|
||||
// Inner payload: {tag(4)}{ANON_REQ_TYPE_REGIONS}{0x00 = zero-hop reply path}.
|
||||
bool MyMesh::sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag) {
|
||||
mesh::Packet* MyMesh::sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag) {
|
||||
// RxReservePacketManager keeps a four-packet emergency floor. Preflight one
|
||||
// extra free packet so its void queue API cannot silently shed this request.
|
||||
if (_mgr->getFreeCount() < NEIGHBOR_DISCOVER_MIN_FREE_PACKETS) return NULL;
|
||||
|
||||
uint8_t secret[PUB_KEY_SIZE];
|
||||
self_id.calcSharedSecret(secret, target);
|
||||
|
||||
@@ -2441,8 +2480,92 @@ bool MyMesh::sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag) {
|
||||
inner[5] = 0x00; // request a zero-hop reply path
|
||||
|
||||
mesh::Packet* pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, target, secret, inner, sizeof(inner));
|
||||
if (!pkt) return false;
|
||||
if (!pkt) return NULL;
|
||||
sendDirect(pkt, NULL, 0, 0);
|
||||
return pkt;
|
||||
}
|
||||
|
||||
bool MyMesh::cancelNeighborDiscoverRequest() {
|
||||
if (!neighbor_discover_request) return false;
|
||||
for (int i = _mgr->getOutboundTotal() - 1; i >= 0; i--) {
|
||||
if (_mgr->getOutboundByIdx(i) == neighbor_discover_request) {
|
||||
mesh::Packet* pkt = _mgr->removeOutboundByIdx(i);
|
||||
if (pkt) releasePacket(pkt);
|
||||
neighbor_discover_request = NULL;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// This timer starts after the request finishes transmitting. Allow the server
|
||||
// delay, the responder's full CAD deferral window plus one maximum retry
|
||||
// overshoot, and airtime for one priority-0 packet ahead of the response plus
|
||||
// the response itself. The radio estimate scales with SF, bandwidth, coding
|
||||
// rate, and preamble.
|
||||
uint32_t MyMesh::neighborDiscoverQueryTimeoutMs() const {
|
||||
uint32_t response_airtime = _radio->getEstAirtimeFor(MAX_PACKET_PAYLOAD + 2);
|
||||
return SERVER_RESPONSE_DELAY + getCADFailMaxDuration() + 360UL
|
||||
+ response_airtime * 2UL;
|
||||
}
|
||||
|
||||
void MyMesh::resetNeighborDiscoverJsonBudget() {
|
||||
getLocalScopes(self_scopes_buf, sizeof(self_scopes_buf));
|
||||
{
|
||||
// No default region means this node floods unscoped, i.e. the wildcard.
|
||||
RegionEntry* def = region_map.getDefaultRegion();
|
||||
const char* def_name = (def && def->name[0]) ? def->name : "*";
|
||||
if (*def_name == '#') def_name++; // match how self.scopes renders names
|
||||
strncpy(self_default_scope_buf, def_name, sizeof(self_default_scope_buf) - 1);
|
||||
self_default_scope_buf[sizeof(self_default_scope_buf) - 1] = 0;
|
||||
}
|
||||
MQTTBridge::getEffectiveMqttOrigin(
|
||||
_prefs.node_name, _cli.getObserverPrefs(),
|
||||
neighbor_discover_origin, sizeof(neighbor_discover_origin));
|
||||
|
||||
char self_pubkey_hex[65];
|
||||
mesh::Utils::toHex(self_pubkey_hex, self_id.pub_key, PUB_KEY_SIZE);
|
||||
char timestamp[40];
|
||||
MQTTMessageBuilder::formatIsoTimestampForMqtt(
|
||||
getRTCClock()->getCurrentTime(), 0, nullptr, timestamp, sizeof(timestamp));
|
||||
|
||||
neighbor_discover_publish_count = 0;
|
||||
neighbor_discover_queried_count = 0;
|
||||
neighbor_discover_truncated = false;
|
||||
neighbor_discover_json_size = MQTTMessageBuilder::measureNeighborsMessageBase(
|
||||
neighbor_discover_origin, self_pubkey_hex, timestamp, self_scopes_buf,
|
||||
self_default_scope_buf, neighbor_discover_count);
|
||||
}
|
||||
|
||||
// Account for one terminal result. The base measurement reserves maximum-width
|
||||
// progress metadata; UINT32_MAX likewise reserves the widest heard-age value.
|
||||
// If this result cannot fit, stop before transmitting another scope request.
|
||||
bool MyMesh::completeNeighborDiscoverEntry() {
|
||||
NeighborDiscoverEntry& entry = neighbor_discover[neighbor_discover_next];
|
||||
char pubkey_hex[65];
|
||||
mesh::Utils::toHex(pubkey_hex, entry.id.pub_key, PUB_KEY_SIZE);
|
||||
|
||||
MQTTMessageBuilder::NeighborsMessageEntry measured = {
|
||||
pubkey_hex,
|
||||
entry.snr / 4.0f,
|
||||
UINT32_MAX,
|
||||
entry.scopes,
|
||||
entry.status == ND_RESPONDED ? "responded"
|
||||
: (entry.status == ND_SEND_FAILED ? "send_failed" : "timeout")
|
||||
};
|
||||
size_t added = MQTTMessageBuilder::measureNeighborsMessageEntry(measured);
|
||||
if (neighbor_discover_publish_count > 0) added++; // array comma
|
||||
|
||||
if (neighbor_discover_json_size + added >= MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE ||
|
||||
neighbor_discover_publish_count >= MQTTBridge::NEIGHBORS_MAX_PUBLISH_ENTRIES) {
|
||||
neighbor_discover_truncated = true;
|
||||
finishNeighborDiscover();
|
||||
return false;
|
||||
}
|
||||
|
||||
neighbor_discover_json_size += added;
|
||||
neighbor_discover_publish_count++;
|
||||
neighbor_discover_next++;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2464,15 +2587,44 @@ bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data
|
||||
memcpy(entry.scopes, &data[8], scope_len);
|
||||
entry.scopes[scope_len] = 0;
|
||||
entry.status = ND_RESPONDED;
|
||||
// A zero-hop reply is proof we heard this neighbour now, so re-stamp both the
|
||||
// snapshot and the live table; a stamp taken before time sync heals here.
|
||||
entry.heard_timestamp = getRTCClock()->getCurrentTime();
|
||||
touchNeighbourHeard(entry.id, entry.heard_timestamp);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Publish-ordering: most recently heard first, then stronger SNR, then pubkey.
|
||||
// The JSON builder drops the tail if the buffer fills, so the head must be the
|
||||
// most useful entries.
|
||||
// Refresh a live neighbour's heard time only: a scope reply carries no advert
|
||||
// timestamp or SNR to update.
|
||||
void MyMesh::touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp) {
|
||||
for (int i = 0; i < MAX_NEIGHBOURS; i++) {
|
||||
if (id.matches(neighbours[i].id)) {
|
||||
neighbours[i].heard_timestamp = heard_timestamp;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A heard age is a wall-clock delta, so it only means something when both stamps
|
||||
// share a clock epoch. An entry heard before the clock was set holds the unset
|
||||
// default, which a synced clock turns into a ~2-year age; report those as
|
||||
// unknown instead. See UPSTREAM_BUGS.md for the monotonic fix.
|
||||
static bool neighborHeardAgeUsable(uint32_t heard_timestamp, uint32_t now_secs) {
|
||||
if (heard_timestamp == 0 || now_secs < heard_timestamp) return false;
|
||||
// Never synced: the stamp shares this clock's boot epoch, so the delta holds.
|
||||
if (now_secs < MQTTConnectionPolicy::kSyncedClockEpoch) return true;
|
||||
return heard_timestamp >= MQTTConnectionPolicy::kSyncedClockEpoch;
|
||||
}
|
||||
|
||||
// Publish-ordering: usable ages first, then most recently heard, then stronger
|
||||
// SNR, then pubkey. The JSON builder drops the tail if the buffer fills, so the
|
||||
// head must be the most useful entries.
|
||||
static bool neighborPublishEntryComesBefore(
|
||||
const MQTTMessageBuilder::NeighborsMessageEntry& lhs,
|
||||
const MQTTMessageBuilder::NeighborsMessageEntry& rhs) {
|
||||
if (lhs.heard_unknown != rhs.heard_unknown) {
|
||||
return !lhs.heard_unknown;
|
||||
}
|
||||
if (lhs.heard_secs_ago != rhs.heard_secs_ago) {
|
||||
return lhs.heard_secs_ago < rhs.heard_secs_ago; // newer first
|
||||
}
|
||||
@@ -2483,176 +2635,219 @@ static bool neighborPublishEntryComesBefore(
|
||||
}
|
||||
|
||||
#if defined(ESP_PLATFORM)
|
||||
// Neighbors allocations prefer PSRAM where it exists and otherwise come from
|
||||
// internal DRAM, so MQTT_NEIGHBORS_WITHOUT_PSRAM boards can build the table too.
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
static const uint32_t kNeighborsAllocCaps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT;
|
||||
#else
|
||||
static const uint32_t kNeighborsAllocCaps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
|
||||
#endif
|
||||
|
||||
static void* neighborsAlloc(size_t size) {
|
||||
if (size == 0) return nullptr;
|
||||
void* p = heap_caps_malloc(size, kNeighborsAllocCaps);
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||
#endif
|
||||
return p;
|
||||
}
|
||||
|
||||
static void neighborsFree(void* ptr) {
|
||||
if (ptr) heap_caps_free(ptr);
|
||||
}
|
||||
|
||||
// ArduinoJson v7 JsonDocument has no real capacity cap (DynamicJsonDocument(N)
|
||||
// is a no-op shim). Keep the pool off internal DRAM and soft-cap peak growth to
|
||||
// the publish buffer size. used only rises on allocate -- conservative for this
|
||||
// single-shot doc (overflow path removes+breaks, so no further growth after free).
|
||||
struct NeighborsDocAllocator : ArduinoJson::Allocator {
|
||||
size_t used = 0;
|
||||
static const size_t kBudget = MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE;
|
||||
static const size_t kBudget = MQTTBridge::NEIGHBORS_DOC_POOL_BUDGET;
|
||||
|
||||
void* allocate(size_t size) override {
|
||||
if (used >= kBudget || size > kBudget - used) return nullptr;
|
||||
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
void* p = neighborsAlloc(size);
|
||||
if (p) used += size;
|
||||
return p;
|
||||
}
|
||||
|
||||
void deallocate(void* ptr) override {
|
||||
heap_caps_free(ptr);
|
||||
neighborsFree(ptr);
|
||||
}
|
||||
|
||||
void* reallocate(void* ptr, size_t new_size) override {
|
||||
size_t old_size = ptr ? heap_caps_get_allocated_size(ptr) : 0;
|
||||
size_t next_used = (used >= old_size) ? (used - old_size) : 0;
|
||||
if (next_used >= kBudget || new_size > kBudget - next_used) return nullptr;
|
||||
void* p = heap_caps_realloc(ptr, new_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
void* p = heap_caps_realloc(ptr, new_size, kNeighborsAllocCaps);
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
if (!p) p = heap_caps_realloc(ptr, new_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||
#endif
|
||||
if (p) used = next_used + new_size;
|
||||
return p;
|
||||
}
|
||||
};
|
||||
#else
|
||||
static void* neighborsAlloc(size_t size) { return size ? malloc(size) : nullptr; }
|
||||
static void neighborsFree(void* ptr) { free(ptr); }
|
||||
#endif
|
||||
|
||||
// Build the neighbors-table JSON and hand it to the bridge, then reschedule.
|
||||
void MyMesh::finishNeighborDiscover() {
|
||||
getLocalScopes(self_scopes_buf, sizeof(self_scopes_buf));
|
||||
|
||||
char self_pubkey_hex[65];
|
||||
mesh::Utils::toHex(self_pubkey_hex, self_id.pub_key, PUB_KEY_SIZE);
|
||||
|
||||
char origin[32];
|
||||
MQTTBridge::getEffectiveMqttOrigin(_prefs.node_name, _cli.getObserverPrefs(), origin, sizeof(origin));
|
||||
|
||||
char timestamp[40];
|
||||
MQTTMessageBuilder::formatIsoTimestampForMqtt(getRTCClock()->getCurrentTime(), 0, nullptr, timestamp, sizeof(timestamp));
|
||||
|
||||
struct NeighborPublishWorkspace {
|
||||
char pubkey_hex[MAX_NEIGHBOURS][65];
|
||||
MQTTMessageBuilder::NeighborsMessageEntry entries[MAX_NEIGHBOURS];
|
||||
};
|
||||
#if defined(ESP_PLATFORM)
|
||||
auto* publish_workspace = static_cast<NeighborPublishWorkspace*>(
|
||||
heap_caps_malloc(sizeof(NeighborPublishWorkspace), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
|
||||
#else
|
||||
auto* publish_workspace = static_cast<NeighborPublishWorkspace*>(
|
||||
malloc(sizeof(NeighborPublishWorkspace)));
|
||||
#endif
|
||||
if (!publish_workspace) {
|
||||
neighbor_discover_active = false;
|
||||
neighbor_discover_count = 0;
|
||||
if (_cli.getObserverPrefs()->mqtt_neighbors_enabled) {
|
||||
next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval);
|
||||
}
|
||||
return;
|
||||
}
|
||||
auto& pubkey_hex = publish_workspace->pubkey_hex;
|
||||
auto& entries = publish_workspace->entries;
|
||||
uint32_t now_secs = getRTCClock()->getCurrentTime();
|
||||
// The entry table plus one hex string each reaches ~4.5 KB at MAX_NEIGHBOURS,
|
||||
// which does not fit the mesh loop task's 8 KB stack, so both share a single
|
||||
// heap block sized to this pass. Publishing is skipped if either alloc fails.
|
||||
const int publish_count = neighbor_discover_publish_count;
|
||||
const size_t hex_size = PUB_KEY_SIZE * 2 + 1;
|
||||
const size_t entries_bytes =
|
||||
sizeof(MQTTMessageBuilder::NeighborsMessageEntry) * publish_count;
|
||||
void* scratch = neighborsAlloc(entries_bytes + hex_size * publish_count);
|
||||
char* json_buf = (char*)neighborsAlloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE);
|
||||
|
||||
for (int i = 0; i < neighbor_discover_count; i++) {
|
||||
auto& nb = neighbours[neighbor_discover[i].neighbour_idx];
|
||||
mesh::Utils::toHex(pubkey_hex[i], nb.id.pub_key, PUB_KEY_SIZE);
|
||||
entries[i].pubkey_hex = pubkey_hex[i];
|
||||
entries[i].snr = nb.snr / 4.0f;
|
||||
entries[i].heard_secs_ago = (nb.heard_timestamp > 0 && now_secs >= nb.heard_timestamp)
|
||||
? (now_secs - nb.heard_timestamp) : 0;
|
||||
entries[i].scopes = neighbor_discover[i].scopes;
|
||||
switch (neighbor_discover[i].status) {
|
||||
case ND_RESPONDED: entries[i].status = "responded"; break;
|
||||
case ND_SEND_FAILED: entries[i].status = "send_failed"; break;
|
||||
default: entries[i].status = "timeout"; break;
|
||||
if (json_buf && (scratch || publish_count == 0)) {
|
||||
auto* entries = (MQTTMessageBuilder::NeighborsMessageEntry*)scratch;
|
||||
char* pubkey_hex = (char*)scratch + entries_bytes;
|
||||
uint32_t now_secs = getRTCClock()->getCurrentTime();
|
||||
|
||||
for (int i = 0; i < publish_count; i++) {
|
||||
auto& entry = neighbor_discover[i];
|
||||
char* hex = &pubkey_hex[i * hex_size];
|
||||
mesh::Utils::toHex(hex, entry.id.pub_key, PUB_KEY_SIZE);
|
||||
entries[i].pubkey_hex = hex;
|
||||
entries[i].snr = entry.snr / 4.0f;
|
||||
bool heard_known = neighborHeardAgeUsable(entry.heard_timestamp, now_secs);
|
||||
entries[i].heard_unknown = !heard_known;
|
||||
entries[i].heard_secs_ago = heard_known ? (now_secs - entry.heard_timestamp) : 0;
|
||||
entries[i].scopes = entry.scopes;
|
||||
switch (entry.status) {
|
||||
case ND_RESPONDED: entries[i].status = "responded"; break;
|
||||
case ND_SEND_FAILED: entries[i].status = "send_failed"; break;
|
||||
default: entries[i].status = "timeout"; break;
|
||||
}
|
||||
}
|
||||
|
||||
// insertion sort: most useful first (JSON builder drops the tail on overflow)
|
||||
for (int i = 1; i < publish_count; i++) {
|
||||
MQTTMessageBuilder::NeighborsMessageEntry entry = entries[i];
|
||||
int j = i;
|
||||
while (j > 0 && neighborPublishEntryComesBefore(entry, entries[j - 1])) {
|
||||
entries[j] = entries[j - 1];
|
||||
j--;
|
||||
}
|
||||
entries[j] = entry;
|
||||
}
|
||||
|
||||
#if defined(ESP_PLATFORM)
|
||||
NeighborsDocAllocator doc_alloc;
|
||||
JsonDocument doc(&doc_alloc);
|
||||
#else
|
||||
JsonDocument doc;
|
||||
#endif
|
||||
int json_len = MQTTMessageBuilder::buildNeighborsMessage(
|
||||
doc, neighbor_discover_origin, self_pubkey_hex, timestamp, self_scopes_buf,
|
||||
self_default_scope_buf, entries, publish_count,
|
||||
json_buf, MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE,
|
||||
neighbor_discover_count, neighbor_discover_queried_count,
|
||||
neighbor_discover_truncated);
|
||||
|
||||
if (json_len > 0 && bridge) {
|
||||
bridge->requestPublishNeighbors(json_buf, (size_t)json_len);
|
||||
}
|
||||
}
|
||||
|
||||
// insertion sort: most useful first (JSON builder drops the tail on overflow)
|
||||
for (int i = 1; i < neighbor_discover_count; i++) {
|
||||
MQTTMessageBuilder::NeighborsMessageEntry entry = entries[i];
|
||||
int j = i;
|
||||
while (j > 0 && neighborPublishEntryComesBefore(entry, entries[j - 1])) {
|
||||
entries[j] = entries[j - 1];
|
||||
j--;
|
||||
}
|
||||
entries[j] = entry;
|
||||
}
|
||||
|
||||
#if defined(ESP_PLATFORM)
|
||||
char* json_buf = (char*)heap_caps_malloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE, MALLOC_CAP_SPIRAM);
|
||||
#else
|
||||
char* json_buf = (char*)malloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE);
|
||||
#endif
|
||||
if (!json_buf) {
|
||||
#if defined(ESP_PLATFORM)
|
||||
heap_caps_free(publish_workspace);
|
||||
#else
|
||||
free(publish_workspace);
|
||||
#endif
|
||||
neighbor_discover_active = false;
|
||||
neighbor_discover_count = 0;
|
||||
if (_cli.getObserverPrefs()->mqtt_neighbors_enabled) {
|
||||
next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(ESP_PLATFORM)
|
||||
NeighborsDocAllocator doc_alloc;
|
||||
JsonDocument doc(&doc_alloc);
|
||||
#else
|
||||
JsonDocument doc;
|
||||
#endif
|
||||
int json_len = MQTTMessageBuilder::buildNeighborsMessage(
|
||||
doc, origin, self_pubkey_hex, timestamp, self_scopes_buf,
|
||||
entries, neighbor_discover_count,
|
||||
json_buf, MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE);
|
||||
|
||||
if (json_len > 0 && bridge) {
|
||||
bridge->requestPublishNeighbors(json_buf, (size_t)json_len);
|
||||
}
|
||||
|
||||
#if defined(ESP_PLATFORM)
|
||||
heap_caps_free(json_buf);
|
||||
heap_caps_free(publish_workspace);
|
||||
#else
|
||||
free(json_buf);
|
||||
free(publish_workspace);
|
||||
#endif
|
||||
neighborsFree(scratch);
|
||||
neighborsFree(json_buf);
|
||||
|
||||
neighbor_discover_active = false;
|
||||
neighbor_discover_count = 0;
|
||||
neighbor_discover_next = 0;
|
||||
neighbor_discover_publish_count = 0;
|
||||
neighbor_discover_queried_count = 0;
|
||||
neighbor_discover_json_size = 0;
|
||||
neighbor_discover_truncated = false;
|
||||
neighbor_discover_until = 0;
|
||||
neighbor_discover_request = NULL;
|
||||
if (_cli.getObserverPrefs()->mqtt_neighbors_enabled) {
|
||||
next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the scope-query phase; publish once all entries resolve or the window
|
||||
// times out (stragglers marked ND_TIMEOUT).
|
||||
// Advance the newest-first scope-query phase. Keep only one request in flight so
|
||||
// its responder gets a clear reply opportunity and the packet pool stays free.
|
||||
void MyMesh::loopNeighborDiscover() {
|
||||
if (!neighbor_discover_active) return;
|
||||
|
||||
bool all_done = true;
|
||||
for (int i = 0; i < neighbor_discover_count; i++) {
|
||||
if (neighbor_discover[i].status == ND_PENDING) { all_done = false; break; }
|
||||
if (neighbor_discover_next >= neighbor_discover_count) {
|
||||
finishNeighborDiscover();
|
||||
return;
|
||||
}
|
||||
if (!all_done && !millisHasNowPassed(neighbor_discover_until)) return;
|
||||
if (!all_done) {
|
||||
for (int i = 0; i < neighbor_discover_count; i++) {
|
||||
if (neighbor_discover[i].status == ND_PENDING) neighbor_discover[i].status = ND_TIMEOUT;
|
||||
|
||||
NeighborDiscoverEntry& entry = neighbor_discover[neighbor_discover_next];
|
||||
if (entry.status == ND_QUEUED) {
|
||||
if (!millisHasNowPassed(neighbor_discover_until)) return;
|
||||
if (cancelNeighborDiscoverRequest()) {
|
||||
entry.status = ND_SEND_FAILED;
|
||||
completeNeighborDiscoverEntry();
|
||||
return;
|
||||
}
|
||||
if (isCurrentOutbound(neighbor_discover_request)) {
|
||||
neighbor_discover_until = futureMillis(neighborDiscoverQueryTimeoutMs());
|
||||
return;
|
||||
}
|
||||
neighbor_discover_request = NULL; // packet manager already shed it
|
||||
entry.status = ND_SEND_FAILED;
|
||||
completeNeighborDiscoverEntry();
|
||||
return;
|
||||
}
|
||||
if (entry.status == ND_PENDING) {
|
||||
if (!millisHasNowPassed(neighbor_discover_until)) return;
|
||||
entry.status = ND_TIMEOUT;
|
||||
completeNeighborDiscoverEntry();
|
||||
return;
|
||||
}
|
||||
if (entry.status == ND_RESPONDED || entry.status == ND_SEND_FAILED
|
||||
|| entry.status == ND_TIMEOUT) {
|
||||
completeNeighborDiscoverEntry();
|
||||
return;
|
||||
}
|
||||
if (entry.status != ND_UNSENT) {
|
||||
neighbor_discover_next++;
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t tag;
|
||||
mesh::Packet* request = sendAnonRegionsReq(entry.id, tag);
|
||||
if (request) {
|
||||
entry.tag = tag;
|
||||
entry.status = ND_QUEUED;
|
||||
neighbor_discover_request = request;
|
||||
neighbor_discover_until = futureMillis(NEIGHBOR_DISCOVER_QUEUE_TIMEOUT_MS);
|
||||
} else {
|
||||
entry.status = ND_SEND_FAILED;
|
||||
completeNeighborDiscoverEntry();
|
||||
}
|
||||
finishNeighborDiscover();
|
||||
}
|
||||
|
||||
// Shared precondition for starting a discovery: PSRAM present + bridge running.
|
||||
// Shared precondition for starting a discovery: usable buffers + bridge running.
|
||||
// PSRAM builds size their neighbors buffers for PSRAM, so a board whose PSRAM
|
||||
// failed to init must not silently spend that much internal DRAM here.
|
||||
// MQTT_NEIGHBORS_WITHOUT_PSRAM builds are already sized for internal DRAM.
|
||||
bool MyMesh::neighborDiscoverReady(char* reply) {
|
||||
#if defined(ESP_PLATFORM)
|
||||
#if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM)
|
||||
if (!psramFound()) { strcpy(reply, "Err - PSRAM not available"); return false; }
|
||||
#endif
|
||||
if (!bridge || !bridge->isRunning()) { strcpy(reply, "Err - MQTT bridge not running"); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
// Snapshot the neighbor table into the overlay and fire one anon-regions query
|
||||
// per heard neighbour; arm the 30s scope-query window.
|
||||
// Snapshot the neighbor table newest-first. loopNeighborDiscover() emits one
|
||||
// anon-regions query at a time so hidden responders do not reply as a burst.
|
||||
bool MyMesh::startNeighborDiscover(char* reply) {
|
||||
if (neighbor_discover_active) {
|
||||
strcpy(reply, "Err - neighbor discover already active");
|
||||
@@ -2662,30 +2857,48 @@ bool MyMesh::startNeighborDiscover(char* reply) {
|
||||
return false; // reply already set
|
||||
}
|
||||
|
||||
getLocalScopes(self_scopes_buf, sizeof(self_scopes_buf));
|
||||
neighbor_discover_count = 0;
|
||||
for (int i = 0; i < MAX_NEIGHBOURS; i++) {
|
||||
if (neighbours[i].heard_timestamp > 0) {
|
||||
neighbor_discover[neighbor_discover_count].neighbour_idx = (uint8_t)i;
|
||||
neighbor_discover[neighbor_discover_count].scopes[0] = 0;
|
||||
neighbor_discover[neighbor_discover_count].status = ND_PENDING;
|
||||
uint32_t tag;
|
||||
if (sendAnonRegionsReq(neighbours[i].id, tag)) {
|
||||
neighbor_discover[neighbor_discover_count].tag = tag;
|
||||
} else {
|
||||
neighbor_discover[neighbor_discover_count].status = ND_SEND_FAILED;
|
||||
}
|
||||
NeighborDiscoverEntry& entry = neighbor_discover[neighbor_discover_count];
|
||||
entry.id = neighbours[i].id;
|
||||
entry.heard_timestamp = neighbours[i].heard_timestamp;
|
||||
entry.snr = neighbours[i].snr;
|
||||
entry.scopes[0] = 0;
|
||||
entry.tag = 0;
|
||||
entry.status = ND_UNSENT;
|
||||
neighbor_discover_count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Query the freshest/strongest entries first; pubkey makes ties deterministic.
|
||||
for (int i = 1; i < neighbor_discover_count; i++) {
|
||||
NeighborDiscoverEntry entry = neighbor_discover[i];
|
||||
int j = i;
|
||||
while (j > 0) {
|
||||
auto& rhs = neighbor_discover[j - 1];
|
||||
bool before = entry.heard_timestamp > rhs.heard_timestamp
|
||||
|| (entry.heard_timestamp == rhs.heard_timestamp && entry.snr > rhs.snr)
|
||||
|| (entry.heard_timestamp == rhs.heard_timestamp && entry.snr == rhs.snr
|
||||
&& memcmp(entry.id.pub_key, rhs.id.pub_key, PUB_KEY_SIZE) < 0);
|
||||
if (!before) break;
|
||||
neighbor_discover[j] = neighbor_discover[j - 1];
|
||||
j--;
|
||||
}
|
||||
neighbor_discover[j] = entry;
|
||||
}
|
||||
|
||||
neighbor_discover_next = 0;
|
||||
resetNeighborDiscoverJsonBudget();
|
||||
neighbor_discover_active = true;
|
||||
neighbor_discover_until = futureMillis(NEIGHBOR_DISCOVER_TIMEOUT_MS);
|
||||
neighbor_discover_until = 0;
|
||||
neighbor_discover_request = NULL;
|
||||
|
||||
if (neighbor_discover_count == 0) {
|
||||
finishNeighborDiscover();
|
||||
strcpy(reply, "OK - neighbor discover started (0 neighbors, self only)");
|
||||
} else {
|
||||
loopNeighborDiscover(); // queue the first request now
|
||||
sprintf(reply, "OK - neighbor discover started (%u neighbors)", (unsigned)neighbor_discover_count);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -180,37 +180,55 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
|
||||
uint32_t pending_discover_tag;
|
||||
unsigned long pending_discover_until;
|
||||
enum NeighborDiscoverStatus : uint8_t {
|
||||
ND_PENDING = 1,
|
||||
ND_RESPONDED = 2,
|
||||
ND_TIMEOUT = 3,
|
||||
ND_SEND_FAILED = 4,
|
||||
ND_UNSENT = 0,
|
||||
ND_QUEUED = 1,
|
||||
ND_PENDING = 2,
|
||||
ND_RESPONDED = 3,
|
||||
ND_TIMEOUT = 4,
|
||||
ND_SEND_FAILED = 5,
|
||||
};
|
||||
struct NeighborDiscoverEntry {
|
||||
uint8_t neighbour_idx;
|
||||
mesh::Identity id;
|
||||
uint32_t heard_timestamp;
|
||||
int8_t snr;
|
||||
uint32_t tag;
|
||||
char scopes[96];
|
||||
uint8_t status;
|
||||
};
|
||||
NeighborDiscoverEntry neighbor_discover[MAX_NEIGHBOURS];
|
||||
uint8_t neighbor_discover_count;
|
||||
uint8_t neighbor_discover_next;
|
||||
uint8_t neighbor_discover_publish_count;
|
||||
uint8_t neighbor_discover_queried_count; // requests confirmed transmitted
|
||||
size_t neighbor_discover_json_size;
|
||||
bool neighbor_discover_truncated;
|
||||
bool neighbor_discover_active;
|
||||
bool neighbor_table_refresh_active;
|
||||
bool neighbor_table_refresh_periodic;
|
||||
unsigned long neighbor_discover_until;
|
||||
mesh::Packet* neighbor_discover_request;
|
||||
unsigned long next_neighbors_publish;
|
||||
char self_scopes_buf[96];
|
||||
char self_default_scope_buf[31];
|
||||
char neighbor_discover_origin[32];
|
||||
|
||||
void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr);
|
||||
void sendNodeDiscoverReq();
|
||||
bool sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag);
|
||||
mesh::Packet* sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag);
|
||||
bool cancelNeighborDiscoverRequest();
|
||||
uint32_t neighborDiscoverQueryTimeoutMs() const;
|
||||
bool completeNeighborDiscoverEntry();
|
||||
void resetNeighborDiscoverJsonBudget();
|
||||
bool neighborDiscoverReady(char* reply);
|
||||
bool startNeighborDiscover(char* reply);
|
||||
void loopNeighborDiscover();
|
||||
void finishNeighborDiscover();
|
||||
bool handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len);
|
||||
void touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp);
|
||||
void getLocalScopes(char* buf, size_t len);
|
||||
static const int NEIGHBOR_DISCOVER_PEER_BASE = 1000;
|
||||
static const unsigned long NEIGHBOR_DISCOVER_TIMEOUT_MS = 30000;
|
||||
static const unsigned long NEIGHBOR_DISCOVER_QUEUE_TIMEOUT_MS = 29000;
|
||||
static const int NEIGHBOR_DISCOVER_MIN_FREE_PACKETS = 5;
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
MQTTBridge* bridge;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include "UITask.h"
|
||||
static UITask ui_task(display);
|
||||
static bool display_ready = false;
|
||||
#endif
|
||||
|
||||
StdRNG fast_rng;
|
||||
@@ -45,7 +46,8 @@ void setup() {
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (display.begin()) {
|
||||
display_ready = display.begin();
|
||||
if (display_ready) {
|
||||
display.startFrame();
|
||||
display.setCursor(0, 0);
|
||||
display.print("Please wait...");
|
||||
@@ -113,7 +115,9 @@ void setup() {
|
||||
the_mesh.begin(fs);
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
|
||||
if (display_ready) {
|
||||
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef ETHERNET_ENABLED
|
||||
@@ -179,7 +183,7 @@ void loop() {
|
||||
the_mesh.loop();
|
||||
sensors.loop();
|
||||
#ifdef DISPLAY_CLASS
|
||||
ui_task.loop();
|
||||
if (display_ready) ui_task.loop();
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
|
||||
@@ -915,7 +915,6 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise
|
||||
radio_apply_failures = 0;
|
||||
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
_prefs.airtime_factor = 1.0; // one half
|
||||
_prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0;
|
||||
_prefs.tx_delay_factor = 0.5f; // was 0.25f
|
||||
|
||||
@@ -125,6 +125,8 @@ build_flags = ${arduino_base.build_flags}
|
||||
lib_deps =
|
||||
${arduino_base.lib_deps}
|
||||
https://github.com/oltaco/CustomLFS#0.2.3
|
||||
; Vendored ESP-IDF MQTT client; the LDF otherwise pulls it into non-ESP builds.
|
||||
lib_ignore = PsychicMqttClient
|
||||
|
||||
; Shared LoRa OTA recipe for nRF52840 variants. nRF52840 has no A/B slot, so the update is
|
||||
; applied in place by the custom OTAFIX bootloader (in-place detools); the app only stages + verifies +
|
||||
|
||||
@@ -6,43 +6,81 @@
|
||||
# that replace extra_scripts still compile from a clean checkout; this script
|
||||
# refreshes it whenever the source page (or this script) is newer.
|
||||
#
|
||||
# Comments and indentation are stripped before compressing (see strip_source).
|
||||
# The source page is heavily commented by house style and none of it is worth
|
||||
# flash, so the page ships smaller than it reads.
|
||||
#
|
||||
# Output: src/helpers/esp32/WebConfigHtml.h
|
||||
# WEBCONFIG_HTML_GZ[] - gzipped page (PROGMEM)
|
||||
# WEBCONFIG_HTML_GZ_LEN - byte length
|
||||
# WEBCONFIG_HTML_ETAG - quoted strong ETag (sha256 prefix of the gz body)
|
||||
#
|
||||
# Runnable outside SCons to inspect exactly what gets shipped:
|
||||
# python3 scripts/generate_webconfig_html.py --emit /tmp/shipped.html
|
||||
# or served directly by the mock backend with its --minify flag.
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
# PlatformIO/SCons injects Import(). Keep the generator directly runnable as
|
||||
# well so maintainers can refresh and verify the tracked header without first
|
||||
# starting a firmware build.
|
||||
try:
|
||||
Import("env") # noqa: F821
|
||||
except NameError:
|
||||
pass
|
||||
pass # running standalone (--emit), not as a PIO extra_script
|
||||
|
||||
SOURCE = os.path.join("webui", "index.html")
|
||||
OUTPUT = os.path.join("src", "helpers", "esp32", "WebConfigHtml.h")
|
||||
# __file__ is not defined inside PIO/SCons-executed extra_scripts
|
||||
SCRIPT = os.path.join("scripts", "generate_webconfig_html.py")
|
||||
MINIFIER = os.path.join("scripts", "webconfig_minify.py")
|
||||
HASH_MARKER = "// build-inputs-sha256: "
|
||||
|
||||
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
|
||||
from webconfig_minify import check_stripped, strip_source # noqa: E402
|
||||
|
||||
|
||||
def status(msg):
|
||||
sys.stderr.write("WebConfig HTML: %s\n" % msg)
|
||||
|
||||
|
||||
def needs_rebuild():
|
||||
if not os.path.isfile(OUTPUT):
|
||||
return True
|
||||
out_mtime = os.path.getmtime(OUTPUT)
|
||||
if os.path.getmtime(SOURCE) > out_mtime:
|
||||
return True
|
||||
if os.path.isfile(SCRIPT) and os.path.getmtime(SCRIPT) > out_mtime:
|
||||
return True
|
||||
return False
|
||||
def content_hash():
|
||||
# Hash the source page, this generator and the minifier so any change to
|
||||
# any of them forces a regenerate, independent of file timestamps.
|
||||
h = hashlib.sha256()
|
||||
with open(SOURCE, "rb") as f:
|
||||
h.update(f.read())
|
||||
for path in (SCRIPT, MINIFIER):
|
||||
if os.path.isfile(path):
|
||||
h.update(b"\0")
|
||||
with open(path, "rb") as f:
|
||||
h.update(f.read())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def stored_hash():
|
||||
try:
|
||||
with open(OUTPUT, "r") as f:
|
||||
for line in f:
|
||||
if line.startswith(HASH_MARKER):
|
||||
return line[len(HASH_MARKER):].strip()
|
||||
if line.startswith("#"): # reached the C preprocessor lines
|
||||
break
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def shipped_page():
|
||||
"""The exact bytes the device serves: the source page, stripped."""
|
||||
with open(SOURCE, "r", encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
stripped = strip_source(raw)
|
||||
problem = check_stripped(raw, stripped)
|
||||
if problem:
|
||||
status("ERROR: comment stripping corrupted the page (%s)" % problem)
|
||||
sys.exit(2)
|
||||
return raw, stripped
|
||||
|
||||
|
||||
def main():
|
||||
@@ -50,11 +88,20 @@ def main():
|
||||
status("ERROR: %s not found" % SOURCE)
|
||||
sys.exit(2)
|
||||
|
||||
if not needs_rebuild():
|
||||
if "--emit" in sys.argv:
|
||||
dest = sys.argv[sys.argv.index("--emit") + 1]
|
||||
src, stripped = shipped_page()
|
||||
with open(dest, "w", encoding="utf-8") as f:
|
||||
f.write(stripped)
|
||||
status("%s -> %s (%d -> %d bytes)" % (SOURCE, dest, len(src), len(stripped)))
|
||||
return
|
||||
|
||||
with open(SOURCE, "rb") as f:
|
||||
raw = f.read()
|
||||
src_hash = content_hash()
|
||||
if os.path.isfile(OUTPUT) and stored_hash() == src_hash:
|
||||
return
|
||||
|
||||
src, stripped = shipped_page()
|
||||
raw = stripped.encode("utf-8")
|
||||
|
||||
# mtime=0 keeps the gzip output (and therefore the ETag) deterministic
|
||||
gz = gzip.compress(raw, compresslevel=9, mtime=0)
|
||||
@@ -63,6 +110,7 @@ def main():
|
||||
lines = []
|
||||
lines.append("// Auto-generated by scripts/generate_webconfig_html.py from %s" % SOURCE.replace(os.sep, "/"))
|
||||
lines.append("// DO NOT EDIT - edit webui/index.html instead.")
|
||||
lines.append("%s%s" % (HASH_MARKER, src_hash))
|
||||
lines.append("#pragma once")
|
||||
lines.append("#include <stdint.h>")
|
||||
lines.append("#include <pgmspace.h>")
|
||||
@@ -80,7 +128,8 @@ def main():
|
||||
with open(OUTPUT, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
status("%s -> %s (%d bytes raw, %d bytes gzipped)" % (SOURCE, OUTPUT, len(raw), len(gz)))
|
||||
status("%s -> %s (%d bytes source, %d stripped, %d gzipped)"
|
||||
% (SOURCE, OUTPUT, len(src.encode("utf-8")), len(raw), len(gz)))
|
||||
|
||||
|
||||
# This script is attached only to esp32_base. Generate unconditionally here:
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the portal terminal's command table against the mock backend.
|
||||
|
||||
Autocomplete in webui/index.html carries its own list of commands. Nothing ties
|
||||
that list to what a node actually answers, so it can quietly drift into offering
|
||||
commands that do not exist -- or, more often here, the mock can lag the table and
|
||||
make a perfectly real command look broken.
|
||||
|
||||
This drives every command the table offers through /api/cli and reports the ones
|
||||
that come back an error, so the two stay honest about each other.
|
||||
|
||||
python3 scripts/webconfig_mock_server.py --port 8137 &
|
||||
python3 scripts/webconfig_cli_audit.py
|
||||
|
||||
Exits non-zero if anything fails that is not in EXPECTED_FAILURES. Stdlib only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ.get("WEBCONFIG_MOCK", "http://localhost:8137")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html")
|
||||
|
||||
# Errors that are the correct answer, not a gap.
|
||||
EXPECTED_FAILURES = {
|
||||
# Runtime-gated on the real device by Board::canControlLoRaFemLna(); the
|
||||
# command exists in every build and the board answers for itself. The mock
|
||||
# board is a Heltec V3, which has no front-end module.
|
||||
"get radio.fem.rxgain": "unsupported",
|
||||
"set radio.fem.rxgain on": "unsupported",
|
||||
# Guarded by the firmware the same way when no alert PSK is configured.
|
||||
"alert test": "not configured",
|
||||
}
|
||||
|
||||
# Commands that change the node out from under the audit.
|
||||
SKIP = {"reboot", "clkreboot", "poweroff", "shutdown", "erase", "start ota",
|
||||
"stop webconfig", "ota update", "start webconfig", "start webconfig ap"}
|
||||
|
||||
|
||||
def table():
|
||||
"""The commands autocomplete offers, read straight out of the page."""
|
||||
html = open(INDEX_HTML, encoding="utf-8").read()
|
||||
|
||||
def section(start, end):
|
||||
return html[html.index(start):html.index(end)]
|
||||
|
||||
verbs = re.findall(r'\["([^"]+)","', section("var CLI_VERBS=", "var CLI_KEYS="))
|
||||
keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)',
|
||||
section("var CLI_KEYS=", "var CLI_SLOT="))
|
||||
fields = re.findall(r'\["(\w+)","', section("var CLI_SLOT=", "var CLI_TYPES="))
|
||||
|
||||
gets = ["get " + k for k, mode in keys if mode != "2"]
|
||||
gets += ["get mqtt%d.%s" % (n, f) for n in (1, 3) for f in fields]
|
||||
# Verbs taking an argument need a value the node will accept; those are
|
||||
# covered by the round-trip probes below rather than guessed at here.
|
||||
plain = [v for v in verbs if not v.endswith(" ") and v not in SKIP]
|
||||
return gets + plain
|
||||
|
||||
|
||||
# Where top-level commands are implemented. MyMesh handles a few before
|
||||
# delegating to CommonCLI, which is exactly how discover.* stayed missing from
|
||||
# the table for so long: grepping CommonCLI alone does not see them.
|
||||
COMMAND_SOURCES = [
|
||||
"src/helpers/CommonCLI.cpp",
|
||||
"src/helpers/CommonCLI_Observer.cpp",
|
||||
"examples/simple_repeater/MyMesh.cpp",
|
||||
]
|
||||
|
||||
# Firmware commands the table deliberately does not offer. Everything below
|
||||
# except tls.bundletest is also rejected by /api/cli (wcCliUnavailable), so the
|
||||
# portal never pretends to run something it cannot.
|
||||
NOT_OFFERED = {
|
||||
"tls.bundletest", # TLS debugging, not an operator command
|
||||
"start ota", # binds port 80, which the portal is already using
|
||||
"clock sync", # takes its time from the caller; a web request has none
|
||||
"log", # streams to Serial and stalls the radio ("log start" is offered)
|
||||
"get acl", # streams to Serial, returns nothing
|
||||
}
|
||||
|
||||
|
||||
def firmware_commands():
|
||||
"""Top-level command literals the firmware dispatches on."""
|
||||
found = set()
|
||||
for rel in COMMAND_SOURCES:
|
||||
path = os.path.join(HERE, "..", rel)
|
||||
try:
|
||||
src = open(path, encoding="utf-8").read()
|
||||
except OSError:
|
||||
continue
|
||||
for lit in re.findall(r'(?:mem|str)n?cmp\(\s*command\s*,\s*"([^"]+)"', src):
|
||||
found.add(lit.strip())
|
||||
return found - NOT_OFFERED
|
||||
ROUND_TRIPS = [
|
||||
("set radio.watchdog 30", "get radio.watchdog", "30"),
|
||||
("set dutycycle 25", "get dutycycle", "25.0"),
|
||||
("set alert.mqtt on", "get alert.mqtt", "on"),
|
||||
("set bridge.source tx", "get bridge.source", "tx"),
|
||||
("set mqtt.neighbors on", "get mqtt.neighbors", "on"),
|
||||
("set path.hash.mode 2", "get path.hash.mode", "2"),
|
||||
("set mqtt.iata den", "get mqtt.iata", "DEN"),
|
||||
# Secret reads are masked back down for an HTTP caller, in CommonCLI's own
|
||||
# words for a non-serial one (wcIsSecretReadCommand).
|
||||
("set guest.password hunter2", "get guest.password", "******** (serial only)"),
|
||||
("set wifi.pwd hunter2", "get wifi.pwd", "******** (serial only)"),
|
||||
]
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base):
|
||||
self.base = base
|
||||
r = self._open("/api/login", b'{"password":"password"}')
|
||||
self.cookie = r.headers["Set-Cookie"].split(";")[0]
|
||||
# The node caps a sequence at MAX_BATCH and reports it; chunk to match
|
||||
# rather than hardcoding a number that drifts when the slot is resized.
|
||||
self.max_cmds = json.load(self._open("/api/status")).get("max_cmds", 24)
|
||||
|
||||
def _open(self, path, data=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if getattr(self, "cookie", None):
|
||||
headers["Cookie"] = self.cookie
|
||||
return urllib.request.urlopen(urllib.request.Request(
|
||||
self.base + path, data=data, headers=headers,
|
||||
method="POST" if data is not None else "GET"))
|
||||
|
||||
def run(self, cmds):
|
||||
"""[(command, result)]. The node never echoes the command back -- it may
|
||||
carry a secret -- so results pair with what was sent, by index."""
|
||||
out = []
|
||||
for i in range(0, len(cmds), self.max_cmds):
|
||||
chunk = cmds[i:i + self.max_cmds]
|
||||
results = self._sequence(chunk)
|
||||
if len(results) != len(chunk):
|
||||
sys.exit("node returned %d results for %d commands" % (len(results), len(chunk)))
|
||||
out += list(zip(chunk, results))
|
||||
return out
|
||||
|
||||
def _sequence(self, cmds):
|
||||
reqid = secrets.token_hex(8)
|
||||
body = json.dumps({"reqid": reqid, "cmds": cmds}).encode()
|
||||
for _ in range(200): # the executor frees itself in time
|
||||
try:
|
||||
self._open("/api/cli", body)
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 409:
|
||||
raise
|
||||
time.sleep(0.5)
|
||||
# Results stream and page, so keep reading from a cursor until the node
|
||||
# says done -- "done" arrives only once every result has been handed over.
|
||||
out = []
|
||||
while True:
|
||||
r = json.load(self._open("/api/cli/result?reqid=%s&from=%d" % (reqid, len(out))))
|
||||
out += r.get("results", [])
|
||||
if r["state"] == "done":
|
||||
return out
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
cli = Client(BASE)
|
||||
except OSError as e:
|
||||
sys.exit("cannot reach the mock at %s (%s)\n"
|
||||
"start it with: python3 scripts/webconfig_mock_server.py --port 8137" % (BASE, e))
|
||||
|
||||
failures = []
|
||||
|
||||
cmds = table()
|
||||
unexpected = []
|
||||
for cmd, res in cli.run(cmds):
|
||||
if res["ok"]:
|
||||
continue
|
||||
want = EXPECTED_FAILURES.get(cmd)
|
||||
if want and want in res["reply"]:
|
||||
continue
|
||||
unexpected.append((cmd, res["reply"]))
|
||||
print("commands offered by autocomplete : %d" % len(cmds))
|
||||
print("answered : %d" % (len(cmds) - len(unexpected)))
|
||||
print("sequence cap reported by the node: %d" % cli.max_cmds)
|
||||
for cmd, reply in unexpected:
|
||||
print(" FAIL %-30s %s" % (cmd, reply))
|
||||
failures += unexpected
|
||||
|
||||
# The reverse direction: a command the firmware implements but the table
|
||||
# never offers is invisible to the check above, because the check only ever
|
||||
# drives what the table already knows about.
|
||||
offered = " ".join(cmds) + " " + " ".join(
|
||||
re.findall(r'\["([^"]+)","', open(INDEX_HTML, encoding="utf-8").read()))
|
||||
missing = sorted(c for c in firmware_commands() if c not in offered)
|
||||
print("\nfirmware commands not in the table: %d" % len(missing))
|
||||
for c in missing:
|
||||
print(" MISSING %s" % c)
|
||||
failures += [(c, "not offered by autocomplete") for c in missing]
|
||||
|
||||
results = cli.run([c for probe in ROUND_TRIPS for c in probe[:2]])
|
||||
print("\nround-trips : %d" % len(ROUND_TRIPS))
|
||||
for i, (setc, getc, want) in enumerate(ROUND_TRIPS):
|
||||
setr, getr = results[i * 2][1], results[i * 2 + 1][1]
|
||||
# `get` answers "> value"; compare the value, as the terminal displays it
|
||||
got = re.sub(r"^>\s?", "", getr["reply"])
|
||||
if setr["ok"] and got == want:
|
||||
continue
|
||||
print(" FAIL %-30s got %r, wanted %r (set: %s)"
|
||||
% (getc, got, want, setr["reply"]))
|
||||
failures.append((getc, got))
|
||||
|
||||
# Commands the portal refuses must be refused clearly, not run and fudged.
|
||||
print("\nrefused with a reason : ", end="")
|
||||
refused = []
|
||||
for cmd in sorted(NOT_OFFERED - {"tls.bundletest"}):
|
||||
try:
|
||||
cli._sequence([cmd])
|
||||
refused.append((cmd, "was accepted, expected a 400"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = json.load(e) if e.code == 400 else {}
|
||||
if e.code != 400 or not body.get("error"):
|
||||
refused.append((cmd, "HTTP %d, expected 400 with a reason" % e.code))
|
||||
print("%d/%d" % (len(NOT_OFFERED) - 1 - len(refused), len(NOT_OFFERED) - 1))
|
||||
for cmd, why in refused:
|
||||
print(" FAIL %-30s %s" % (cmd, why))
|
||||
failures += refused
|
||||
|
||||
print("\n%s" % ("FAILED: %d" % len(failures) if failures else "all clear"))
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Comment/indentation stripping for webui/index.html.
|
||||
|
||||
Shared by the build-time generator (which embeds the stripped page in flash)
|
||||
and the mock backend's --minify flag (which serves it), so what you test in a
|
||||
browser is byte-for-byte what the device ships. A second implementation would
|
||||
only drift.
|
||||
|
||||
Stdlib only; imported from a PIO extra_script, so it must stay side-effect free.
|
||||
"""
|
||||
|
||||
# Comment openers/closers per region of the page. The region is tracked so a
|
||||
# `<!--` inside <script> is never treated as a comment, and vice versa.
|
||||
_COMMENTS = {
|
||||
"html": [("<!--", "-->")],
|
||||
"css": [("/*", "*/")],
|
||||
"js": [("/*", "*/")],
|
||||
}
|
||||
|
||||
|
||||
def strip_source(text):
|
||||
"""Drop comments, indentation and blank lines from the page.
|
||||
|
||||
Deliberately line-based and conservative. Only a comment that *starts* its
|
||||
own line is removed, so a `//` inside a URL or a `/*` inside a regex is
|
||||
never mistaken for one; trailing comments survive, which costs a little
|
||||
flash and removes the entire class of "the minifier ate a string" bug.
|
||||
|
||||
Line breaks are preserved. That keeps JS statement boundaries exactly as
|
||||
written (no ASI surprises) and keeps the single collapsed space a newline
|
||||
contributes between HTML inline elements.
|
||||
"""
|
||||
out, mode, closer = [], "html", None
|
||||
for line in text.split("\n"):
|
||||
s = line.strip()
|
||||
|
||||
if closer is not None: # inside a multi-line comment
|
||||
at = s.find(closer)
|
||||
if at < 0:
|
||||
continue
|
||||
s = s[at + len(closer):].strip() # code may follow the close
|
||||
closer = None
|
||||
|
||||
if not s:
|
||||
continue
|
||||
|
||||
for opener, close in _COMMENTS[mode]:
|
||||
if not s.startswith(opener):
|
||||
continue
|
||||
if s.endswith(close) and len(s) > len(opener) + len(close) - 1:
|
||||
s = "" # the whole line is a comment
|
||||
elif close not in s[len(opener):]:
|
||||
closer = close # ... and it continues below
|
||||
s = ""
|
||||
break # else code follows the close
|
||||
if not s: # on this line: leave it be
|
||||
continue
|
||||
|
||||
if mode == "js" and s.startswith("//"):
|
||||
continue
|
||||
|
||||
out.append(s)
|
||||
|
||||
low = s.lower()
|
||||
if mode == "html" and "<style" in low:
|
||||
mode = "css"
|
||||
elif mode == "html" and "<script" in low:
|
||||
mode = "js"
|
||||
elif mode != "html" and ("</style>" in low or "</script>" in low):
|
||||
mode = "html"
|
||||
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def check_stripped(raw, stripped):
|
||||
"""Guard against a stripper bug silently shipping a broken portal to the
|
||||
fleet. Returns a reason string when the output looks wrong, else None."""
|
||||
for tag in ("<script>", "</script>", "<style>", "</style>", "</body>"):
|
||||
if raw.count(tag) != stripped.count(tag):
|
||||
return "%s count changed" % tag
|
||||
if len(stripped) < len(raw) * 0.5:
|
||||
return "output shrank by more than half (%d -> %d)" % (len(raw), len(stripped))
|
||||
# Only comments and whitespace may go, so no structural token may appear
|
||||
# that the source did not already have.
|
||||
for token in ("{", "}", "(", ")", "<script", "<style"):
|
||||
if stripped.count(token) > raw.count(token):
|
||||
return "gained a %s" % token
|
||||
return None
|
||||
@@ -9,6 +9,11 @@ pending -> done result polling, aggregate-success reboot gating, secret masking
|
||||
So the browser drives the actual portal JS (wizard, save/poll/reqid, effective
|
||||
value handling, reboot overlay, stats, scan) against realistic responses.
|
||||
|
||||
/api/cli is the CLI terminal's backend and has no firmware counterpart yet: it
|
||||
is the proposed contract (202 + reqid, streamed per-command results) executed
|
||||
against a CommonCLI-shaped interpreter, so the terminal UI can be designed
|
||||
against realistic single- and multi-line replies before any of it goes on-device.
|
||||
|
||||
It does NOT run the C++ handlers (that's what test/ gtest covers) or the
|
||||
AsyncTCP transport -- it's a frontend + contract harness.
|
||||
|
||||
@@ -28,6 +33,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -36,6 +42,18 @@ from urllib.parse import parse_qs, urlsplit
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html")
|
||||
|
||||
sys.path.insert(0, HERE)
|
||||
# The build-time comment stripper, shared so --minify serves byte-for-byte what
|
||||
# the generator embeds rather than a second implementation that could drift.
|
||||
from webconfig_minify import strip_source # noqa: E402
|
||||
|
||||
MINIFY = False
|
||||
# Overridable with --fw-version to exercise the console's channel labelling:
|
||||
# v1.16.0.5-observer-a1b2c3d release
|
||||
# v1.16.0.5-observer-beta-dev-a1b2c3d dev
|
||||
# v1.16.0 local build, no OTA
|
||||
FW_VERSION = "v1.16.0.5-observer-a1b2c3d"
|
||||
|
||||
SENTINEL = "********"
|
||||
ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build flag
|
||||
BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling
|
||||
@@ -47,9 +65,17 @@ LEN_LIMITS = {
|
||||
"name": 31, "wifi.ssid": 31, "wifi.pwd": 63, "mqtt.origin": 31,
|
||||
"mqtt.email": 63, "mqtt.ntp": 63, "timezone": 31, "snmp.community": 23,
|
||||
}
|
||||
# "filter" is absent on purpose: it is a bitmask, not a text buffer, so it has
|
||||
# no destination-buffer limit. It is still bounded by the shared CLI command
|
||||
# budget below, like every other key.
|
||||
SLOT_LEN_LIMITS = {"server": 63, "username": 31, "password": 63,
|
||||
"token": 47, "topic": 95, "audience": 63}
|
||||
|
||||
# BatchEntry::cmd[160] in WebConfigServer.cpp holds "set <key> <value>" plus a
|
||||
# NUL. Over-long values are rejected there rather than truncated, because a
|
||||
# clipped value can still be valid and would persist as a different setting.
|
||||
BATCH_CMD_SIZE = 160
|
||||
|
||||
# Preset names + what the UI must collect (mirrors handlePresets()).
|
||||
PRESETS = (
|
||||
[(n, "none") for n in (
|
||||
@@ -92,14 +118,30 @@ def default_config(setup_mode):
|
||||
"interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7,
|
||||
"ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False,
|
||||
"snmp_community": "public",
|
||||
"neighbors": False, "neighbors_interval": 24,
|
||||
"slots": [_slot() for _ in range(6)],
|
||||
},
|
||||
# Settings the CLI reaches but no portal form does, so they are absent
|
||||
# from /api/config (see config_json) and live only here. Without them
|
||||
# the terminal answers "unknown config key" for perfectly real commands.
|
||||
"cli": {
|
||||
"radio.watchdog": 0, "int.thresh": 0, "agc.reset.interval": 0,
|
||||
"direct.txdelay": 0.0, "multi.acks": 0, "allow.read.only": False,
|
||||
"path.hash.mode": 0, "owner.info": "", "guest.password": "",
|
||||
"adc.multiplier": 1.0,
|
||||
"alert": False, "alert.psk": "", "alert.hashtag": "",
|
||||
"alert.region": "", "alert.interval": 15,
|
||||
"alert.mqtt": False, "alert.wifi": False,
|
||||
"bridge.enabled": False, "bridge.source": "rx", "bridge.baud": 115200,
|
||||
"bridge.delay": 0, "bridge.channel": 0, "bridge.secret": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _slot():
|
||||
return {"preset": "none", "server": "", "port": 8883, "username": "",
|
||||
"password": "", "token": "", "topic": "", "audience": ""}
|
||||
"password": "", "token": "", "topic": "", "audience": "",
|
||||
"filter": "all"}
|
||||
|
||||
|
||||
class State:
|
||||
@@ -113,6 +155,8 @@ class State:
|
||||
self.start = time.time()
|
||||
self.session = None # cookie token when logged in (LAN mode)
|
||||
self.batch = {"state": "idle"}
|
||||
self.cli = {"state": "idle"} # deferred CLI sequence, see /api/cli
|
||||
self.admin_pwd_set = False # satisfies the initial-setup invariant
|
||||
self.scan_started = None
|
||||
|
||||
# ---- auth -------------------------------------------------------------
|
||||
@@ -128,6 +172,7 @@ class State:
|
||||
# ---- config serialization (masks secrets, like handleConfigGet) -------
|
||||
def config_json(self):
|
||||
c = copy.deepcopy(self.cfg)
|
||||
c.pop("cli") # CLI-only settings: not part of this contract
|
||||
c["wifi"]["pwd"] = SENTINEL if self.cfg["wifi"]["pwd"] else ""
|
||||
for s in c["mqtt"]["slots"]:
|
||||
s["password"] = SENTINEL if s["password"] else ""
|
||||
@@ -140,9 +185,14 @@ class State:
|
||||
"auth": authed,
|
||||
"needs_setup": self.cfg["wifi"]["ssid"] == "",
|
||||
"name": self.cfg["radio"]["name"], "node_id": "a1b2c3d4e5f60718",
|
||||
"fw": "v1.7.1-mock", "role": "Repeater", "board": "Heltec V3 (mock)",
|
||||
# Shaped like build.sh's EMBEDDED_VERSION_STRING
|
||||
# (base[.build][-observer][-channel]-hash) so the console's channel
|
||||
# labelling is exercised against a real version, not "v1.x-mock".
|
||||
"fw": FW_VERSION, "build_date": "6 Jun 2026",
|
||||
"role": "Repeater", "board": "Heltec V3 (mock)",
|
||||
"uptime_s": int(time.time() - self.start),
|
||||
"runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots,
|
||||
"max_cmds": CLI_MAX_CMDS,
|
||||
}
|
||||
|
||||
|
||||
@@ -153,13 +203,15 @@ class State:
|
||||
BOOL_KEYS = {"cad": ("radio", "cad"), "radio.rxgain": ("radio", "rxgain"),
|
||||
"repeat": ("radio", "repeat"), "mqtt.status": ("mqtt", "status"),
|
||||
"mqtt.packets": ("mqtt", "packets"), "mqtt.raw": ("mqtt", "raw"),
|
||||
"mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp")}
|
||||
"mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp"),
|
||||
"mqtt.neighbors": ("mqtt", "neighbors")}
|
||||
INT_KEYS = {"tx": ("radio", "tx"), "flood.max": ("radio", "flood_max"),
|
||||
"flood.max.advert": ("radio", "flood_max_advert"),
|
||||
"flood.max.unscoped": ("radio", "flood_max_unscoped"),
|
||||
"advert.interval": ("radio", "advert_interval"),
|
||||
"flood.advert.interval": ("radio", "flood_advert_interval"),
|
||||
"mqtt.interval": ("mqtt", "interval"),
|
||||
"mqtt.neighbors.interval": ("mqtt", "neighbors_interval"),
|
||||
"timezone.offset": ("mqtt", "timezone_offset")}
|
||||
FLOAT_KEYS = {"lat": ("radio", "lat"), "lon": ("radio", "lon"),
|
||||
"af": ("radio", "af"), "rxdelay": ("radio", "rxdelay"),
|
||||
@@ -171,6 +223,16 @@ STR_KEYS = {"name": ("radio", "name"), "wifi.ssid": ("wifi", "ssid"),
|
||||
"snmp.community": ("mqtt", "snmp_community"), "mqtt.tx": ("mqtt", "tx")}
|
||||
SECRET_STR_KEYS = {"wifi.pwd": ("wifi", "pwd")}
|
||||
|
||||
# The CLI-only settings, typed the same way so apply_set/cli_read_key reach them
|
||||
# through the existing lookups rather than a parallel code path.
|
||||
for _k, _v in default_config(False)["cli"].items():
|
||||
_t = {bool: BOOL_KEYS, int: INT_KEYS, float: FLOAT_KEYS, str: STR_KEYS}[type(_v)]
|
||||
_t[_k] = ("cli", _k)
|
||||
SECRET_STR_KEYS.update({k: ("cli", k) for k in
|
||||
("guest.password", "alert.psk", "bridge.secret")})
|
||||
for _k in SECRET_STR_KEYS:
|
||||
STR_KEYS.pop(_k, None)
|
||||
|
||||
|
||||
def _hex64(v):
|
||||
return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v)
|
||||
@@ -191,6 +253,28 @@ def apply_set(cfg, key, val):
|
||||
ADMIN_PASSWORD = val
|
||||
return True, "OK"
|
||||
|
||||
if key == "radio.fem.rxgain":
|
||||
return False, "Error: unsupported" # no FEM on the mock board, see GETTERS
|
||||
|
||||
if key == "dutycycle":
|
||||
try:
|
||||
dc = float(val)
|
||||
except ValueError:
|
||||
return False, "Error: expected a number"
|
||||
if not 0 < dc <= 100:
|
||||
return False, "Error, must be 1-100"
|
||||
cfg["radio"]["af"] = 100.0 / dc - 1 # the CLI stores it as airtime_factor
|
||||
return True, "OK"
|
||||
|
||||
if key in ("freq", "bw", "sf", "cr"):
|
||||
# single-component radio setters, reachable from the CLI but not from
|
||||
# the form batch (which always sends the whole `radio` combo)
|
||||
try:
|
||||
cfg["radio"][key] = int(val) if key in ("sf", "cr") else float(val)
|
||||
except ValueError:
|
||||
return False, "Error: expected a number"
|
||||
return True, "OK - reboot to apply"
|
||||
|
||||
if key == "radio":
|
||||
try:
|
||||
f, bw, sf, cr = val.split(",")
|
||||
@@ -211,6 +295,12 @@ def apply_set(cfg, key, val):
|
||||
cfg["mqtt"]["iata"] = val.upper()
|
||||
return True, "OK"
|
||||
|
||||
if key == "prv.key":
|
||||
# write-only by design: the identity goes in, nothing reads it back
|
||||
if not _hex64(val):
|
||||
return False, "Error: private key must be 64 hex characters"
|
||||
return True, "OK - identity restored, reboot to apply"
|
||||
|
||||
if key == "mqtt.owner":
|
||||
if val == "":
|
||||
cfg["mqtt"]["owner"] = ""
|
||||
@@ -250,7 +340,57 @@ def apply_set(cfg, key, val):
|
||||
sec, f = STR_KEYS[key]
|
||||
cfg[sec][f] = val
|
||||
return True, "OK"
|
||||
return True, "OK" # unknown-but-allowlisted: accept (mock is lenient here)
|
||||
# Strict fallthrough: this function is the single authority on what can be
|
||||
# set, for the batch and the CLI alike. Accepting unknown keys here once hid
|
||||
# the fact that the CLI could not reach `dutycycle` or `radio.fem.rxgain`.
|
||||
# Verbatim shape from CommonCLI::handleSetCmd's fallthrough.
|
||||
return False, "unknown config: %s" % key
|
||||
|
||||
|
||||
# Payload-type names accepted alongside the decimal form. Mirrors
|
||||
# namedPacketTypes() in src/helpers/MQTTPacketFilter.h; 12-14 are reserved
|
||||
# upstream and stay reachable by number only.
|
||||
PACKET_TYPE_NAMES = {
|
||||
"req": 0, "response": 1, "txt_msg": 2, "ack": 3, "advert": 4,
|
||||
"grp_txt": 5, "grp_data": 6, "anon_req": 7, "path": 8, "trace": 9,
|
||||
"multipart": 10, "control": 11, "raw_custom": 15,
|
||||
}
|
||||
|
||||
|
||||
def packet_filter_mask(text):
|
||||
"""Canonical filter text -> bitmask, for the stats payload."""
|
||||
if text == "all":
|
||||
return 0xFFFF
|
||||
if text == "none":
|
||||
return 0
|
||||
mask = 0
|
||||
for token in text.split(","):
|
||||
mask |= 1 << int(token)
|
||||
return mask
|
||||
|
||||
|
||||
def canonical_packet_filter(val):
|
||||
"""Mirror of MQTTPacketFilter::parse + ::format. Returns None if invalid."""
|
||||
stripped = val.strip()
|
||||
if stripped == "" or stripped == "all":
|
||||
return "all"
|
||||
if stripped == "none":
|
||||
return "none"
|
||||
mask = 0
|
||||
for part in stripped.split(","):
|
||||
token = part.strip()
|
||||
if re.fullmatch(r"[0-9]+", token):
|
||||
packet_type = int(token)
|
||||
if packet_type > 15:
|
||||
return None
|
||||
elif token in PACKET_TYPE_NAMES:
|
||||
packet_type = PACKET_TYPE_NAMES[token]
|
||||
else:
|
||||
return None
|
||||
mask |= 1 << packet_type
|
||||
if mask == 0xFFFF:
|
||||
return "all"
|
||||
return ",".join(str(i) for i in range(16) if mask & (1 << i))
|
||||
|
||||
|
||||
def apply_slot_set(cfg, idx, field, val):
|
||||
@@ -266,6 +406,13 @@ def apply_slot_set(cfg, idx, field, val):
|
||||
return False, "Error: port must be between 1 and 65535"
|
||||
slot["port"] = p
|
||||
return True, "OK"
|
||||
if field == "filter":
|
||||
canonical = canonical_packet_filter(val)
|
||||
if canonical is None:
|
||||
return False, ("Error: filter must be all, none, or a CSV of "
|
||||
"types 0-15 / names (advert,txt_msg,...)")
|
||||
slot["filter"] = canonical
|
||||
return True, "OK - slot %d packet types: %s" % (idx + 1, canonical)
|
||||
if field in ("preset", "server", "username", "password", "token", "topic", "audience"):
|
||||
slot[field] = val
|
||||
if field == "token":
|
||||
@@ -275,7 +422,270 @@ def apply_slot_set(cfg, idx, field, val):
|
||||
|
||||
|
||||
def is_secret_key(key):
|
||||
return key == "wifi.pwd" or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key))
|
||||
# The serial console prints these back; the portal is reachable over the
|
||||
# LAN, so it masks them in `get` replies the way /api/config already does.
|
||||
return key in SECRET_STR_KEYS or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI command execution (backs /api/cli), mirroring CommonCLI enough to give
|
||||
# the terminal UI realistic single- and multi-line replies.
|
||||
#
|
||||
# The portal's `set` batch is allowlisted (WebConfigKeys.h) because it is driven
|
||||
# by form fields; the CLI is deliberately NOT, since its whole point is reaching
|
||||
# the same surface the serial console reaches. Auth is the boundary -- exactly
|
||||
# as it is for the serial console and for remote admin over the mesh.
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAX_BATCH in WebConfigServer.h: the CLI shares the config batch's fixed slot,
|
||||
# so this is the real cap, reported to the page as status.max_cmds.
|
||||
CLI_MAX_CMDS = 24
|
||||
CLI_RESULT_PAGE = 8 # WebConfigBatch::kCliResultPage
|
||||
CLI_CMD_SECS = 0.25 # simulated per-command execution time
|
||||
# Board::reboot() does not return, so the firmware answers `reboot` itself and
|
||||
# arms the deferred reboot once results have been read (see wcIsDeferredReboot).
|
||||
CLI_DEFERRED_REBOOT = "reboot" # matched as a PREFIX, like CommonCLI does
|
||||
|
||||
# Commands the CLI reaches but the portal cannot honestly serve; rejected at
|
||||
# POST. Mirrors wcCliUnavailable() in WebConfigServer.cpp.
|
||||
CLI_UNAVAILABLE = [
|
||||
("start ota", True, "start ota needs port 80, which this portal is using. "
|
||||
"Run it from the serial console, or use `ota update`."),
|
||||
("clock sync", True, "clock sync takes its time from the caller, which a web request "
|
||||
"has no way to supply. Use `time <epoch-seconds>` instead."),
|
||||
("log", False, "log writes the packet log to the serial console, not here, and "
|
||||
"blocks the radio while it does. Use `log start` / `log stop`."),
|
||||
("get acl", False, "get acl writes to the serial console, not here."),
|
||||
]
|
||||
|
||||
|
||||
def cli_unavailable(cmd):
|
||||
for token, is_prefix, why in CLI_UNAVAILABLE:
|
||||
if cmd.startswith(token) if is_prefix else cmd == token:
|
||||
return why
|
||||
return None
|
||||
|
||||
|
||||
# Failure replies CommonCLI emits that do NOT start with "Err" -- the shapes that
|
||||
# made a naive prefix test call them success. Mirrors
|
||||
# WebConfigBatch::cliReplyIsFailure.
|
||||
def cli_reads_secret(cmd):
|
||||
"""Commands that READ a secret. CommonCLI gates these on the caller being
|
||||
the serial console; the portal is not, so the value is masked here the way
|
||||
CommonCLI masks it for remote callers. Mirrors wcCliReadsSecret()."""
|
||||
if not cmd.startswith("get "):
|
||||
return False
|
||||
key = cmd[4:].strip()
|
||||
return key in ("prv.key", "guest.password", "alert.psk", "bridge.secret") \
|
||||
or is_secret_key(key)
|
||||
|
||||
|
||||
def cli_reply_is_failure(reply):
|
||||
if not reply:
|
||||
return False
|
||||
if reply.startswith(("Err", "ERR", "err", "(ERR", "Unknown command",
|
||||
"unknown config", "??", "Can't find")):
|
||||
return True
|
||||
return ": Err" in reply
|
||||
|
||||
# Commands the device answers but that have no config-key equivalent.
|
||||
GETTERS = {
|
||||
"freq": lambda c: "%.3f" % c["radio"]["freq"],
|
||||
"bw": lambda c: "%.2f" % c["radio"]["bw"],
|
||||
"sf": lambda c: str(c["radio"]["sf"]),
|
||||
"cr": lambda c: str(c["radio"]["cr"]),
|
||||
"public.key": lambda c: "a1b2c3d4" * 8,
|
||||
"wifi.status": lambda c: (
|
||||
"SSID: %s\nIP: 192.168.1.42\nRSSI: -58 dBm\nUptime: %dm"
|
||||
% (c["wifi"]["ssid"] or "(not set)", int(time.time() - ST.start) // 60)),
|
||||
"mqtt.status": lambda c: cli_mqtt_status(c),
|
||||
"mqtt.presets": lambda c: "\n".join(
|
||||
"%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd)
|
||||
for i, (n, nd) in enumerate(PRESETS)),
|
||||
"role": lambda c: "Repeater",
|
||||
"acl": lambda c: "a1b2c3d4e5f60718 perms 3\n1122334455667788 perms 1",
|
||||
# not its own pref: the CLI derives it from airtime_factor both ways
|
||||
"dutycycle": lambda c: "%.1f" % (100.0 / (c["radio"]["af"] + 1)),
|
||||
"mqtt.config.valid": lambda c: (
|
||||
"yes" if any(s["preset"] != "none" for s in c["mqtt"]["slots"]) else "no - no slot configured"),
|
||||
"mqtt.ntp.diag": lambda c: "last sync: 42s ago via %s (offset +0.011s)" % (c["mqtt"]["ntp"] or "none"),
|
||||
"mqtt.stats": lambda c: ("published: %d\ndropped: 0\nqueue: 0/24\nreconnects: 1"
|
||||
% (100 + int(time.time() - ST.start))),
|
||||
# Runtime-gated on the real device (Board::canControlLoRaFemLna), not
|
||||
# compiled out -- the command exists everywhere and the board answers for
|
||||
# itself. The mock board is a Heltec V3, which has no FEM.
|
||||
"radio.fem.rxgain": lambda c: None,
|
||||
}
|
||||
|
||||
|
||||
def cli_mqtt_status(cfg):
|
||||
lines = []
|
||||
for i, s in enumerate(cfg["mqtt"]["slots"][:ST.active_slots]):
|
||||
if s["preset"] == "none":
|
||||
lines.append("slot %d: unconfigured" % (i + 1))
|
||||
else:
|
||||
lines.append("slot %d: %-16s connected tx=%d err=0"
|
||||
% (i + 1, s["preset"], 100 + int(time.time() - ST.start)))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cli_get(cfg, key):
|
||||
"""Reply to `get <key>`.
|
||||
|
||||
CommonCLI::handleGetCmd answers `> value` -- the marker sets the value apart
|
||||
on the serial console. Reproduced here because it is load-bearing: a reply
|
||||
that starts with "> " does not start with "OK", which is what made the
|
||||
firmware's first cut mark every getter a failure.
|
||||
"""
|
||||
ok, val = _cli_get_value(cfg, key)
|
||||
return (ok, "> " + val) if ok else (ok, val)
|
||||
|
||||
|
||||
def _cli_get_value(cfg, key):
|
||||
if key in GETTERS:
|
||||
val = GETTERS[key](cfg)
|
||||
return (True, val) if val is not None else (False, "Error: unsupported")
|
||||
if is_secret_key(key):
|
||||
# The serial console prints these; the portal is reachable over the LAN,
|
||||
# so it masks them the same way /api/config does.
|
||||
return True, SENTINEL if cli_read_key(cfg, key) else "(not set)"
|
||||
val = cli_read_key(cfg, key)
|
||||
if val is None:
|
||||
return False, "??: %s" % key # CommonCLI::handleGetCmd fallthrough
|
||||
return True, str(val)
|
||||
|
||||
|
||||
def cli_read_key(cfg, key):
|
||||
"""Current value of a `set` key, or None when the key is unknown."""
|
||||
m = re.match(r"^mqtt([1-6])\.(\w+)$", key)
|
||||
if m:
|
||||
slot = cfg["mqtt"]["slots"][int(m.group(1)) - 1]
|
||||
return slot.get(m.group(2))
|
||||
for table in (BOOL_KEYS, INT_KEYS, FLOAT_KEYS, STR_KEYS, SECRET_STR_KEYS):
|
||||
if key in table:
|
||||
sec, f = table[key]
|
||||
v = cfg[sec][f]
|
||||
return ("on" if v else "off") if key in BOOL_KEYS else v
|
||||
# keys apply_set() special-cases, so they appear in none of the tables above
|
||||
r = cfg["radio"]
|
||||
return {
|
||||
"name": r["name"], "lat": r["lat"], "lon": r["lon"],
|
||||
"radio": "%.3f,%.2f,%d,%d" % (r["freq"], r["bw"], r["sf"], r["cr"]),
|
||||
"bw": r["bw"], "sf": r["sf"], "cr": r["cr"],
|
||||
"mqtt.iata": cfg["mqtt"]["iata"], "mqtt.owner": cfg["mqtt"]["owner"],
|
||||
}.get(key)
|
||||
|
||||
|
||||
def run_cli(cfg, line):
|
||||
"""Execute one command line. Returns (ok, reply); reply may be multi-line."""
|
||||
cmd = line.strip()
|
||||
if cmd == "":
|
||||
return True, ""
|
||||
if cmd == "ver":
|
||||
# Same source as /api/status's fw on the device: both are
|
||||
# FIRMWARE_VERSION, so they must not disagree here either.
|
||||
return True, "%s (Build: 6 Jun 2026)" % FW_VERSION
|
||||
if cmd == "board":
|
||||
return True, "Heltec V3 (mock)"
|
||||
if cmd == "clock":
|
||||
return True, time.strftime("%d/%m/%Y %H:%M:%S", time.gmtime()) + " UTC"
|
||||
if cmd == "advert":
|
||||
return True, "OK - Advert sent (zero hop)"
|
||||
if cmd == "advert.zerohop":
|
||||
return True, "OK - Advert sent (zero hop)"
|
||||
if cmd in ("reboot", "clkreboot"):
|
||||
return True, "OK - rebooting"
|
||||
if cmd in ("poweroff", "shutdown"):
|
||||
return True, "OK - powering off"
|
||||
if cmd == "erase":
|
||||
return True, "File system erase: OK"
|
||||
if cmd == "memory":
|
||||
return True, ("heap free: 142000\nheap min: 118000\n"
|
||||
"largest block: 96000\npsram free: 3980000")
|
||||
if cmd == "neighbors":
|
||||
return True, ("d4e5f60718 -71 dBm snr 9.5 2m ago\n"
|
||||
"1122334455 -94 dBm snr 2.0 14m ago")
|
||||
# Handled by MyMesh::handleCommand before it delegates to CommonCLI.
|
||||
if cmd == "discover.neighbors":
|
||||
return True, "OK - Discover sent"
|
||||
if cmd == "discover.scopes":
|
||||
return True, "OK - scopes queued (18s discovery remaining)"
|
||||
if cmd.startswith("setperm "):
|
||||
parts = cmd[8:].split()
|
||||
if len(parts) != 2 or not _hex64(parts[0]):
|
||||
return False, "Err - bad params"
|
||||
return True, "OK"
|
||||
if cmd.startswith("clock sync"):
|
||||
# Rejected at POST, but modelled anyway: over the web the caller's
|
||||
# timestamp is 0, so CommonCLI always takes this branch.
|
||||
return False, "(ERR: clock cannot go backwards)"
|
||||
if cmd == "region":
|
||||
return True, "US915"
|
||||
if cmd == "sensor list":
|
||||
return True, "0: battery (mV)\n1: temperature (C)\n2: humidity (%)"
|
||||
if cmd.startswith("sensor get "):
|
||||
return True, "> 22.4"
|
||||
if cmd.startswith("sensor set "):
|
||||
return True, "OK"
|
||||
if cmd.startswith("gps advert "):
|
||||
mode = cmd[11:]
|
||||
if mode not in ("none", "share", "prefs"):
|
||||
return False, "Error, must be none, share or prefs"
|
||||
return True, "OK - advert position: %s" % mode
|
||||
if cmd in ("gps on", "gps off"):
|
||||
return True, "OK - GPS %s" % cmd[4:]
|
||||
if cmd == "gps sync":
|
||||
return True, "OK - clock and location set from GPS"
|
||||
if cmd == "gps setloc":
|
||||
return True, "OK - lat/lon set from the current fix"
|
||||
if cmd == "gps":
|
||||
return True, "GPS: no fix (0 satellites)"
|
||||
if cmd in ("powersaving on", "powersaving off"):
|
||||
return True, "OK - power saving %s" % cmd[12:]
|
||||
if cmd == "powersaving":
|
||||
return True, "off"
|
||||
if cmd.startswith("alert test"):
|
||||
if not ST.cfg["cli"]["alert.psk"]:
|
||||
return False, "Error: alert channel not configured (set alert.psk or set alert.hashtag)"
|
||||
return True, "OK - test alert sent"
|
||||
if cmd.startswith("ota "):
|
||||
return True, ("v1.7.2 available (current v1.7.1-mock)" if cmd == "ota check"
|
||||
else "OK - downloading v1.7.2, will reboot when flashed")
|
||||
if cmd.startswith("start webconfig"):
|
||||
return True, "OK - already running (you are using it)"
|
||||
if cmd == "stop webconfig":
|
||||
return True, "OK - portal stopping"
|
||||
if cmd == "start ota":
|
||||
return True, "OK - upload AP raised at 192.168.4.1"
|
||||
if cmd.startswith("neighbor.remove "):
|
||||
return (True, "OK") if _hex64(cmd[16:]) else (False, "ERR: bad pubkey")
|
||||
if cmd.startswith("tempradio "):
|
||||
return True, "OK - temporary radio params applied (not saved)"
|
||||
if cmd == "clear stats":
|
||||
return True, "OK - stats cleared"
|
||||
if cmd.startswith("stats-"):
|
||||
return True, "recv=512 sent=88 rx_err=3 airtime=41s"
|
||||
if cmd == "log":
|
||||
return True, "packet log: 128 entries, 14 KB"
|
||||
if cmd.startswith("log "):
|
||||
return True, "OK"
|
||||
if cmd.startswith("password "):
|
||||
global ADMIN_PASSWORD
|
||||
ADMIN_PASSWORD = cmd[9:]
|
||||
return True, "OK - password changed"
|
||||
if cmd.startswith("time "):
|
||||
return True, "OK - clock set"
|
||||
if cmd.startswith("get "):
|
||||
return cli_get(cfg, cmd[4:].strip())
|
||||
if cmd.startswith("set "):
|
||||
rest = cmd[4:].strip()
|
||||
key, _, val = rest.partition(" ")
|
||||
if not key:
|
||||
return False, "Error: set what?"
|
||||
# apply_set owns the "is this settable" decision; gating on whether the
|
||||
# key is *readable* rejected write-only and computed ones (`dutycycle`,
|
||||
# `prv.key`, `radio.fem.rxgain`).
|
||||
return apply_set(cfg, key, val.strip())
|
||||
return False, "Unknown command"
|
||||
|
||||
|
||||
def valid_reqid(reqid):
|
||||
@@ -331,6 +741,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._config_result()
|
||||
if path == "/api/cli/result":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._cli_result()
|
||||
if path == "/api/stats":
|
||||
if self._need_auth():
|
||||
return
|
||||
@@ -353,6 +767,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._config_post()
|
||||
if path == "/api/cli":
|
||||
if self._need_auth():
|
||||
return
|
||||
return self._cli_post()
|
||||
if path == "/api/reboot":
|
||||
if self._need_auth():
|
||||
return
|
||||
@@ -369,6 +787,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except OSError:
|
||||
self.send_error(500, "webui/index.html not found")
|
||||
return
|
||||
if MINIFY:
|
||||
# Serve what the device actually serves. The generator strips
|
||||
# comments and indentation before compressing, so --minify is how
|
||||
# you exercise those bytes in a browser rather than trusting that
|
||||
# stripping a 100 KB page never changes its behaviour.
|
||||
html = strip_source(html.decode("utf-8")).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(html)))
|
||||
@@ -424,6 +848,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# drop unchanged secrets (sentinel), like the firmware does
|
||||
entries = [(k, v) for k, v in setmap.items()
|
||||
if not (is_secret_key(k) and v == SENTINEL)]
|
||||
# Same command-budget rejection the firmware applies while building
|
||||
# BatchEntry::cmd; CR/LF are stripped there and don't count.
|
||||
for k, v in entries:
|
||||
prefix = "password " if k == "password" else "set %s " % k
|
||||
stripped = str(v).replace("\r", "").replace("\n", "")
|
||||
if len(prefix) + len(stripped) > BATCH_CMD_SIZE - 1:
|
||||
return self._json(400, {"error": "value too long", "key": k[:32]})
|
||||
if not entries and not reboot:
|
||||
return self._json(400, {"error": "no changes"})
|
||||
# apply now, but expose as pending->done to exercise polling
|
||||
@@ -457,6 +888,125 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"reboot": b["reboot"] and b["all_ok"], "results": b["results"],
|
||||
})
|
||||
|
||||
# ---- CLI ---------------------------------------------------------------
|
||||
# Same 202 + reqid + poll shape as /api/config, for the same reason: the
|
||||
# commands have to run on the main loop, not the web server's task. The
|
||||
# difference is that results stream -- a pasted sequence fills the terminal
|
||||
# command by command instead of appearing all at once at the end.
|
||||
def _cli_post(self):
|
||||
raw = self._read_body()
|
||||
if len(raw) > 8192:
|
||||
return self._json(413, {"error": "body too large"})
|
||||
try:
|
||||
body = json.loads(raw or b"{}")
|
||||
except ValueError:
|
||||
return self._json(400, {"error": "bad json"})
|
||||
reqid = body.get("reqid", "")
|
||||
if not valid_reqid(reqid):
|
||||
return self._json(400, {"error": "bad reqid"})
|
||||
cmds = body.get("cmds")
|
||||
if not isinstance(cmds, list) or not cmds:
|
||||
return self._json(400, {"error": "no commands"})
|
||||
if len(cmds) > CLI_MAX_CMDS:
|
||||
return self._json(413, {"error": "too many commands", "max": CLI_MAX_CMDS})
|
||||
cmds = [str(c).replace("\r", "").replace("\n", "").strip() for c in cmds]
|
||||
cmds = [c for c in cmds if c]
|
||||
if not cmds:
|
||||
return self._json(400, {"error": "no commands"})
|
||||
for c in cmds:
|
||||
if len(c) > BATCH_CMD_SIZE - 1:
|
||||
return self._json(400, {"error": "command too long"})
|
||||
why = cli_unavailable(c)
|
||||
if why:
|
||||
return self._json(400, {"error": why})
|
||||
# Same invariant handleConfigPost enforces (see wcCliUnavailable's
|
||||
# neighbour in WebConfigServer.cpp): first onboarding is committed by the
|
||||
# reboot, and must not commit the factory password onto someone's LAN.
|
||||
if (ST.setup_mode and ST.initial_setup and not ST.admin_pwd_set
|
||||
and not any(c.startswith("password ") for c in cmds)
|
||||
and (any(c.startswith(CLI_DEFERRED_REBOOT) for c in cmds)
|
||||
or any(c.startswith("set wifi.ssid ") for c in cmds))):
|
||||
return self._json(400, {"error": "admin password required for initial setup -- "
|
||||
"run `password <new-password>` first"})
|
||||
|
||||
with ST.lock:
|
||||
self._cli_advance(ST.cli)
|
||||
if ST.cli.get("state") != "idle" and ST.cli.get("reqid") == reqid:
|
||||
return self._json(202, {"state": ST.cli["state"], "reqid": reqid,
|
||||
"total": len(ST.cli["cmds"])})
|
||||
if ST.cli.get("state") == "running":
|
||||
return self._json(409, {"error": "busy", "reqid": ST.cli.get("reqid", "")})
|
||||
ST.cli = {"state": "running", "reqid": reqid, "cmds": cmds, "results": [],
|
||||
"all_ok": True,
|
||||
"reboot": any(c.startswith(CLI_DEFERRED_REBOOT) for c in cmds),
|
||||
"next_at": time.time() + CLI_CMD_SECS}
|
||||
return self._json(202, {"state": "running", "reqid": reqid, "total": len(cmds)})
|
||||
|
||||
@staticmethod
|
||||
def _cli_advance(job):
|
||||
"""Run whichever queued commands are now due. Execution belongs to the
|
||||
node's loop, not to the client's polling -- a client that walks away must
|
||||
not leave the executor claimed forever."""
|
||||
now = time.time()
|
||||
while (job.get("state") == "running" and len(job["results"]) < len(job["cmds"])
|
||||
and now >= job["next_at"]):
|
||||
cmd = job["cmds"][len(job["results"])]
|
||||
if cmd.startswith(CLI_DEFERRED_REBOOT):
|
||||
reply = "OK - reboot queued"
|
||||
else:
|
||||
_, reply = run_cli(ST.cfg, cmd)
|
||||
if cmd.startswith("password "):
|
||||
reply = "OK" # never echo the new password back
|
||||
ST.admin_pwd_set = True
|
||||
elif cli_reads_secret(cmd):
|
||||
val = reply[2:] if reply.startswith("> ") else reply
|
||||
reply = ("> (not set)" if val in ("", "(not set)")
|
||||
else "> ******** (serial only)")
|
||||
ok = not cli_reply_is_failure(reply)
|
||||
# Only writes gate the reboot, and only on the "OK" convention every
|
||||
# setter keeps (WebConfigBatch::cliReplyGatesReboot).
|
||||
if cmd.startswith(("set ", "password ")):
|
||||
job["all_ok"] = job.get("all_ok", True) and reply.startswith("OK")
|
||||
# The command is NOT echoed: it may carry a password or token, and
|
||||
# the client matches results to its own sequence by index.
|
||||
job["results"].append({"ok": ok, "reply": reply})
|
||||
job["next_at"] = now + CLI_CMD_SECS
|
||||
if job.get("state") == "running" and len(job["results"]) == len(job["cmds"]):
|
||||
job["state"] = "done" # stays readable until the next POST
|
||||
|
||||
def _cli_result(self):
|
||||
query = parse_qs(urlsplit(self.path).query)
|
||||
reqid = query.get("reqid", [""])[0]
|
||||
if not valid_reqid(reqid):
|
||||
return self._json(400, {"error": "bad reqid"})
|
||||
# `from` lets the client ask only for results it has not rendered yet,
|
||||
# so a long sequence isn't re-sent on every poll.
|
||||
try:
|
||||
frm = max(0, int(query.get("from", ["0"])[0]))
|
||||
except ValueError:
|
||||
frm = 0
|
||||
with ST.lock:
|
||||
j = ST.cli
|
||||
if j.get("state") == "idle":
|
||||
return self._json(200, {"state": "idle", "reqid": reqid})
|
||||
if j.get("reqid") != reqid:
|
||||
return self._json(404, {"error": "unknown request"})
|
||||
self._cli_advance(j) # one command per CLI_CMD_SECS
|
||||
# Results stream, capped per read so the device's JSON document
|
||||
# stays small; a longer sequence pages across reads. "done" means
|
||||
# the client has been handed everything, not just that execution
|
||||
# finished -- a client that stops polling at "done" must lose nothing.
|
||||
page = j["results"][frm:frm + CLI_RESULT_PAGE]
|
||||
final = j["state"] == "done" and frm + len(page) >= len(j["cmds"])
|
||||
body = {"state": "done" if final else "running", "reqid": reqid,
|
||||
"total": len(j["cmds"]), "from": frm, "results": page}
|
||||
if final:
|
||||
body["all_ok"] = j["all_ok"]
|
||||
body["reboot"] = j["reboot"] and j["all_ok"]
|
||||
if j["reboot"] and not j["all_ok"]:
|
||||
body["reboot_withheld"] = True
|
||||
return self._json(200, body)
|
||||
|
||||
def _scan(self):
|
||||
rescan = "rescan=1" in self.path
|
||||
now = time.time()
|
||||
@@ -473,8 +1023,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
for i, s in enumerate(ST.cfg["mqtt"]["slots"]):
|
||||
if s["preset"] == "none":
|
||||
continue
|
||||
slots.append({"n": i + 1, "name": s["preset"], "state": "ok",
|
||||
"ok": 100 + up, "err": 0})
|
||||
row = {"n": i + 1, "name": s["preset"], "state": "ok",
|
||||
"ok": 100 + up, "err": 0}
|
||||
# Mirrors buildStatsJson(): "filt" carries the raw mask and is
|
||||
# omitted entirely for the all-types default.
|
||||
mask = packet_filter_mask(s.get("filter", "all"))
|
||||
if mask != 0xFFFF:
|
||||
row["filt"] = mask
|
||||
slots.append(row)
|
||||
return {
|
||||
"uptime_s": up, "batt_mv": 4020, "heap_free": 142000, "heap_min": 118000,
|
||||
"heap_max_alloc": 96000, "noise": -98, "rssi": -71, "snr": 9.5,
|
||||
@@ -486,17 +1042,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
def main():
|
||||
global ST, PORT
|
||||
global ST, PORT, MINIFY, FW_VERSION
|
||||
ap = argparse.ArgumentParser(description="Mock WebConfig portal backend")
|
||||
ap.add_argument("--port", type=int, default=8080)
|
||||
ap.add_argument("--setup", action="store_true", help="first-boot setup wizard mode")
|
||||
ap.add_argument("--active-slots", type=int, default=5, help="server slots to expose (2 or 5)")
|
||||
ap.add_argument("--fw-version", default=FW_VERSION,
|
||||
help="version string to report, shaped like build.sh's embedded one")
|
||||
ap.add_argument("--minify", action="store_true",
|
||||
help="serve the comment-stripped page the device ships, not the source")
|
||||
args = ap.parse_args()
|
||||
ST, PORT = State(args), args.port
|
||||
ST, PORT, MINIFY = State(args), args.port, args.minify
|
||||
FW_VERSION = args.fw_version
|
||||
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
mode = "SETUP (wizard)" if args.setup else "LAN (login: %s)" % ADMIN_PASSWORD
|
||||
print("WebConfig mock backend -- %s" % mode)
|
||||
print("WebConfig mock backend -- %s%s" % (mode, " [minified]" if MINIFY else ""))
|
||||
print(" open http://localhost:%d/ (Ctrl-C to stop)" % args.port)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
|
||||
@@ -358,6 +358,7 @@ public:
|
||||
uint32_t getNumRecvDirect() const { return n_recv_direct; }
|
||||
uint16_t getErrFlags() const { return _err_flags; } // Get error flags
|
||||
bool hasOutbound() const { return outbound != NULL; }
|
||||
bool isCurrentOutbound(const Packet* packet) const { return outbound == packet; }
|
||||
void resetStats() {
|
||||
n_sent_flood = n_sent_direct = n_recv_flood = n_recv_direct = 0;
|
||||
_err_flags = 0;
|
||||
|
||||
@@ -52,6 +52,8 @@ static void resetToUf2Bootloader() {
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#else
|
||||
#include <malloc.h> // mallinfo() for the `memory` command on nRF52/RP2040
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
#include "bridges/MQTTBridge.h"
|
||||
@@ -2026,6 +2028,16 @@ void CommonCLI::loadMQTTPrefs(
|
||||
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs read failed, using defaults (file preserved)");
|
||||
} else {
|
||||
has_observer_fields = plan.observer_fields_present;
|
||||
// Written by a later build with appended fields. Everything this
|
||||
// binary knows loaded normally; say so, because the next `set` will
|
||||
// rewrite the file at this length and drop the newer settings.
|
||||
if (file_size - sizeof(MQTTPrefsHeader) > plan.payload_len) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"MQTT: /mqtt_prefs written by newer firmware (%u > %u bytes); "
|
||||
"config loaded, newer settings ignored and dropped on next save",
|
||||
(unsigned)(file_size - sizeof(MQTTPrefsHeader)),
|
||||
(unsigned)plan.payload_len);
|
||||
}
|
||||
}
|
||||
if (file) file.close();
|
||||
} else if (plan.rewrite_legacy) {
|
||||
@@ -2182,12 +2194,15 @@ bool CommonCLI::saveMQTTPrefs(FILESYSTEM* fs) {
|
||||
}
|
||||
|
||||
// Write header and payload sequentially so the transaction needs no second
|
||||
// full-size (2.8 KiB) staging buffer on constrained targets.
|
||||
const MQTTPrefsHeader header = MQTTPrefsCodec::makeHeader();
|
||||
// full-size (2.8 KiB) staging buffer on constrained targets. The length is
|
||||
// the shortest that still round-trips this config, so a node with default
|
||||
// packet filters keeps writing a payload older firmware can read.
|
||||
const size_t payload_len = MQTTPrefsCodec::payloadLenFor(_mqtt_prefs);
|
||||
const MQTTPrefsHeader header = MQTTPrefsCodec::makeHeader(payload_len);
|
||||
MQTTPrefsFileStore store(fs);
|
||||
const MQTTPrefsAtomicStore::Result result = MQTTPrefsAtomicStore::write(
|
||||
store, (const uint8_t *)&header, sizeof(header),
|
||||
(const uint8_t *)&_mqtt_prefs, sizeof(_mqtt_prefs));
|
||||
(const uint8_t *)&_mqtt_prefs, payload_len);
|
||||
if (!MQTTPrefsAtomicStore::committed(result)) {
|
||||
MESH_DEBUG_PRINTLN("MQTT: atomic /mqtt_prefs save failed at %s; source preserved",
|
||||
mqttPrefsSaveResultName(result));
|
||||
@@ -2514,8 +2529,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
||||
strcpy(reply, "ERR: webconfig not supported on this build");
|
||||
}
|
||||
#endif
|
||||
#ifdef ESP_PLATFORM
|
||||
} else if (memcmp(command, "memory", 6) == 0) {
|
||||
#ifdef ESP_PLATFORM
|
||||
sprintf(reply, "Free: %d, Min: %d, Max: %d, Queue: %d, IntFree: %d, IntMax: %d, PSRAM: %d/%d",
|
||||
ESP.getFreeHeap(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap(),
|
||||
_callbacks->getQueueSize(),
|
||||
@@ -2523,6 +2538,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
||||
(int)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
|
||||
(int)heap_caps_get_free_size(MALLOC_CAP_SPIRAM),
|
||||
(int)heap_caps_get_total_size(MALLOC_CAP_SPIRAM));
|
||||
#else
|
||||
struct mallinfo mi = mallinfo();
|
||||
sprintf(reply, "Heap: free=%d, used=%d", (int)mi.fordblks, (int)mi.uordblks);
|
||||
#endif
|
||||
} else if (memcmp(command, "start ota", 9) == 0 && (command[9] == 0 || command[9] == ' ')) {
|
||||
// Manual OTA: bring up the board's browser server for a hand-uploaded binary.
|
||||
|
||||
+222
-89
@@ -8,6 +8,7 @@
|
||||
#include <helpers/MQTTPrefs.h>
|
||||
#include <helpers/PrefsSaveRouting.h>
|
||||
#include <helpers/RegionMap.h>
|
||||
#include <helpers/ConfigSerializer.h>
|
||||
|
||||
#ifndef DEFAULT_CAD_ENABLED
|
||||
#define DEFAULT_CAD_ENABLED 0
|
||||
@@ -106,90 +107,91 @@
|
||||
#define TELEMETRY_ACCESS_ALL 0
|
||||
#define TELEMETRY_ACCESS_ACL 1
|
||||
|
||||
struct NodePrefs { // persisted to file
|
||||
float airtime_factor;
|
||||
char node_name[32];
|
||||
double node_lat, node_lon;
|
||||
char password[16];
|
||||
float freq;
|
||||
int8_t tx_power_dbm;
|
||||
uint8_t disable_fwd;
|
||||
uint8_t advert_interval; // minutes / 2
|
||||
uint8_t flood_advert_interval; // hours
|
||||
float rx_delay_base;
|
||||
float tx_delay_factor;
|
||||
char guest_password[16];
|
||||
float direct_tx_delay_factor;
|
||||
uint32_t guard;
|
||||
uint8_t sf;
|
||||
uint8_t cr;
|
||||
uint8_t allow_read_only;
|
||||
uint8_t multi_acks;
|
||||
float bw;
|
||||
uint8_t flood_max;
|
||||
uint8_t flood_max_unscoped;
|
||||
uint8_t flood_max_advert;
|
||||
uint8_t interference_threshold;
|
||||
uint8_t agc_reset_interval; // secs / 4
|
||||
class NodePrefs : public ConfigSerializer { // persisted to file
|
||||
public:
|
||||
float airtime_factor = 0;
|
||||
char node_name[32] = {};
|
||||
double node_lat = 0, node_lon = 0;
|
||||
char password[16] = {};
|
||||
float freq = 0;
|
||||
int8_t tx_power_dbm = 0;
|
||||
uint8_t disable_fwd = 0;
|
||||
uint8_t advert_interval = 0; // minutes / 2
|
||||
uint8_t flood_advert_interval = 0; // hours
|
||||
float rx_delay_base = 0;
|
||||
float tx_delay_factor = 0;
|
||||
char guest_password[16] = {};
|
||||
float direct_tx_delay_factor = 0;
|
||||
uint32_t guard = 0;
|
||||
uint8_t sf = 0;
|
||||
uint8_t cr = 0;
|
||||
uint8_t allow_read_only = 0;
|
||||
uint8_t multi_acks = 0;
|
||||
float bw = 0;
|
||||
uint8_t flood_max = 0;
|
||||
uint8_t flood_max_unscoped = 0;
|
||||
uint8_t flood_max_advert = 0;
|
||||
uint8_t interference_threshold = 0;
|
||||
uint8_t agc_reset_interval = 0; // secs / 4
|
||||
// Bridge settings
|
||||
uint8_t bridge_enabled; // boolean
|
||||
uint16_t bridge_delay; // milliseconds (default 500 ms)
|
||||
uint8_t bridge_pkt_src; // 0 = logTx, 1 = logRx (default logRx)
|
||||
uint32_t bridge_baud; // 9600, 19200, 38400, 57600, 115200 (default 115200)
|
||||
uint8_t bridge_channel; // 1-14 (ESP-NOW only)
|
||||
char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only)
|
||||
uint8_t bridge_enabled = 0; // boolean
|
||||
uint16_t bridge_delay = 0; // milliseconds (default 500 ms)
|
||||
uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (default logRx)
|
||||
uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200)
|
||||
uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only)
|
||||
char bridge_secret[16] = {}; // for XOR encryption of bridge packets (ESP-NOW only)
|
||||
// Power setting
|
||||
uint8_t powersaving_enabled; // boolean
|
||||
uint8_t reboot_interval; // hours, 0-255 (default 0=disable)
|
||||
uint8_t powersaving_enabled = 0; // boolean
|
||||
uint8_t reboot_interval = 0; // hours, 0-255 (default 0=disable)
|
||||
// Gps settings
|
||||
uint8_t gps_enabled;
|
||||
uint32_t gps_interval; // in seconds
|
||||
uint8_t advert_loc_policy;
|
||||
uint32_t discovery_mod_timestamp;
|
||||
float adc_multiplier;
|
||||
char owner_info[120];
|
||||
uint8_t gps_enabled = 0;
|
||||
uint32_t gps_interval = 0; // in seconds
|
||||
uint8_t advert_loc_policy = 0;
|
||||
uint32_t discovery_mod_timestamp = 0;
|
||||
float adc_multiplier = 0;
|
||||
char owner_info[120] = {};
|
||||
// NOTE: member order below matches mcarper/keymindCascade (upstream/dev order).
|
||||
// It is in-memory only - /com_prefs is read/written field-by-field in loadPrefsInt/
|
||||
// savePrefs, whose canonical file order (identical to the flex fleet's through
|
||||
// offset 294, keymind retry tail at 295+) is what devices actually persist.
|
||||
uint8_t rx_boosted_gain; // power settings
|
||||
uint8_t radio_fem_rxgain; // LoRa FEM RX gain setting
|
||||
uint8_t path_hash_mode; // which path mode to use when sending
|
||||
uint8_t loop_detect;
|
||||
uint8_t cad_enabled; // hardware Channel Activity Detection before TX (boolean)
|
||||
uint8_t rx_boosted_gain = 0; // power settings
|
||||
uint8_t radio_fem_rxgain = 0; // LoRa FEM RX gain setting
|
||||
uint8_t path_hash_mode = 0; // which path mode to use when sending
|
||||
uint8_t loop_detect = 0;
|
||||
uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean)
|
||||
// LR2021 side-detector SFs are appended at /com_prefs offset 856. Their
|
||||
// in-memory placement here does not shift the established binary layout.
|
||||
uint8_t extra_sf[4] = {};
|
||||
uint8_t radio_fem_txgain; // LoRa FEM TX gain; persisted at /com_prefs offset 860
|
||||
uint8_t retry_preset;
|
||||
uint8_t direct_retry_attempts;
|
||||
uint16_t direct_retry_base_ms;
|
||||
uint16_t direct_retry_step_ms;
|
||||
uint16_t direct_retry_snr_margin_x4;
|
||||
int8_t direct_retry_cr4_snr_x4;
|
||||
int8_t direct_retry_cr5_snr_x4;
|
||||
int8_t direct_retry_cr7_snr_x4;
|
||||
int8_t direct_retry_cr8_snr_x4;
|
||||
uint8_t direct_retry_enabled;
|
||||
uint8_t direct_retry_cr_enabled;
|
||||
uint8_t direct_retry_prefs_magic[2];
|
||||
uint8_t flood_retry_attempts;
|
||||
uint8_t flood_retry_max_path;
|
||||
uint8_t flood_retry_prefixes[FLOOD_RETRY_PREFIX_SLOTS][FLOOD_RETRY_PREFIX_LEN];
|
||||
uint8_t flood_retry_bridge_enabled;
|
||||
uint8_t flood_retry_bridge_buckets[FLOOD_RETRY_BRIDGE_BUCKETS][FLOOD_RETRY_BUCKET_PREFIXES][FLOOD_RETRY_PREFIX_LEN];
|
||||
uint8_t flood_retry_ignore_prefixes[FLOOD_RETRY_IGNORE_PREFIXES][FLOOD_RETRY_PREFIX_LEN];
|
||||
uint8_t flood_retry_advert_enabled;
|
||||
uint8_t battery_alert_enabled;
|
||||
uint8_t battery_alert_low_percent;
|
||||
uint8_t battery_alert_critical_percent;
|
||||
uint8_t direct_retry_recent_enabled;
|
||||
uint8_t flood_channel_data_enabled;
|
||||
uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain; persisted at /com_prefs offset 860
|
||||
uint8_t retry_preset = 0;
|
||||
uint8_t direct_retry_attempts = 0;
|
||||
uint16_t direct_retry_base_ms = 0;
|
||||
uint16_t direct_retry_step_ms = 0;
|
||||
uint16_t direct_retry_snr_margin_x4 = 0;
|
||||
int8_t direct_retry_cr4_snr_x4 = 0;
|
||||
int8_t direct_retry_cr5_snr_x4 = 0;
|
||||
int8_t direct_retry_cr7_snr_x4 = 0;
|
||||
int8_t direct_retry_cr8_snr_x4 = 0;
|
||||
uint8_t direct_retry_enabled = 0;
|
||||
uint8_t direct_retry_cr_enabled = 0;
|
||||
uint8_t direct_retry_prefs_magic[2] = {};
|
||||
uint8_t flood_retry_attempts = 0;
|
||||
uint8_t flood_retry_max_path = 0;
|
||||
uint8_t flood_retry_prefixes[FLOOD_RETRY_PREFIX_SLOTS][FLOOD_RETRY_PREFIX_LEN] = {};
|
||||
uint8_t flood_retry_bridge_enabled = 0;
|
||||
uint8_t flood_retry_bridge_buckets[FLOOD_RETRY_BRIDGE_BUCKETS][FLOOD_RETRY_BUCKET_PREFIXES][FLOOD_RETRY_PREFIX_LEN] = {};
|
||||
uint8_t flood_retry_ignore_prefixes[FLOOD_RETRY_IGNORE_PREFIXES][FLOOD_RETRY_PREFIX_LEN] = {};
|
||||
uint8_t flood_retry_advert_enabled = 0;
|
||||
uint8_t battery_alert_enabled = 0;
|
||||
uint8_t battery_alert_low_percent = 0;
|
||||
uint8_t battery_alert_critical_percent = 0;
|
||||
uint8_t direct_retry_recent_enabled = 0;
|
||||
uint8_t flood_channel_data_enabled = 0;
|
||||
// Retains the removed flood.channel.block byte at its established file
|
||||
// offset so every following preference remains upgrade-compatible.
|
||||
uint8_t legacy_flood_channel_block_max_hops;
|
||||
uint8_t flood_channel_data_max_hops;
|
||||
uint8_t telemetry_access;
|
||||
uint8_t legacy_flood_channel_block_max_hops = 0;
|
||||
uint8_t flood_channel_data_max_hops = 0;
|
||||
uint8_t telemetry_access = 0;
|
||||
|
||||
// NOTE: observer settings (MQTT/WiFi/timezone/SNMP/alert) were moved out of
|
||||
// NodePrefs into MQTTPrefs (persisted to /mqtt_prefs) so this struct stays
|
||||
@@ -198,24 +200,155 @@ struct NodePrefs { // persisted to file
|
||||
|
||||
#if defined(ENABLE_OTA)
|
||||
// OTA config (persisted; synced to OtaContext on load, written on change). 0 = conservative defaults.
|
||||
uint8_t ota_autofetch; // OtaManager AUTOFETCH_* (0=off, 1=any-compatible, 2=signed-only)
|
||||
uint8_t ota_autoinstall; // OtaContext AUTOINSTALL_* (0=off, 1=trusted-only)
|
||||
uint8_t ota_signer_count; // # of allowlisted signer pubkeys below
|
||||
uint8_t ota_signers[4][32]; // trusted Ed25519 signer pubkeys (== MAX_OTA_SIGNERS)
|
||||
uint16_t ota_checkpoint_blocks; // resume checkpoint cadence (blocks); 0=never. Default 4 (runtime-tunable)
|
||||
uint16_t ota_advert_interval; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable)
|
||||
uint8_t ota_max_hops; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable)
|
||||
uint8_t ota_autofetch = 0; // OtaManager AUTOFETCH_* (0=off, 1=any-compatible, 2=signed-only)
|
||||
uint8_t ota_autoinstall = 0; // OtaContext AUTOINSTALL_* (0=off, 1=trusted-only)
|
||||
uint8_t ota_signer_count = 0; // # of allowlisted signer pubkeys below
|
||||
uint8_t ota_signers[4][32] = {}; // trusted Ed25519 signer pubkeys (== MAX_OTA_SIGNERS)
|
||||
uint16_t ota_checkpoint_blocks = 0; // resume checkpoint cadence (blocks); 0=never. Default 4 (runtime-tunable)
|
||||
uint16_t ota_advert_interval = 0; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable)
|
||||
uint8_t ota_max_hops = 0; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable)
|
||||
#endif
|
||||
|
||||
uint8_t rx_powersaving_enabled; // boolean
|
||||
uint32_t rx_ps_rx_us;
|
||||
uint32_t rx_ps_sleep_us;
|
||||
uint8_t rx_ps_level; // 0 = manual/explicit us timings; 1..10 = level-derived (auto-retunes on SF/BW change)
|
||||
uint8_t rx_ps_preamble; // 0 = auto (derive from SF); else 16 or 32 = explicit override for level calc
|
||||
char battery_alert_region[31]; // named scope for low-battery floods; empty = no alert scope
|
||||
uint8_t flood_retry_group_max_path; // PAYLOAD_TYPE_GRP_DATA retry path gate; 0xFF = use only the general gate
|
||||
uint8_t rx_watchdog_enabled; // repeater RX-inactivity reboot watchdog (boolean)
|
||||
uint8_t system_watchdog_enabled; // nRF52 main-loop hardware watchdog (boolean; default on)
|
||||
uint8_t rx_powersaving_enabled = 0; // boolean
|
||||
uint32_t rx_ps_rx_us = 0;
|
||||
uint32_t rx_ps_sleep_us = 0;
|
||||
uint8_t rx_ps_level = 0; // 0 = manual/explicit us timings; 1..10 = level-derived (auto-retunes on SF/BW change)
|
||||
uint8_t rx_ps_preamble = 0; // 0 = auto (derive from SF); else 16 or 32 = explicit override for level calc
|
||||
char battery_alert_region[31] = {}; // named scope for low-battery floods; empty = no alert scope
|
||||
uint8_t flood_retry_group_max_path = 0; // PAYLOAD_TYPE_GRP_DATA retry path gate; 0xFF = use only the general gate
|
||||
uint8_t rx_watchdog_enabled = 0; // repeater RX-inactivity reboot watchdog (boolean)
|
||||
uint8_t system_watchdog_enabled = 0; // nRF52 main-loop hardware watchdog (boolean; default on)
|
||||
|
||||
private:
|
||||
class RadioPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
|
||||
protected:
|
||||
void structure() override {
|
||||
def("freq", _parent->freq);
|
||||
def("bw", _parent->bw);
|
||||
def("sf", _parent->sf);
|
||||
def("cr", _parent->cr);
|
||||
def("cad", _parent->cad_enabled);
|
||||
def("int_thr", _parent->interference_threshold);
|
||||
def("rxgain", _parent->rx_boosted_gain);
|
||||
def("fem_rxgain", _parent->radio_fem_rxgain);
|
||||
def("fem_txgain", _parent->radio_fem_txgain);
|
||||
def("tx", _parent->tx_power_dbm);
|
||||
def("af", _parent->airtime_factor);
|
||||
def("rxdelay", _parent->rx_delay_base);
|
||||
def("f_txdelay", _parent->tx_delay_factor);
|
||||
def("d_txdelay", _parent->direct_tx_delay_factor);
|
||||
def("agc_int", _parent->agc_reset_interval);
|
||||
def("hash_mode", _parent->path_hash_mode);
|
||||
def("multi_ack", _parent->multi_acks);
|
||||
}
|
||||
|
||||
public:
|
||||
explicit RadioPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
RadioPrefs radio;
|
||||
|
||||
class BridgePrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
|
||||
protected:
|
||||
void structure() override {
|
||||
def("en", _parent->bridge_enabled);
|
||||
def("delay", _parent->bridge_delay);
|
||||
def("src", _parent->bridge_pkt_src);
|
||||
def("baud", _parent->bridge_baud);
|
||||
def("ch", _parent->bridge_channel);
|
||||
def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret));
|
||||
}
|
||||
|
||||
public:
|
||||
explicit BridgePrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
BridgePrefs bridge;
|
||||
|
||||
class GPSPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
|
||||
protected:
|
||||
void structure() override {
|
||||
def("en", _parent->gps_enabled);
|
||||
def("int", _parent->gps_interval);
|
||||
def("adv_loc", _parent->advert_loc_policy);
|
||||
}
|
||||
|
||||
public:
|
||||
explicit GPSPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
GPSPrefs gps;
|
||||
|
||||
class PowerPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
|
||||
protected:
|
||||
void structure() override {
|
||||
def("adc_mult", _parent->adc_multiplier);
|
||||
def("pwr_sav_en", _parent->powersaving_enabled);
|
||||
}
|
||||
|
||||
public:
|
||||
explicit PowerPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
PowerPrefs power;
|
||||
|
||||
class RepeatPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
|
||||
protected:
|
||||
void structure() override {
|
||||
def("disable", _parent->disable_fwd);
|
||||
def("f_max", _parent->flood_max);
|
||||
def("f_max_uns", _parent->flood_max_unscoped);
|
||||
def("f_max_adv", _parent->flood_max_advert);
|
||||
def("loop", _parent->loop_detect);
|
||||
}
|
||||
|
||||
public:
|
||||
explicit RepeatPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
RepeatPrefs repeat;
|
||||
|
||||
class RoomPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
|
||||
protected:
|
||||
void structure() override {
|
||||
def("rd_only", _parent->allow_read_only);
|
||||
}
|
||||
|
||||
public:
|
||||
explicit RoomPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
RoomPrefs room;
|
||||
|
||||
protected:
|
||||
void structure() override {
|
||||
def("name", node_name, sizeof(node_name));
|
||||
def("pass", password, sizeof(password));
|
||||
def("guest", guest_password, sizeof(guest_password));
|
||||
def("owner", owner_info, sizeof(owner_info));
|
||||
def("adv_int", advert_interval);
|
||||
def("f_adv_int", flood_advert_interval);
|
||||
def("lat", node_lat);
|
||||
def("lon", node_lon);
|
||||
def("disc_mod", discovery_mod_timestamp);
|
||||
def("radio", radio);
|
||||
def("bridge", bridge);
|
||||
def("gps", gps);
|
||||
def("repeat", repeat);
|
||||
def("room", room);
|
||||
def("power", power);
|
||||
}
|
||||
|
||||
public:
|
||||
NodePrefs()
|
||||
: ConfigSerializer(), radio(this), bridge(this), gps(this), power(this),
|
||||
repeat(this), room(this) { }
|
||||
};
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
@@ -518,7 +651,7 @@ class CommonCLI {
|
||||
// run on defaults and saveMQTTPrefs() must not overwrite the source file.
|
||||
bool _mqtt_prefs_hold = false;
|
||||
#endif
|
||||
bool _com_prefs_needs_upgrade = false; // old-format /com_prefs detected; rewrite once after load
|
||||
bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load
|
||||
|
||||
mesh::RTCClock* getRTCClock() { return _rtc; }
|
||||
void savePrefs(
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
#include "bridges/MQTTBridge.h"
|
||||
#include "MQTTConnectionPolicy.h" // classifySlotActivation() -- "will this slot connect here?"
|
||||
#include "MQTTDefaults.h"
|
||||
#include "MQTTPacketFilter.h"
|
||||
#endif
|
||||
|
||||
// Local copy of the busted-libc-safe atoi (the original in CommonCLI.cpp is static).
|
||||
@@ -294,7 +295,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
#elif defined(WITH_MQTT_BRIDGE)
|
||||
} else if (memcmp(config, "mqtt.neighbors.interval ", 24) == 0 ||
|
||||
memcmp(config, "mqtt.neighbors ", 15) == 0) {
|
||||
strcpy(reply, "Err - not supported (requires PSRAM)");
|
||||
strcpy(reply, "Err - neighbors not enabled in this build");
|
||||
#endif
|
||||
} else if (memcmp(config, "mqtt.ntp ", 9) == 0) {
|
||||
const char* host = &config[9];
|
||||
@@ -539,6 +540,32 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
saveObserverPrefs();
|
||||
_callbacks->restartBridgeSlot(slot);
|
||||
sprintf(reply, "OK - slot %d JWT audience cleared (using username/password auth)", slot + 1);
|
||||
} else if (strcmp(subcmd, "filter") == 0 ||
|
||||
strncmp(subcmd, "filter ", 7) == 0) {
|
||||
// Empty/bare input resets to the backwards-compatible all-types default.
|
||||
const char* filter_value = subcmd[6] == '\0' ? "" : &subcmd[7];
|
||||
uint16_t filter_mask = 0;
|
||||
if (!MQTTPacketFilter::parse(filter_value, &filter_mask)) {
|
||||
strcpy(reply, "Error: filter must be all, none, or a CSV of types 0-15 / names (advert,txt_msg,...)");
|
||||
} else {
|
||||
_mqtt_prefs.mqtt_slot_packet_filter[slot] = filter_mask;
|
||||
savePrefs();
|
||||
char filter_text[MQTTPacketFilter::kFilterTextSize];
|
||||
MQTTPacketFilter::format(filter_mask, filter_text, sizeof(filter_text));
|
||||
snprintf(reply, 160, "OK - slot %d packet types: %s", slot + 1, filter_text);
|
||||
// A non-default filter extends /mqtt_prefs past what pre-filter
|
||||
// firmware can read (see MQTTPrefsCodec::payloadLenFor), so say when
|
||||
// that cost buys nothing: slots beyond the runtime array are never
|
||||
// published to on this board, the same warning `preset` gives.
|
||||
if (slot >= RUNTIME_MQTT_SLOTS &&
|
||||
filter_mask != MQTTPacketFilter::kAllPacketTypes) {
|
||||
size_t used = strlen(reply);
|
||||
if (used < 158) {
|
||||
snprintf(reply + used, 160 - used,
|
||||
" (slot inactive on this hardware; blocks firmware rollback)");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sprintf(reply, "unknown config: %s", config);
|
||||
}
|
||||
@@ -825,7 +852,7 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
|
||||
#elif defined(WITH_MQTT_BRIDGE)
|
||||
} else if (memcmp(config, "mqtt.neighbors.interval", 23) == 0 ||
|
||||
memcmp(config, "mqtt.neighbors", 14) == 0) {
|
||||
strcpy(reply, "Err - not supported (requires PSRAM)");
|
||||
strcpy(reply, "Err - neighbors not enabled in this build");
|
||||
#endif
|
||||
} else if (memcmp(config, "mqtt.ntp.diag", 13) == 0 && (config[13] == '\0' || config[13] == ' ')) {
|
||||
#if defined(PORTABLE_MQTT_OBSERVER)
|
||||
@@ -888,6 +915,14 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
|
||||
} else {
|
||||
strcpy(reply, "> (not set - custom slots use username/password auth)");
|
||||
}
|
||||
} else if (strcmp(subcmd, "filter") == 0) {
|
||||
char filter_text[MQTTPacketFilter::kFilterTextSize];
|
||||
if (MQTTPacketFilter::format(_mqtt_prefs.mqtt_slot_packet_filter[slot],
|
||||
filter_text, sizeof(filter_text))) {
|
||||
snprintf(reply, 160, "> %s", filter_text);
|
||||
} else {
|
||||
strcpy(reply, "Error: invalid stored packet filter");
|
||||
}
|
||||
} else if (memcmp(subcmd, "diag", 4) == 0) {
|
||||
MQTTBridge::formatSlotDiagReply(reply, 160, slot);
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#include "JWTHelper.h"
|
||||
|
||||
// MQTT-only translation unit. 22 variants re-glob helpers/*.cpp past the
|
||||
// arduino_base exclusion, so the contents are guarded here rather than in
|
||||
// the build filter -- same idiom as helpers/esp32/WebConfigServer.cpp.
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
|
||||
#include "JWTHelper.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <SHA256.h>
|
||||
#include <string.h>
|
||||
@@ -202,4 +205,4 @@ size_t JWTHelper::createPayload(
|
||||
return base64UrlEncode((uint8_t*)jsonBuffer, len, output, outputSize);
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif // WITH_MQTT_BRIDGE
|
||||
@@ -18,6 +18,10 @@ static const uint32_t kDefaultJwtLifetimeSecs = 86400UL;
|
||||
static const uint32_t kMaxJwtStaggerSecs = 300UL;
|
||||
static const uint32_t kMinimumValidEpoch = 1000000000UL;
|
||||
static const uint32_t kJwtClockThreshold = 1735689600UL; // 2025-01-01 UTC
|
||||
// A wall clock at or past this instant was set from a real source (NTP or an
|
||||
// admin); anything earlier is the firmware's unset-clock default (1715770351,
|
||||
// 15 May 2024), so a delta spanning the sync is meaningless.
|
||||
static const uint32_t kSyncedClockEpoch = kJwtClockThreshold;
|
||||
|
||||
// Unsigned subtraction is intentionally used: it is the standard millis()
|
||||
// idiom and remains correct across a single 32-bit counter rollover.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <string.h>
|
||||
#include <MeshCore.h>
|
||||
#include "MQTTPrefs.h"
|
||||
#include "MQTTPacketFilter.h"
|
||||
#include "MQTTPresets.h"
|
||||
|
||||
// Compile-time defaults for fresh /mqtt_prefs (override via platformio build_flags).
|
||||
@@ -82,6 +83,9 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) {
|
||||
MQTT_DEFAULT_SLOT5_PRESET);
|
||||
mqttDefaultSlotPreset(prefs->mqtt_slot_preset[5], sizeof(prefs->mqtt_slot_preset[5]),
|
||||
MQTT_DEFAULT_SLOT6_PRESET);
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
prefs->mqtt_slot_packet_filter[i] = MQTTPacketFilter::kAllPacketTypes;
|
||||
}
|
||||
|
||||
if (MQTT_DEFAULT_IATA[0] != '\0') {
|
||||
strncpy(prefs->mqtt_iata, MQTT_DEFAULT_IATA, sizeof(prefs->mqtt_iata) - 1);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#include "MQTTMessageBuilder.h"
|
||||
|
||||
// MQTT-only translation unit. 22 variants re-glob helpers/*.cpp past the
|
||||
// arduino_base exclusion, so the contents are guarded here rather than in
|
||||
// the build filter -- same idiom as helpers/esp32/WebConfigServer.cpp.
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
|
||||
#include "MQTTMessageBuilder.h"
|
||||
|
||||
#include "MQTTPayloadBuilder.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <cstring>
|
||||
@@ -114,14 +117,38 @@ int MQTTMessageBuilder::buildNeighborsMessage(
|
||||
const char* origin_id,
|
||||
const char* timestamp,
|
||||
const char* self_scopes,
|
||||
const char* self_default_scope,
|
||||
const NeighborsMessageEntry* neighbors,
|
||||
int neighbor_count,
|
||||
char* buffer,
|
||||
size_t buffer_size
|
||||
size_t buffer_size,
|
||||
int total_neighbors,
|
||||
int queried_neighbors,
|
||||
bool truncated
|
||||
) {
|
||||
return MQTTPayloadBuilder::buildNeighborsMessage(
|
||||
doc, origin, origin_id, timestamp, self_scopes, neighbors, neighbor_count,
|
||||
buffer, buffer_size);
|
||||
doc, origin, origin_id, timestamp, self_scopes, self_default_scope,
|
||||
neighbors, neighbor_count, buffer, buffer_size, total_neighbors,
|
||||
queried_neighbors, truncated);
|
||||
}
|
||||
|
||||
size_t MQTTMessageBuilder::measureNeighborsMessageBase(
|
||||
const char* origin,
|
||||
const char* origin_id,
|
||||
const char* timestamp,
|
||||
const char* self_scopes,
|
||||
const char* self_default_scope,
|
||||
int total_neighbors
|
||||
) {
|
||||
return MQTTPayloadBuilder::measureNeighborsMessageBase(
|
||||
origin, origin_id, timestamp, self_scopes, self_default_scope,
|
||||
total_neighbors);
|
||||
}
|
||||
|
||||
size_t MQTTMessageBuilder::measureNeighborsMessageEntry(
|
||||
const NeighborsMessageEntry& neighbor
|
||||
) {
|
||||
return MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbor);
|
||||
}
|
||||
|
||||
int MQTTMessageBuilder::buildPacketJSON(
|
||||
@@ -348,4 +375,4 @@ void MQTTMessageBuilder::packetToHex(mesh::Packet* packet, char* hex, size_t hex
|
||||
bytesToHex(raw_buf, raw_len, hex, hex_size);
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif // WITH_MQTT_BRIDGE
|
||||
@@ -166,11 +166,24 @@ public:
|
||||
const char* origin_id,
|
||||
const char* timestamp,
|
||||
const char* self_scopes,
|
||||
const char* self_default_scope,
|
||||
const NeighborsMessageEntry* neighbors,
|
||||
int neighbor_count,
|
||||
char* buffer,
|
||||
size_t buffer_size
|
||||
size_t buffer_size,
|
||||
int total_neighbors = -1,
|
||||
int queried_neighbors = -1,
|
||||
bool truncated = false
|
||||
);
|
||||
static size_t measureNeighborsMessageBase(
|
||||
const char* origin,
|
||||
const char* origin_id,
|
||||
const char* timestamp,
|
||||
const char* self_scopes,
|
||||
const char* self_default_scope,
|
||||
int total_neighbors
|
||||
);
|
||||
static size_t measureNeighborsMessageEntry(const NeighborsMessageEntry& neighbor);
|
||||
|
||||
/**
|
||||
* Convert packet to JSON message
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
// Pure per-broker packet-type allowlist helpers. MeshCore payload types occupy
|
||||
// the low four bits of the packet header, so a uint16_t stores the complete
|
||||
// 0..15 allowlist without dynamic allocation.
|
||||
namespace MQTTPacketFilter {
|
||||
|
||||
static const uint8_t kMinPacketType = 0;
|
||||
static const uint8_t kMaxPacketType = 15;
|
||||
static const uint16_t kAllPacketTypes = 0xFFFFu;
|
||||
// "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15" plus the terminator.
|
||||
static const size_t kFilterTextSize = 38;
|
||||
|
||||
inline bool isAsciiSpace(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
||||
}
|
||||
|
||||
inline bool tokenEquals(const char* begin, size_t len, const char* token) {
|
||||
return token != nullptr && strlen(token) == len && memcmp(begin, token, len) == 0;
|
||||
}
|
||||
|
||||
// Payload-type spellings accepted alongside the decimal form, mirroring the
|
||||
// PAYLOAD_TYPE_* names in src/Packet.h. Types 12-14 are reserved upstream and
|
||||
// have no name, so they stay reachable only by number.
|
||||
struct NamedPacketType {
|
||||
const char* name;
|
||||
uint8_t type;
|
||||
};
|
||||
|
||||
inline const NamedPacketType* namedPacketTypes(size_t* count_out) {
|
||||
static const NamedPacketType kNames[] = {
|
||||
{"req", 0}, {"response", 1}, {"txt_msg", 2}, {"ack", 3},
|
||||
{"advert", 4}, {"grp_txt", 5}, {"grp_data", 6}, {"anon_req", 7},
|
||||
{"path", 8}, {"trace", 9}, {"multipart", 10}, {"control", 11},
|
||||
{"raw_custom", 15},
|
||||
};
|
||||
if (count_out != nullptr) *count_out = sizeof(kNames) / sizeof(kNames[0]);
|
||||
return kNames;
|
||||
}
|
||||
|
||||
// Resolve one already-trimmed list entry to a payload type. Decimal and named
|
||||
// spellings are interchangeable within a list; "all"/"none" are whole-value
|
||||
// keywords and deliberately do not resolve here, so "all,2" stays invalid.
|
||||
inline bool resolveToken(const char* begin, size_t len, uint8_t* type_out) {
|
||||
if (begin == nullptr || type_out == nullptr || len == 0) return false;
|
||||
|
||||
if (*begin >= '0' && *begin <= '9') {
|
||||
unsigned value = 0;
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
if (begin[i] < '0' || begin[i] > '9') return false;
|
||||
value = value * 10u + static_cast<unsigned>(begin[i] - '0');
|
||||
if (value > kMaxPacketType) return false;
|
||||
}
|
||||
*type_out = static_cast<uint8_t>(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t name_count = 0;
|
||||
const NamedPacketType* names = namedPacketTypes(&name_count);
|
||||
for (size_t i = 0; i < name_count; ++i) {
|
||||
if (tokenEquals(begin, len, names[i].name)) {
|
||||
*type_out = names[i].type;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse an allowlist value. Empty input means "all" so WebConfig can clear a
|
||||
// field and retain the same backwards-compatible default as an older
|
||||
// /mqtt_prefs file. Keywords and type names are deliberately lowercase; "all"
|
||||
// and "none" cannot be mixed into a list. Entries may be decimal (0..15) or
|
||||
// named, may carry surrounding ASCII whitespace, and may be repeated.
|
||||
inline bool parse(const char* input, uint16_t* mask_out) {
|
||||
if (input == nullptr || mask_out == nullptr) return false;
|
||||
|
||||
const char* begin = input;
|
||||
while (*begin && isAsciiSpace(*begin)) begin++;
|
||||
const char* end = begin + strlen(begin);
|
||||
while (end > begin && isAsciiSpace(end[-1])) end--;
|
||||
|
||||
const size_t len = static_cast<size_t>(end - begin);
|
||||
if (len == 0 || tokenEquals(begin, len, "all")) {
|
||||
*mask_out = kAllPacketTypes;
|
||||
return true;
|
||||
}
|
||||
if (tokenEquals(begin, len, "none")) {
|
||||
*mask_out = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint16_t parsed = 0;
|
||||
const char* cursor = begin;
|
||||
for (;;) {
|
||||
const char* comma = cursor;
|
||||
while (comma < end && *comma != ',') comma++;
|
||||
|
||||
const char* token_begin = cursor;
|
||||
const char* token_end = comma;
|
||||
while (token_begin < token_end && isAsciiSpace(*token_begin)) token_begin++;
|
||||
while (token_end > token_begin && isAsciiSpace(token_end[-1])) token_end--;
|
||||
|
||||
uint8_t type = 0;
|
||||
if (!resolveToken(token_begin, static_cast<size_t>(token_end - token_begin), &type)) {
|
||||
return false;
|
||||
}
|
||||
parsed |= static_cast<uint16_t>(1u << type);
|
||||
|
||||
if (comma >= end) break;
|
||||
cursor = comma + 1;
|
||||
}
|
||||
|
||||
*mask_out = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Format masks deterministically for CLI/API output. Subsets are emitted in
|
||||
// ascending order; the two useful extremes use concise keywords.
|
||||
inline bool format(uint16_t mask, char* output, size_t output_size) {
|
||||
if (output == nullptr || output_size == 0) return false;
|
||||
output[0] = '\0';
|
||||
|
||||
const char* keyword = nullptr;
|
||||
if (mask == kAllPacketTypes) keyword = "all";
|
||||
else if (mask == 0) keyword = "none";
|
||||
if (keyword != nullptr) {
|
||||
const size_t len = strlen(keyword);
|
||||
if (output_size <= len) return false;
|
||||
memcpy(output, keyword, len + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
char formatted[kFilterTextSize];
|
||||
size_t pos = 0;
|
||||
bool first = true;
|
||||
for (uint8_t type = kMinPacketType; type <= kMaxPacketType; ++type) {
|
||||
if ((mask & static_cast<uint16_t>(1u << type)) == 0) continue;
|
||||
if (!first) formatted[pos++] = ',';
|
||||
if (type >= 10) formatted[pos++] = '1';
|
||||
formatted[pos++] = static_cast<char>('0' + (type % 10));
|
||||
first = false;
|
||||
}
|
||||
formatted[pos] = '\0';
|
||||
|
||||
if (output_size <= pos) return false;
|
||||
memcpy(output, formatted, pos + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The keyword shortcut above means format() never actually emits the whole
|
||||
// 0..15 list, but the emit loop is written for every type, so the internal
|
||||
// buffer must still hold it: 22 digits + 15 separators + NUL.
|
||||
static_assert(kFilterTextSize >= 38, "filter text buffer must hold the full 0..15 list");
|
||||
|
||||
inline bool allows(uint16_t mask, uint8_t packet_type) {
|
||||
return packet_type <= kMaxPacketType &&
|
||||
(mask & static_cast<uint16_t>(1u << packet_type)) != 0;
|
||||
}
|
||||
|
||||
// How many types a mask allows. Lets a length-constrained reply fall back to
|
||||
// "N/16" instead of a clipped list, which would read as a different allowlist.
|
||||
inline uint8_t countTypes(uint16_t mask) {
|
||||
uint8_t total = 0;
|
||||
for (uint8_t type = kMinPacketType; type <= kMaxPacketType; ++type) {
|
||||
if ((mask & static_cast<uint16_t>(1u << type)) != 0) total++;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Conservative "could any configured broker want this type?" mask, used to
|
||||
// reject a packet before it is copied into the publish queue. Disabled slots
|
||||
// contribute nothing; a slot whose topic style later turns out not to support
|
||||
// the publication is still counted here, so the gate never drops a packet the
|
||||
// per-slot pass would have published.
|
||||
inline uint16_t enabledUnion(const uint16_t* masks, const bool* enabled, size_t count) {
|
||||
if (masks == nullptr) return 0;
|
||||
uint16_t combined = 0;
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
if (enabled != nullptr && !enabled[i]) continue;
|
||||
combined = static_cast<uint16_t>(combined | masks[i]);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
// True when every slot still carries the default all-types mask, i.e. nothing
|
||||
// depends on the packet-filter tail of /mqtt_prefs being written.
|
||||
inline bool allMasksDefault(const uint16_t* masks, size_t count) {
|
||||
if (masks == nullptr) return true;
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
if (masks[i] != kAllPacketTypes) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stage one of the publish gate: everything decidable before a packet is
|
||||
// serialised or a topic is built. Cheap enough to run per packet per slot.
|
||||
inline bool slotCandidate(bool slot_enabled, uint16_t mask, uint8_t packet_type) {
|
||||
return slot_enabled && allows(mask, packet_type);
|
||||
}
|
||||
|
||||
// The complete gate, once the slot's topic support is known. Eligibility
|
||||
// deliberately excludes connection state: a temporarily disconnected broker
|
||||
// that is configured for this type still requires the shared queue's existing
|
||||
// bounded retry policy.
|
||||
//
|
||||
// eligiblePacketSlots() calls this directly, using slotCandidate() first only
|
||||
// to skip the topic build for slots the mask already rejects. A slot that
|
||||
// passes both therefore has its topic built again in the publish loop; that
|
||||
// second build is the accepted cost of not carrying six 128-byte topics on the
|
||||
// MQTT task stack.
|
||||
inline bool slotEligible(bool slot_enabled, bool topic_supported,
|
||||
uint16_t mask, uint8_t packet_type) {
|
||||
return slotCandidate(slot_enabled, mask, packet_type) && topic_supported;
|
||||
}
|
||||
|
||||
// A fully filtered/topic-incompatible packet is intentionally complete. Once
|
||||
// any eligible target exists, at least one actual publish must succeed.
|
||||
inline bool publishComplete(bool has_eligible_target, bool any_publish_succeeded) {
|
||||
return any_publish_succeeded || !has_eligible_target;
|
||||
}
|
||||
|
||||
} // namespace MQTTPacketFilter
|
||||
@@ -186,47 +186,110 @@ int MQTTPayloadBuilder::buildRawMessage(
|
||||
return serializeComplete(root, buffer, buffer_size);
|
||||
}
|
||||
|
||||
static JsonArray buildNeighborsMessageBase(
|
||||
JsonDocument& doc,
|
||||
const char* origin,
|
||||
const char* origin_id,
|
||||
const char* timestamp,
|
||||
const char* self_scopes,
|
||||
const char* self_default_scope,
|
||||
int total_neighbors,
|
||||
int queried_neighbors,
|
||||
bool truncated
|
||||
) {
|
||||
doc.clear();
|
||||
JsonObject root = doc.to<JsonObject>();
|
||||
root["timestamp"] = timestamp;
|
||||
root["origin"] = origin;
|
||||
root["origin_id"] = origin_id;
|
||||
if (total_neighbors >= 0) {
|
||||
root["total_neighbors"] = total_neighbors;
|
||||
root["queried_neighbors"] = queried_neighbors >= 0 ? queried_neighbors : total_neighbors;
|
||||
root["truncated"] = truncated;
|
||||
}
|
||||
|
||||
JsonObject self = root["self"].to<JsonObject>();
|
||||
self["scopes"] = self_scopes ? self_scopes : "";
|
||||
self["default_scope"] = self_default_scope ? self_default_scope : "";
|
||||
return root["neighbors"].to<JsonArray>();
|
||||
}
|
||||
|
||||
static void addNeighborsMessageEntry(
|
||||
JsonArray& arr,
|
||||
const MQTTPayloadBuilder::NeighborsMessageEntry& neighbor
|
||||
) {
|
||||
JsonObject nb = arr.add<JsonObject>();
|
||||
nb["pubkey"] = neighbor.pubkey_hex;
|
||||
nb["snr"] = neighbor.snr;
|
||||
if (neighbor.heard_unknown) {
|
||||
nb["heard_secs_ago"] = nullptr; // age unknown, not zero
|
||||
} else {
|
||||
nb["heard_secs_ago"] = neighbor.heard_secs_ago;
|
||||
}
|
||||
nb["scopes"] = neighbor.scopes ? neighbor.scopes : "";
|
||||
nb["status"] = neighbor.status;
|
||||
}
|
||||
|
||||
size_t MQTTPayloadBuilder::measureNeighborsMessageBase(
|
||||
const char* origin,
|
||||
const char* origin_id,
|
||||
const char* timestamp,
|
||||
const char* self_scopes,
|
||||
const char* self_default_scope,
|
||||
int total_neighbors
|
||||
) {
|
||||
JsonDocument doc;
|
||||
buildNeighborsMessageBase(
|
||||
doc, origin, origin_id, timestamp, self_scopes, self_default_scope,
|
||||
total_neighbors, total_neighbors, false);
|
||||
return measureJson(doc);
|
||||
}
|
||||
|
||||
size_t MQTTPayloadBuilder::measureNeighborsMessageEntry(
|
||||
const NeighborsMessageEntry& neighbor
|
||||
) {
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc.to<JsonArray>();
|
||||
addNeighborsMessageEntry(arr, neighbor);
|
||||
JsonObject entry = arr[0];
|
||||
return measureJson(entry);
|
||||
}
|
||||
|
||||
int MQTTPayloadBuilder::buildNeighborsMessage(
|
||||
JsonDocument& doc,
|
||||
const char* origin,
|
||||
const char* origin_id,
|
||||
const char* timestamp,
|
||||
const char* self_scopes,
|
||||
const char* self_default_scope,
|
||||
const NeighborsMessageEntry* neighbors,
|
||||
int neighbor_count,
|
||||
char* buffer,
|
||||
size_t buffer_size
|
||||
size_t buffer_size,
|
||||
int total_neighbors,
|
||||
int queried_neighbors,
|
||||
bool truncated
|
||||
) {
|
||||
if (!buffer || buffer_size == 0) return 0;
|
||||
|
||||
doc.clear();
|
||||
JsonObject root = doc.to<JsonObject>();
|
||||
root["timestamp"] = timestamp;
|
||||
root["origin"] = origin;
|
||||
root["origin_id"] = origin_id;
|
||||
|
||||
JsonObject self = root["self"].to<JsonObject>();
|
||||
self["scopes"] = self_scopes ? self_scopes : "";
|
||||
|
||||
JsonArray arr = root["neighbors"].to<JsonArray>();
|
||||
if (measureJson(root) >= buffer_size) return 0;
|
||||
JsonArray arr = buildNeighborsMessageBase(
|
||||
doc, origin, origin_id, timestamp, self_scopes, self_default_scope,
|
||||
total_neighbors, queried_neighbors, truncated);
|
||||
if (doc.overflowed() || arr.isNull() || measureJson(doc) >= buffer_size) return 0;
|
||||
|
||||
for (int i = 0; i < neighbor_count; i++) {
|
||||
JsonObject nb = arr.add<JsonObject>();
|
||||
nb["pubkey"] = neighbors[i].pubkey_hex;
|
||||
nb["snr"] = neighbors[i].snr;
|
||||
nb["heard_secs_ago"] = neighbors[i].heard_secs_ago;
|
||||
nb["scopes"] = neighbors[i].scopes ? neighbors[i].scopes : "";
|
||||
nb["status"] = neighbors[i].status;
|
||||
addNeighborsMessageEntry(arr, neighbors[i]);
|
||||
if (doc.overflowed()) return 0;
|
||||
|
||||
// Entries arrive ordered most- to least-useful. Stop as soon as the next
|
||||
// one would fill the fixed publish buffer, dropping the remaining tail so
|
||||
// document growth stays bounded.
|
||||
if (measureJson(root) >= buffer_size) {
|
||||
if (measureJson(doc) >= buffer_size) {
|
||||
arr.remove(arr.size() - 1);
|
||||
if (total_neighbors >= 0) doc["truncated"] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return serializeComplete(root, buffer, buffer_size);
|
||||
return serializeComplete(doc.as<JsonObject>(), buffer, buffer_size);
|
||||
}
|
||||
@@ -75,6 +75,11 @@ public:
|
||||
uint32_t heard_secs_ago;
|
||||
const char* scopes;
|
||||
const char* status;
|
||||
// True renders heard_secs_ago as JSON null, for a neighbour whose stored
|
||||
// stamp cannot yield an age. No default initializer: the struct stays an
|
||||
// aggregate for the device toolchain, and a zeroed tail means "age known",
|
||||
// so size-measurement callers keep reserving the widest numeric value.
|
||||
bool heard_unknown;
|
||||
};
|
||||
|
||||
// Build neighbors-table JSON for the meshcore/{iata}/{device}/neighbors topic.
|
||||
@@ -86,9 +91,26 @@ public:
|
||||
const char* origin_id,
|
||||
const char* timestamp,
|
||||
const char* self_scopes,
|
||||
const char* self_default_scope,
|
||||
const NeighborsMessageEntry* neighbors,
|
||||
int neighbor_count,
|
||||
char* buffer,
|
||||
size_t buffer_size
|
||||
size_t buffer_size,
|
||||
int total_neighbors = -1,
|
||||
int queried_neighbors = -1,
|
||||
bool truncated = false
|
||||
);
|
||||
|
||||
// Exact serialized-size components used by paced neighbor discovery. The
|
||||
// base reserves the largest progress metadata values for this snapshot;
|
||||
// callers add each entry size plus one byte for commas after the first.
|
||||
static size_t measureNeighborsMessageBase(
|
||||
const char* origin,
|
||||
const char* origin_id,
|
||||
const char* timestamp,
|
||||
const char* self_scopes,
|
||||
const char* self_default_scope,
|
||||
int total_neighbors
|
||||
);
|
||||
static size_t measureNeighborsMessageEntry(const NeighborsMessageEntry& neighbor);
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "MQTTPacketFilter.h"
|
||||
#include "MQTTPrefsStorage.h"
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
@@ -37,23 +38,44 @@ struct DecodePlan {
|
||||
|
||||
static const size_t kV1PreObserverPayloadSize = MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE;
|
||||
static const size_t kV1PreNeighborsPayloadSize = MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE;
|
||||
static const size_t kV1PreFilterPayloadSize = MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE;
|
||||
static const size_t kV1BaselinePayloadSize = MQTT_PREFS_V1_FULL_PAYLOAD_SIZE;
|
||||
static const size_t kEncodedSize = sizeof(MQTTPrefsHeader) + kV1BaselinePayloadSize;
|
||||
|
||||
inline MQTTPrefsHeader makeHeader() {
|
||||
// Shortest payload length that still round-trips this configuration.
|
||||
//
|
||||
// The packet-filter tail is the only optional part of the current layout, and
|
||||
// its default (all types) is exactly what a pre-filter decoder supplies for a
|
||||
// missing tail. So a device whose filters are all default keeps writing the
|
||||
// 2864-byte payload that pre-filter firmware can still read. That matters
|
||||
// because /mqtt_prefs also carries the WiFi credentials: an unrecognised
|
||||
// longer payload sends older firmware to defaults with no network, and it
|
||||
// refuses to overwrite the file, so the node cannot be recovered over the air.
|
||||
// Touching any filter opts that node into the longer payload -- a deliberate,
|
||||
// operator-initiated trade rather than a side effect of upgrading.
|
||||
inline size_t payloadLenFor(const MQTTPrefs& prefs) {
|
||||
return MQTTPacketFilter::allMasksDefault(prefs.mqtt_slot_packet_filter,
|
||||
MQTT_PREFS_SLOT_COUNT)
|
||||
? kV1PreFilterPayloadSize
|
||||
: kV1BaselinePayloadSize;
|
||||
}
|
||||
|
||||
inline MQTTPrefsHeader makeHeader(size_t payload_len) {
|
||||
MQTTPrefsHeader header;
|
||||
memcpy(header.magic, MQTT_PREFS_MAGIC, sizeof(header.magic));
|
||||
header.version = MQTT_PREFS_VERSION;
|
||||
header.payload_len = static_cast<uint16_t>(kV1BaselinePayloadSize);
|
||||
header.payload_len = static_cast<uint16_t>(payload_len);
|
||||
return header;
|
||||
}
|
||||
|
||||
inline size_t encode(const MQTTPrefs& prefs, uint8_t* output, size_t output_size) {
|
||||
if (output == nullptr || output_size < kEncodedSize) return 0;
|
||||
const MQTTPrefsHeader header = makeHeader();
|
||||
const size_t payload_len = payloadLenFor(prefs);
|
||||
const size_t encoded_size = sizeof(MQTTPrefsHeader) + payload_len;
|
||||
if (output == nullptr || output_size < encoded_size) return 0;
|
||||
const MQTTPrefsHeader header = makeHeader(payload_len);
|
||||
memcpy(output, &header, sizeof(header));
|
||||
memcpy(output + sizeof(header), &prefs, sizeof(prefs));
|
||||
return kEncodedSize;
|
||||
memcpy(output + sizeof(header), &prefs, payload_len);
|
||||
return encoded_size;
|
||||
}
|
||||
|
||||
inline bool isMagicPrefix(const uint8_t* input, size_t available) {
|
||||
@@ -92,14 +114,35 @@ inline DecodePlan classify(const uint8_t* prefix, size_t prefix_read, size_t fil
|
||||
if (header.payload_len != payload_available) {
|
||||
return corruptPlan();
|
||||
}
|
||||
// A same-version append is still unknown to this binary. Holding the
|
||||
// file prevents a downgrade from discarding it on the next CLI save.
|
||||
// A longer same-version payload was written by a later build that
|
||||
// appended fields. Within a version tag the layout is append-only, so
|
||||
// every byte this binary knows is present and correctly positioned -- read
|
||||
// the baseline prefix and ignore the tail.
|
||||
//
|
||||
// This is the downgrade contract, and it is deliberately asymmetric:
|
||||
// refusing the file would cost the operator WiFi credentials and every
|
||||
// broker slot (a node with no network and no portal, recoverable only
|
||||
// over serial), whereas reading it costs only the settings the newer
|
||||
// build added. Losing a later feature's settings is the acceptable half.
|
||||
//
|
||||
// The tail survives until something actually writes: saveMQTTPrefs()
|
||||
// rewrites at this binary's own length, so a rollback that changes no
|
||||
// observer setting and is later rolled forward keeps the newer fields
|
||||
// intact. Only an explicit `set` while downgraded drops them.
|
||||
//
|
||||
// A layout change that is NOT a pure append must bump MQTT_PREFS_VERSION;
|
||||
// the version check above is what makes this rule safe.
|
||||
if (header.payload_len > kV1BaselinePayloadSize) {
|
||||
return {Source::UnsupportedVersion, false, true, false, 0};
|
||||
return {Source::Current, false, false, true, kV1BaselinePayloadSize};
|
||||
}
|
||||
if (header.payload_len == kV1BaselinePayloadSize) {
|
||||
return {Source::Current, false, false, true, kV1BaselinePayloadSize};
|
||||
}
|
||||
if (header.payload_len == kV1PreFilterPayloadSize) {
|
||||
// Written before the per-slot packet-filter tail. Defaults supply an
|
||||
// all-types mask for every slot.
|
||||
return {Source::Current, false, false, true, kV1PreFilterPayloadSize};
|
||||
}
|
||||
if (header.payload_len == kV1PreNeighborsPayloadSize) {
|
||||
// Written by observer/webconfig firmware before the neighbors tail
|
||||
// existed. The observer fields ARE present; only the neighbors tail is
|
||||
|
||||
@@ -117,6 +117,11 @@ struct MQTTPrefs {
|
||||
// interchangeable (see the offsetof static_asserts below).
|
||||
uint8_t mqtt_neighbors_enabled;
|
||||
uint32_t mqtt_neighbors_interval;
|
||||
|
||||
// Per-slot payload-type allow masks. Bit N controls MeshCore packet type N
|
||||
// for both packets and raw MQTT topics. Appended so older v1 payloads load
|
||||
// with the default all-types masks intact.
|
||||
uint16_t mqtt_slot_packet_filter[MQTT_PREFS_SLOT_COUNT];
|
||||
};
|
||||
|
||||
// Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose
|
||||
@@ -129,14 +134,28 @@ static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_MS = MQTT_NEIGHBORS_MIN_INTERV
|
||||
static const uint32_t MQTT_NEIGHBORS_MAX_INTERVAL_MS = MQTT_NEIGHBORS_MAX_INTERVAL_HOURS * 3600000UL;
|
||||
static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS = MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS * 3600000UL;
|
||||
|
||||
// Version-1 has three payload layouts this firmware can decode. Never infer a
|
||||
// compatible payload from an arbitrary shorter size: raw prefs have no checksum.
|
||||
// Version-1 has four payload layouts this firmware can decode. Never infer a
|
||||
// compatible payload from an arbitrary SHORTER size: raw prefs have no
|
||||
// checksum, so a short length has to match a boundary that was really shipped.
|
||||
//
|
||||
// A LONGER v1 payload is different and is always readable: within a version tag
|
||||
// the layout is append-only, so a later build's file still starts with this
|
||||
// binary's exact baseline. classify() reads that prefix and ignores the tail
|
||||
// rather than rejecting the file -- see the downgrade contract there. Any change
|
||||
// that is not a pure append MUST bump MQTT_PREFS_VERSION instead.
|
||||
// - PRE_OBSERVER (2736): stops before the observer tail (snmp_*/alert_*).
|
||||
// - PRE_NEIGHBORS (2860): full observer tail, no neighbors fields yet.
|
||||
// - FULL (2864): current baseline, with the neighbors tail.
|
||||
// - PRE_FILTER (2864): neighbors tail, no per-slot packet filters.
|
||||
// - FULL (2876): current baseline, with six uint16_t filter masks.
|
||||
//
|
||||
// FULL is the maximum written, not the default: MQTTPrefsCodec::payloadLenFor()
|
||||
// keeps emitting PRE_FILTER while every slot holds the all-types default, so a
|
||||
// node that never touches a filter stays readable by pre-filter firmware. See
|
||||
// the rollback note there -- /mqtt_prefs also carries the WiFi credentials.
|
||||
static const size_t MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE = 2736;
|
||||
static const size_t MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE = 2860;
|
||||
static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2864;
|
||||
static const size_t MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE = 2864;
|
||||
static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2876;
|
||||
|
||||
// /mqtt_prefs starts with a self-describing 8-byte header. Headerless files
|
||||
// are deployed legacy layouts and continue to be distinguished by size.
|
||||
@@ -266,6 +285,8 @@ static_assert(offsetof(MQTTPrefs, mqtt_neighbors_enabled) == 2857,
|
||||
"neighbors enable flag must sit at the flex-compatible offset");
|
||||
static_assert(offsetof(MQTTPrefs, mqtt_neighbors_interval) == MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE,
|
||||
"neighbors interval offset must equal the pre-neighbors payload size");
|
||||
static_assert(offsetof(MQTTPrefs, mqtt_slot_packet_filter) == MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE,
|
||||
"packet filters must begin at the pre-filter payload boundary");
|
||||
static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed");
|
||||
static_assert(sizeof(PreWifiPowerOldMQTTPrefs) == 472, "frozen pre-WiFi-power /mqtt_prefs layout changed");
|
||||
static_assert(offsetof(OldMQTTPrefs, wifi_power_save) == 144,
|
||||
|
||||
@@ -33,7 +33,7 @@ enum MQTTAuthType : uint8_t {
|
||||
|
||||
enum MQTTTopicStyle : uint8_t {
|
||||
MQTT_TOPIC_MESHCORE, // meshcore/{iata}/{device_id}/{status|packets|raw}
|
||||
MQTT_TOPIC_MESHRANK, // meshrank/uplink/{token}/{device_id}/packets (packets only)
|
||||
MQTT_TOPIC_MESHRANK, // meshrank/uplink/{token}/{device_id}/{type} (no raw)
|
||||
};
|
||||
|
||||
struct MQTTPresetDef {
|
||||
@@ -79,7 +79,7 @@ static inline bool mqttPresetNeedsSlotCredentials(const MQTTPresetDef* preset) {
|
||||
}
|
||||
|
||||
// Number of built-in presets
|
||||
static const int MQTT_PRESET_COUNT = 29;
|
||||
static const int MQTT_PRESET_COUNT = 34;
|
||||
|
||||
// Keep the certificate and preset tables in one translation unit. Defining
|
||||
// these as header-local constants created a complete flash copy in every MQTT
|
||||
@@ -165,13 +165,15 @@ extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {
|
||||
{ "ctmesh", "mqtt://mqtt.ctmesh.org:1883", nullptr, nullptr, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 60, "meshdev", "large4cats"},
|
||||
{ "chimesh", "wss://mqtt.chimesh.org:443", "mqtt.chimesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "meshat.se", "wss://meshcore-mqtt.meshat.se:443", "meshcore-mqtt.meshat.se", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "eastidahomesh", "wss://broker.eastidahomesh.net:443", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "eastidahomesh", "mqtt://live.eastidahomesh.com:1883", nullptr, nullptr, MQTT_AUTH_NONE, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "coloradomesh", "wss://mqtt.meshcore.coloradomesh.org:443","mqtt.meshcore.coloradomesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "dutchmeshcore-1", "wss://collector1.dutchmeshcore.nl:443/mqtt", "collector1.dutchmeshcore.nl", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "dutchmeshcore-2", "wss://collector2.dutchmeshcore.nl:443/mqtt", "collector2.dutchmeshcore.nl", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "meshcore-ca-1", "wss://mqtt1.meshcore.ca:443/mqtt", "mqtt1.meshcore.ca", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "meshcore-ca-2", "wss://mqtt2.meshcore.ca:443/mqtt", "mqtt2.meshcore.ca", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "meshcore-fi", "wss://mc-mqtt.meshcore.fi:443/", "mc-mqtt.meshcore.fi", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "okimesh-1", "wss://mqtt1.okimesh.org:9002/mqtt", "mqtt1.okimesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "okimesh-2", "wss://mqtt2.okimesh.org:9002/mqtt", "mqtt2.okimesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "inwmesh", "mqtts://scope.inwmesh.org:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "bostonmesh", "wss://mqttmc01.bostonme.sh:443/mqtt", "mqttmc01.bostonme.sh", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "rflab", "wss://mqtt.rflab.io:443", "mqtt.rflab.io", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
@@ -183,6 +185,10 @@ extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {
|
||||
{ "mesh-chaun14", "mqtt://mqtt.mesh.chaun14.fr:1884", nullptr, nullptr, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 60, MQTT_USERPASS_USERNAME_PUBKEY, nullptr },
|
||||
// LetsMesh-compatible JWT; TLS is Let's Encrypt (ISRG Root X1), not GTS.
|
||||
{ "wcmesh", "wss://mqtt.wcmesh.com:443", "mqtt.wcmesh.com", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "atvirastinklas","wss://mqtt-mc.atvirastinklas.lt:443", "mqtt-mc.atvirastinklas.lt", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
// JWT token auth; LE Gen-Y ECDSA chain (YE2 -> Root YE -> X2) still anchors at ISRG Root X1.
|
||||
{ "gomesh", "wss://mqtt.gomesh.dev:443", "mqtt.gomesh.dev", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
{ "idahomesh", "wss://mqtt.idahomesh.org:443/mqtt", "mqtt.idahomesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
|
||||
};
|
||||
|
||||
#else
|
||||
|
||||
@@ -45,11 +45,12 @@ static inline bool mqttWriteTopic(char* buf, size_t buf_size, const char* format
|
||||
return written > 0 && (size_t)written < buf_size;
|
||||
}
|
||||
|
||||
// Build the complete topic for one publication. MeshRank is deliberately
|
||||
// packets-only; status, raw, and neighbors are unsupported by the current
|
||||
// broker contract (the type != PACKETS guard below rejects them all).
|
||||
// MeshCore routes require a configured IATA and device id. Custom templates may
|
||||
// omit either placeholder, so their individual values are allowed to be empty.
|
||||
// Build the complete topic for one publication. MeshRank takes status, packets,
|
||||
// and neighbors under meshrank/uplink/{token}/{device}/, using the same type
|
||||
// suffixes as the MeshCore layout, and requires a per-slot token rather than an
|
||||
// IATA. MeshCore routes require a configured IATA and device id. Custom
|
||||
// templates may omit either placeholder, so their individual values are allowed
|
||||
// to be empty.
|
||||
static inline bool mqttBuildPublicationTopic(MQTTTopicRouteStyle style, int type,
|
||||
const char* custom_template,
|
||||
const char* iata, const char* device,
|
||||
@@ -69,7 +70,9 @@ static inline bool mqttBuildPublicationTopic(MQTTTopicRouteStyle style, int type
|
||||
return mqttWriteTopic(buf, buf_size, "meshcore/%s/%s/%s", iata, device, type_name);
|
||||
|
||||
case MQTT_ROUTE_MESHRANK:
|
||||
if (type != MQTT_PUBLICATION_PACKETS || !token || token[0] == '\0' ||
|
||||
// Raw is deliberately withheld: highest-volume topic, and the broker does
|
||||
// not consume it. observer-firmware still sends it -- keep this on merge.
|
||||
if (type == MQTT_PUBLICATION_RAW || !token || token[0] == '\0' ||
|
||||
!device || device[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h> // size_t / NULL for the reply classifiers below
|
||||
#include <stdint.h>
|
||||
|
||||
// Fork-owned, dependency-free spec for the WebConfig "config batch / reboot /
|
||||
@@ -193,6 +194,110 @@ static inline uint32_t confirmSetupHandoffRebootAt(uint32_t now) {
|
||||
return scheduleAt(now, kSetupHandoffRebootConfirmMs);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CLI sequences (/api/cli). The terminal shares this one deferred-command slot
|
||||
// with config saves rather than owning a second MAX_BATCH array: both drain on
|
||||
// the loop task, both are single-slot, and a duplicate would cost ~8 KB of
|
||||
// permanently resident RAM. Sharing also makes a save and a CLI run mutually
|
||||
// exclusive, which they must be.
|
||||
//
|
||||
// Two things differ from a config save:
|
||||
// 1. results stream. A save's results appear only when the whole batch is
|
||||
// Done; a CLI read hands back whatever has executed so far, so a pasted
|
||||
// sequence fills the terminal command by command.
|
||||
// 2. the reboot is not requested by a `reboot` flag on the request but by the
|
||||
// word `reboot` appearing in the sequence. It is deferred rather than
|
||||
// executed, because Board::reboot() does not return and would take the node
|
||||
// down before the client could read a single result.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Results returned by one read. Bounds the JSON document built on the
|
||||
// async_tcp task; a longer sequence pages across successive reads.
|
||||
static const int kCliResultPage = 8;
|
||||
|
||||
static inline int cliPageCount(int from, int produced, int page) {
|
||||
const int pending = produced - from;
|
||||
if (pending <= 0) return 0;
|
||||
return pending > page ? page : pending;
|
||||
}
|
||||
|
||||
// "done" means the client has been handed every result, not merely that
|
||||
// execution finished: the last page may still be unread, and a client that
|
||||
// stops polling at "done" would lose it.
|
||||
static inline bool cliReadIsFinal(State state, int from, int page_count, int total) {
|
||||
return state == State::Done && from + page_count >= total;
|
||||
}
|
||||
|
||||
// A trailing `reboot` is withheld when any command in the sequence failed,
|
||||
// exactly as a config save's is. The operator asked for the reboot, but
|
||||
// rebooting into a half-applied config -- over a link they may not get back --
|
||||
// is the worse failure, and the result body reports the refusal.
|
||||
static inline bool cliRebootAllowed(bool has_reboot, bool all_ok) {
|
||||
return has_reboot && all_ok;
|
||||
}
|
||||
|
||||
// CommonCLI has no single failure convention. Testing only for an "Err" prefix
|
||||
// let five other shapes through as success -- including "Unknown command" and
|
||||
// "unknown config: x", the two an operator hits most -- which coloured them
|
||||
// green AND let a queued reboot proceed after them.
|
||||
//
|
||||
// Every shape below is a literal from CommonCLI.cpp / CommonCLI_Observer.cpp.
|
||||
// This list is the fragile part of the design: a new failure string added there
|
||||
// is silently a success here. Which is exactly why it must not be what decides
|
||||
// whether to reboot -- see cliReplyConfirmsWrite.
|
||||
static inline bool cliReplyIsFailure(const char* r) {
|
||||
if (r == NULL || r[0] == 0) return false; // empty is normalised to "OK"
|
||||
static const char* const kPrefixes[] = {
|
||||
"Err", "ERR", "err", // "Err - ", "ERR: ", "Error: "
|
||||
"(ERR", // "(ERR: clock cannot go backwards)"
|
||||
"Unknown command",
|
||||
"unknown config",
|
||||
"??", // "??: <key>" from the get fallthrough
|
||||
"Can't find", // "Can't find GPS"
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(kPrefixes) / sizeof(kPrefixes[0]); i++) {
|
||||
const char* p = kPrefixes[i];
|
||||
size_t n = 0;
|
||||
while (p[n]) n++;
|
||||
bool match = true;
|
||||
for (size_t j = 0; j < n; j++) {
|
||||
if (r[j] != p[j]) { match = false; break; }
|
||||
}
|
||||
if (match) return true;
|
||||
}
|
||||
// "File system erase: Err" reports the failure at the END of the reply.
|
||||
for (size_t i = 0; r[i]; i++) {
|
||||
if (r[i] == ':' && r[i + 1] == ' ' && r[i + 2] == 'E' && r[i + 3] == 'r' &&
|
||||
r[i + 4] == 'r') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether a command's reply is allowed to gate the deferred reboot.
|
||||
//
|
||||
// Only writes are, and only writes have a reply convention worth trusting:
|
||||
// every setter answers with an "OK" prefix. Diagnostics do not -- `memory`
|
||||
// answers "Free: ...", a getter answers "> value" -- so letting them gate would
|
||||
// mean guessing, and guessing wrong here either strands the operator (a
|
||||
// harmless `memory` blocks their reboot) or reboots into a config that did not
|
||||
// apply. The question the gate exists to answer is narrower than "did anything
|
||||
// fail": it is "did every setting I asked for actually take".
|
||||
static inline bool cliReplyGatesReboot(const char* cmd) {
|
||||
if (cmd == NULL) return false;
|
||||
const char* set = "set ";
|
||||
const char* pwd = "password ";
|
||||
bool is_set = true, is_pwd = true;
|
||||
for (int i = 0; i < 4; i++) if (cmd[i] != set[i]) { is_set = false; break; }
|
||||
for (int i = 0; i < 9; i++) if (cmd[i] != pwd[i]) { is_pwd = false; break; }
|
||||
return is_set || is_pwd;
|
||||
}
|
||||
|
||||
// A write took effect iff its reply starts with "OK" -- the one convention every
|
||||
// setter in CommonCLI actually keeps.
|
||||
static inline bool cliWriteSucceeded(const char* reply) {
|
||||
return reply != NULL && reply[0] == 'O' && reply[1] == 'K';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Reboot fire (.cpp:262-265) and isRebootPending (.cpp:70-74).
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -31,6 +31,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = {
|
||||
};
|
||||
static const char* const WC_ALLOWED_SLOT_KEYS[] = {
|
||||
"preset", "server", "port", "username", "password", "token", "topic", "audience",
|
||||
"filter",
|
||||
};
|
||||
|
||||
// True when `key` is a well-formed per-slot key ("mqttN.<field>" with N in
|
||||
@@ -82,6 +83,28 @@ static inline bool wcIsSecretKey(const char* key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// CommonCLI answers a secret getter in plaintext only for the serial console
|
||||
// (sender_timestamp 0) and masks it for remote callers. The web CLI executes
|
||||
// with sender_timestamp 0 -- that is what makes `erase`, `stats-*` and `set freq`
|
||||
// reachable -- so it would otherwise inherit the serial console's plaintext
|
||||
// answers for an HTTP request. This says which `get` commands must be masked
|
||||
// back down, restoring the distinction for a caller not at the serial port.
|
||||
//
|
||||
// Writing these has always been possible from the portal; reading them never
|
||||
// was, because handleConfigGet masks them (wcIsSecretKey). The two are different
|
||||
// capabilities: replacing a WiFi password does not reveal the current one, and
|
||||
// replacing an identity does not reveal the existing private key.
|
||||
static inline bool wcIsSecretReadCommand(const char* cmd) {
|
||||
if (strncmp(cmd, "get ", 4) != 0) return false;
|
||||
const char* key = cmd + 4;
|
||||
while (*key == ' ') key++;
|
||||
if (strcmp(key, "prv.key") == 0) return true; // this node's identity
|
||||
if (strcmp(key, "guest.password") == 0) return true;
|
||||
if (strcmp(key, "alert.psk") == 0) return true;
|
||||
if (strcmp(key, "bridge.secret") == 0) return true;
|
||||
return wcIsSecretKey(key); // wifi.pwd, mqttN.password, mqttN.token
|
||||
}
|
||||
|
||||
// Browser-generated request IDs are exactly eight random bytes encoded as
|
||||
// hexadecimal. Keeping the grammar deliberately small makes the ID safe to
|
||||
// echo in JSON/logs and prevents an empty or truncated ID from weakening the
|
||||
|
||||
@@ -351,10 +351,17 @@ void MQTTBridge::formatMqttStatsReply(char* buf, size_t bufsize) {
|
||||
|
||||
// drops=<outbox>/<skipped>: outbox-cap drops vs. memory-pressure skips.
|
||||
int pos = 0;
|
||||
replyAppendf(buf, bufsize, &pos, "> Free=%d Max=%d q:%d/%d Outbox=%u drops=%lu/%d |",
|
||||
replyAppendf(buf, bufsize, &pos, "> Free=%d Max=%d q:%d/%d Outbox=%u drops=%lu/%d",
|
||||
(int)ESP.getFreeHeap(), (int)ESP.getMaxAllocHeap(),
|
||||
q, MAX_QUEUE_SIZE, (unsigned)outbox_total,
|
||||
outbox_drops, b->_skipped_publishes);
|
||||
// filt=<n>: packets the per-slot type filters rejected before the queue.
|
||||
// Omitted while zero so an unfiltered node's reply keeps its former length --
|
||||
// the per-slot list below is what usually gets clamped away first.
|
||||
if (b->_filtered_packets > 0) {
|
||||
replyAppendf(buf, bufsize, &pos, " filt=%lu", b->_filtered_packets);
|
||||
}
|
||||
replyAppendf(buf, bufsize, &pos, " |");
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
||||
if (!b->_slots[i].enabled || !b->_slots[i].client) continue;
|
||||
replyAppendf(buf, bufsize, &pos, " s%d=%lu/%lu", i + 1,
|
||||
@@ -392,6 +399,9 @@ bool MQTTBridge::getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out)
|
||||
out->has_publish_counts = true;
|
||||
out->publish_ok = slot.client ? slot.client->getPublishOk() : 0;
|
||||
out->publish_err = slot.client ? slot.client->getPublishErr() : 0;
|
||||
// Lets the portal show why a healthy slot is quiet.
|
||||
out->filter_mask = b->_obs ? b->_obs->mqtt_slot_packet_filter[slot_index]
|
||||
: MQTTPacketFilter::kAllPacketTypes;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -498,6 +508,17 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
|
||||
|
||||
// replyAppendf clamps pos on every call, so the chained appends below can't
|
||||
// walk past the reply buffer even if the accumulated text exceeds it (A1).
|
||||
// A non-default filter is the one healthy-looking reason for a slot to stop
|
||||
// publishing, so it has to appear -- but appended last. replyAppendf clamps at
|
||||
// the 160-byte reply, and an error tail (TLS + mbedTLS + errno + age) can
|
||||
// already reach ~117 chars, so putting the filter first would push the
|
||||
// operator's diagnostic detail off the end of a failing slot's line.
|
||||
const uint16_t filter_mask = b->_obs ? b->_obs->mqtt_slot_packet_filter[slot_index]
|
||||
: MQTTPacketFilter::kAllPacketTypes;
|
||||
char filter_text[MQTTPacketFilter::kFilterTextSize];
|
||||
const bool show_filter = filter_mask != MQTTPacketFilter::kAllPacketTypes &&
|
||||
MQTTPacketFilter::format(filter_mask, filter_text, sizeof(filter_text));
|
||||
|
||||
int pos = 0;
|
||||
replyAppendf(buf, bufsize, &pos, "> mqtt%d: %s", slot_index + 1, state);
|
||||
if (slot.disconnect_count > 0) {
|
||||
@@ -508,14 +529,10 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
|
||||
}
|
||||
}
|
||||
|
||||
// If connected with no errors, we're done
|
||||
// Connected with no errors: nothing more to say about the connection.
|
||||
if (slot.connected && slot.last_error_time == 0) {
|
||||
replyAppendf(buf, bufsize, &pos, ", no errors");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show last error if we have one
|
||||
if (slot.last_error_time > 0) {
|
||||
} else if (slot.last_error_time > 0) {
|
||||
// TLS error with human-friendly description
|
||||
if (slot.last_tls_err != 0) {
|
||||
const char* desc = tlsErrorStr(slot.last_tls_err);
|
||||
@@ -545,6 +562,26 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
|
||||
} else if (!slot.connected) {
|
||||
replyAppendf(buf, bufsize, &pos, ", no error info");
|
||||
}
|
||||
|
||||
// Appended last so it never displaces connection diagnostics. replyAppendf
|
||||
// clamps rather than overflows, but a clipped type list is worse than no
|
||||
// list: "...,13,14," parses as a real, different allowlist, and this is the
|
||||
// one line an operator reads to find out why a slot is quiet. So the exact
|
||||
// text is only emitted when it fits whole; otherwise fall back to a count,
|
||||
// which cannot be misread. `get mqttN.filter` always has the exact value.
|
||||
if (show_filter) {
|
||||
static const int kFilterLabelLen = (int)sizeof(", filter:") - 1;
|
||||
const int remaining = pos < (int)bufsize ? (int)bufsize - 1 - pos : 0;
|
||||
const uint8_t allowed = MQTTPacketFilter::countTypes(filter_mask);
|
||||
const int compact_len = kFilterLabelLen + (allowed >= 10 ? 5 : 4); // "N/16"
|
||||
if (kFilterLabelLen + (int)strlen(filter_text) <= remaining) {
|
||||
replyAppendf(buf, bufsize, &pos, ", filter:%s", filter_text);
|
||||
} else if (compact_len <= remaining) {
|
||||
replyAppendf(buf, bufsize, &pos, ", filter:%u/16", (unsigned)allowed);
|
||||
}
|
||||
// Neither fits: the slot is reporting so much error detail that the filter
|
||||
// is the least useful field on the line. Omit it rather than mislead.
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded cooperative-stop timeout for end() (see MQTTLifecycle::Coordinator).
|
||||
@@ -718,18 +755,23 @@ void MQTTBridge::allocateRuntimeBuffers() {
|
||||
_publish_json_buffer, PUBLISH_JSON_BUFFER_SIZE, psram_malloc));
|
||||
_status_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
|
||||
_status_json_buffer, STATUS_JSON_BUFFER_SIZE, psram_malloc));
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
// Persistent neighbors JSON buffer. Unlike status/packet there is no stack
|
||||
// fallback: the feature is PSRAM-gated, so a nullptr simply disables publishing
|
||||
// (requestPublishNeighbors/publishNeighbors both no-op on nullptr).
|
||||
_neighbors_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
|
||||
_neighbors_json_buffer, NEIGHBORS_JSON_BUFFER_SIZE, psram_malloc));
|
||||
#endif
|
||||
MQTT_DEBUG_PRINTLN("Runtime buffers: raw=%s publish=%s status=%s",
|
||||
_last_raw_data ? "PSRAM" : "unavailable",
|
||||
_publish_json_buffer ? "PSRAM" : "stack fallback",
|
||||
_status_json_buffer ? "PSRAM" : "stack fallback");
|
||||
#endif
|
||||
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
// Persistent neighbors JSON buffer, heap-allocated on every board: too large to
|
||||
// keep inline in the bridge object the way the non-PSRAM status/packet buffers
|
||||
// are. psram_malloc() falls back to internal DRAM, so this works without PSRAM.
|
||||
// Unlike status/packet there is no stack fallback -- a nullptr simply disables
|
||||
// publishing (requestPublishNeighbors/publishNeighbors both no-op on nullptr).
|
||||
_neighbors_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
|
||||
_neighbors_json_buffer, NEIGHBORS_JSON_BUFFER_SIZE, psram_malloc));
|
||||
MQTT_DEBUG_PRINTLN("Neighbors buffer: %s",
|
||||
_neighbors_json_buffer ? "ready" : "unavailable");
|
||||
#endif
|
||||
}
|
||||
|
||||
void MQTTBridge::releaseRuntimeBuffers() {
|
||||
@@ -740,13 +782,15 @@ void MQTTBridge::releaseRuntimeBuffers() {
|
||||
_publish_json_buffer, psram_free));
|
||||
_status_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::release(
|
||||
_status_json_buffer, psram_free));
|
||||
#endif
|
||||
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
// Paired with the unconditional allocation in allocateRuntimeBuffers().
|
||||
_neighbors_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::release(
|
||||
_neighbors_json_buffer, psram_free));
|
||||
_neighbors_publish_len = 0;
|
||||
_neighbors_publish_pending.store(false, std::memory_order_release);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// Never pair a newly allocated raw buffer with metadata from a prior bridge
|
||||
// run. This also makes non-PSRAM restarts discard their stale raw cache.
|
||||
@@ -2322,7 +2366,7 @@ void MQTTBridge::publishStatusToSlot(int index) {
|
||||
// Build per-slot topic (handles IATA check for meshcore, token check for meshrank)
|
||||
char status_topic[128];
|
||||
if (!buildTopicForSlot(index, MSG_STATUS, status_topic, sizeof(status_topic))) {
|
||||
return; // Slot doesn't support status (e.g., meshrank) or missing required config
|
||||
return; // Slot is missing required topic configuration
|
||||
}
|
||||
|
||||
// Reuse pre-allocated buffer to avoid heap alloc/free churn under memory pressure.
|
||||
@@ -2804,15 +2848,16 @@ void MQTTBridge::loop() {
|
||||
void MQTTBridge::onPacketReceived(mesh::Packet *packet) {
|
||||
if (!_initialized || !_obs->mqtt_packets_enabled || !_obs->mqtt_rx_enabled) return;
|
||||
|
||||
// Check if we have any enabled slots to send to
|
||||
bool has_valid_slots = false;
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
||||
if (_slots[i].enabled && _slots[i].client) {
|
||||
has_valid_slots = true;
|
||||
break;
|
||||
}
|
||||
// Drop before the queue copy when no configured slot allows this payload
|
||||
// type. A QueuedPacket carries the packet plus up to 256 bytes of raw radio
|
||||
// data and crosses to Core 0, so filtering here (not at publish time) is
|
||||
// what actually saves the queue slot and the memcpy. This also subsumes the
|
||||
// older "is any slot configured at all?" pre-check.
|
||||
bool filtered = false;
|
||||
if (!shouldQueuePacketType(packet->getPayloadType(), filtered)) {
|
||||
if (filtered) _filtered_packets++;
|
||||
return;
|
||||
}
|
||||
if (!has_valid_slots) return;
|
||||
|
||||
// Queue packet for transmission
|
||||
queuePacket(packet, false);
|
||||
@@ -2830,6 +2875,13 @@ void MQTTBridge::sendPacket(mesh::Packet *packet) {
|
||||
if (!_identity || memcmp(_identity->pub_key, packet->payload, PUB_KEY_SIZE) != 0) return;
|
||||
}
|
||||
|
||||
// Same pre-queue filter gate as the RX path.
|
||||
bool filtered = false;
|
||||
if (!shouldQueuePacketType(packet->getPayloadType(), filtered)) {
|
||||
if (filtered) _filtered_packets++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue packet for transmission
|
||||
queuePacket(packet, true);
|
||||
}
|
||||
@@ -2921,7 +2973,9 @@ void MQTTBridge::processPacketQueue() {
|
||||
_last_raw_timestamp = millis();
|
||||
}
|
||||
|
||||
bool packet_eligible = false;
|
||||
bool packet_published = publishPacket(&queued.packet_copy, queued.is_tx,
|
||||
packet_eligible,
|
||||
queued.has_raw_data ? queued.raw_data : nullptr,
|
||||
queued.has_raw_data ? queued.raw_len : 0,
|
||||
queued.snr, queued.rssi);
|
||||
@@ -2929,15 +2983,21 @@ void MQTTBridge::processPacketQueue() {
|
||||
|
||||
// Publish raw if enabled (live from prefs so `set mqtt.raw` applies without
|
||||
// a bridge restart)
|
||||
bool raw_eligible = false;
|
||||
bool raw_published = false;
|
||||
if (_obs->mqtt_raw_enabled) {
|
||||
raw_published = publishRaw(&queued.packet_copy);
|
||||
raw_published = publishRaw(&queued.packet_copy, raw_eligible);
|
||||
}
|
||||
|
||||
bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published);
|
||||
// Decide intentional completion once across the entire queue item. An
|
||||
// ineligible raw path (for example, MeshRank, which does not take raw)
|
||||
// must not hide a failed eligible structured publish.
|
||||
const bool queue_complete = MQTTPacketFilter::publishComplete(
|
||||
packet_eligible || raw_eligible,
|
||||
MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published));
|
||||
const MQTTPacketQueuePolicy::RetryDecision retry =
|
||||
MQTTPacketQueuePolicy::retryDecision(
|
||||
any_published, queued.retry_attempts,
|
||||
queue_complete, queued.retry_attempts,
|
||||
static_cast<uint32_t>(now_ms));
|
||||
if (retry.action == MQTTPacketQueuePolicy::RetryAction::Schedule) {
|
||||
queued.retry_attempts = retry.retry_attempts;
|
||||
@@ -3048,22 +3108,30 @@ void MQTTBridge::processPacketQueue() {
|
||||
_last_raw_timestamp = millis();
|
||||
}
|
||||
|
||||
bool packet_eligible = false;
|
||||
bool packet_published = publishPacket(&queued.packet_copy, queued.is_tx,
|
||||
packet_eligible,
|
||||
queued.has_raw_data ? queued.raw_data : nullptr,
|
||||
queued.has_raw_data ? queued.raw_len : 0,
|
||||
queued.snr, queued.rssi);
|
||||
// No taskYIELD() on non-ESP32 platforms (non-FreeRTOS, cooperative scheduling not needed)
|
||||
|
||||
// Live from prefs so `set mqtt.raw` applies without a bridge restart.
|
||||
bool raw_eligible = false;
|
||||
bool raw_published = false;
|
||||
if (_obs->mqtt_raw_enabled) {
|
||||
raw_published = publishRaw(&queued.packet_copy);
|
||||
raw_published = publishRaw(&queued.packet_copy, raw_eligible);
|
||||
}
|
||||
|
||||
bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published);
|
||||
// Decide intentional completion once across the entire queue item. An
|
||||
// ineligible raw path (for example, MeshRank, which does not take raw)
|
||||
// must not hide a failed eligible structured publish.
|
||||
const bool queue_complete = MQTTPacketFilter::publishComplete(
|
||||
packet_eligible || raw_eligible,
|
||||
MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published));
|
||||
const MQTTPacketQueuePolicy::RetryDecision retry =
|
||||
MQTTPacketQueuePolicy::retryDecision(
|
||||
any_published, queued.retry_attempts,
|
||||
queue_complete, queued.retry_attempts,
|
||||
static_cast<uint32_t>(now_ms));
|
||||
if (retry.action == MQTTPacketQueuePolicy::RetryAction::Schedule) {
|
||||
queued.retry_attempts = retry.retry_attempts;
|
||||
@@ -3110,6 +3178,69 @@ void MQTTBridge::processPacketQueue() {
|
||||
// Publishing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Which slots will actually take this payload type, resolved in two stages so
|
||||
// the cheap test runs first: the filter/enabled check costs a bit test, while
|
||||
// the topic build costs an snprintf. Both are far cheaper than the ~2 KB JSON
|
||||
// serialisation they gate, so this must complete before a caller builds a
|
||||
// message -- a slot that cannot form a topic (MeshRank raw, or a meshcore
|
||||
// preset with no IATA set) would otherwise pay for a document nobody receives.
|
||||
//
|
||||
// A slot's connection state is deliberately not consulted: a configured but
|
||||
// disconnected broker is still a target, so the packet stays on the queue for
|
||||
// the existing bounded retry rather than being dropped as complete.
|
||||
uint8_t MQTTBridge::eligiblePacketSlots(uint8_t packet_type, MQTTMessageType type) {
|
||||
static_assert(RUNTIME_MQTT_SLOTS <= 8, "eligible slot mask must fit in uint8_t");
|
||||
// Per-slot filters cover packet traffic only. Status and neighbors are
|
||||
// documented as never filtered, so refuse those publication types here
|
||||
// rather than silently applying a packet mask if a caller is added later.
|
||||
if (!_obs || (type != MSG_PACKETS && type != MSG_RAW)) return 0;
|
||||
|
||||
uint8_t eligible_slots = 0;
|
||||
char topic[128];
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
|
||||
const bool slot_enabled = _slots[i].enabled && _slots[i].client != nullptr;
|
||||
// Load once so a live CLI/WebConfig update cannot split this packet's
|
||||
// decision across two different masks.
|
||||
const uint16_t filter_mask = _obs->mqtt_slot_packet_filter[i];
|
||||
if (!MQTTPacketFilter::slotCandidate(slot_enabled, filter_mask, packet_type)) continue;
|
||||
|
||||
const bool topic_supported = buildTopicForSlot(i, type, topic, sizeof(topic));
|
||||
if (MQTTPacketFilter::slotEligible(slot_enabled, topic_supported,
|
||||
filter_mask, packet_type)) {
|
||||
eligible_slots |= static_cast<uint8_t>(1u << i);
|
||||
}
|
||||
}
|
||||
return eligible_slots;
|
||||
}
|
||||
|
||||
// Conservative pre-queue gate: the OR of every configured slot's mask. Runs on
|
||||
// Core 1 in the radio callback, so it must stay cheap -- a packet no broker
|
||||
// wants is rejected before it is copied into the queue at all. `filtered`
|
||||
// separates "a broker is configured but none wants this type" (worth counting)
|
||||
// from "no broker configured at all" (the pre-existing silent drop).
|
||||
bool MQTTBridge::shouldQueuePacketType(uint8_t packet_type, bool& filtered) {
|
||||
filtered = false;
|
||||
if (!_obs) return false;
|
||||
|
||||
uint16_t masks[RUNTIME_MQTT_SLOTS];
|
||||
bool enabled[RUNTIME_MQTT_SLOTS];
|
||||
bool any_enabled = false;
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
|
||||
masks[i] = _obs->mqtt_slot_packet_filter[i];
|
||||
enabled[i] = _slots[i].enabled && _slots[i].client != nullptr;
|
||||
any_enabled = any_enabled || enabled[i];
|
||||
}
|
||||
if (!any_enabled) return false;
|
||||
|
||||
if (MQTTPacketFilter::allows(
|
||||
MQTTPacketFilter::enabledUnion(masks, enabled, RUNTIME_MQTT_SLOTS),
|
||||
packet_type)) {
|
||||
return true;
|
||||
}
|
||||
filtered = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MQTTBridge::publishStatus() {
|
||||
if (!_cached_has_connected_slots) {
|
||||
return false;
|
||||
@@ -3200,8 +3331,8 @@ bool MQTTBridge::publishStatus() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no connected slot accepts status topics (e.g. meshrank is packets-only),
|
||||
// treat as success to avoid infinite retry loops
|
||||
// If no connected slot accepts status topics, treat as success to avoid
|
||||
// infinite retry loops
|
||||
if (published || !any_slot_wants_status) {
|
||||
if (published) MQTT_DEBUG_PRINTLN("Status published");
|
||||
return true;
|
||||
@@ -3212,10 +3343,19 @@ bool MQTTBridge::publishStatus() {
|
||||
}
|
||||
|
||||
bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
|
||||
bool& has_eligible_target,
|
||||
const uint8_t* raw_data, int raw_len,
|
||||
float snr, float rssi) {
|
||||
has_eligible_target = false;
|
||||
if (!packet) return false;
|
||||
|
||||
const uint8_t packet_type = packet->getPayloadType();
|
||||
const uint8_t eligible_slots = eligiblePacketSlots(packet_type, MSG_PACKETS);
|
||||
has_eligible_target = eligible_slots != 0;
|
||||
// Filtered out everywhere, or no slot can form a packets topic: intentionally
|
||||
// complete, and nothing below (JSON build included) is worth paying for.
|
||||
if (!has_eligible_target) return false;
|
||||
|
||||
refreshOriginFromPrefs();
|
||||
|
||||
// Memory pressure check: Skip publishes when there's not enough contiguous
|
||||
@@ -3321,7 +3461,11 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
|
||||
bool published = false;
|
||||
char topic[128];
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
||||
if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
|
||||
// Eligibility already proved this slot's topic builds; rebuilding it is
|
||||
// one snprintf, cheaper than carrying a RUNTIME_MQTT_SLOTS x 128 topic
|
||||
// cache on the MQTT task's 8 KB stack alongside the JSON buffer.
|
||||
if ((eligible_slots & static_cast<uint8_t>(1u << i)) != 0 &&
|
||||
_slots[i].enabled && _slots[i].client && _slots[i].connected) {
|
||||
if (buildTopicForSlot(i, MSG_PACKETS, topic, sizeof(topic))) {
|
||||
if (publishToSlot(i, topic, active_buffer, false)) {
|
||||
published = true;
|
||||
@@ -3331,7 +3475,6 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
|
||||
}
|
||||
return published;
|
||||
} else {
|
||||
uint8_t packet_type = packet->getPayloadType();
|
||||
if (packet_type == 4 || packet_type == 9) {
|
||||
MQTT_DEBUG_PRINTLN("Failed to build packet JSON for type=%d (len=%d), packet not published", packet_type, len);
|
||||
}
|
||||
@@ -3339,9 +3482,17 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MQTTBridge::publishRaw(mesh::Packet* packet) {
|
||||
bool MQTTBridge::publishRaw(mesh::Packet* packet, bool& has_eligible_target) {
|
||||
has_eligible_target = false;
|
||||
if (!packet) return false;
|
||||
|
||||
const uint8_t packet_type = packet->getPayloadType();
|
||||
const uint8_t eligible_slots = eligiblePacketSlots(packet_type, MSG_RAW);
|
||||
has_eligible_target = eligible_slots != 0;
|
||||
// Filtered out everywhere, or no slot has a raw topic (MeshRank does not take
|
||||
// raw): intentionally complete, and no JSON is built.
|
||||
if (!has_eligible_target) return false;
|
||||
|
||||
refreshOriginFromPrefs();
|
||||
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
@@ -3372,7 +3523,8 @@ bool MQTTBridge::publishRaw(mesh::Packet* packet) {
|
||||
bool published = false;
|
||||
char topic[128];
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
||||
if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
|
||||
if ((eligible_slots & static_cast<uint8_t>(1u << i)) != 0 &&
|
||||
_slots[i].enabled && _slots[i].client && _slots[i].connected) {
|
||||
if (buildTopicForSlot(i, MSG_RAW, topic, sizeof(topic))) {
|
||||
if (publishToSlot(i, topic, active_buffer, false)) {
|
||||
published = true;
|
||||
@@ -3419,11 +3571,12 @@ bool MQTTBridge::publishNeighbors() {
|
||||
char topic[128];
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
||||
if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
|
||||
// MeshRank slots reject non-packets by contract, so buildTopicForSlot
|
||||
// returns false for them here and the slot is skipped.
|
||||
// Slots that cannot form a neighbors topic are skipped here.
|
||||
if (buildTopicForSlot(i, MSG_NEIGHBORS, topic, sizeof(topic))) {
|
||||
// Neighbor snapshots are periodically refreshed. Publish synchronously
|
||||
// at QoS 0 to avoid the QoS 1 outbox, retaining where the broker allows.
|
||||
bool use_retain = _slots[i].preset ? _slots[i].preset->allow_retain : false;
|
||||
if (publishToSlot(i, topic, _neighbors_json_buffer, use_retain, 1)) {
|
||||
if (publishToSlot(i, topic, _neighbors_json_buffer, use_retain, 0)) {
|
||||
published = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <WiFiUdp.h>
|
||||
#include <Timezone.h>
|
||||
#include "helpers/JWTHelper.h"
|
||||
#include "helpers/MQTTPacketFilter.h"
|
||||
#include "helpers/MQTTPresets.h"
|
||||
#include "helpers/MQTTLifecycle.h"
|
||||
#include <atomic>
|
||||
@@ -52,11 +53,15 @@ struct MQTTNodeInfo {
|
||||
bool repeat_when_nonzero = true;
|
||||
};
|
||||
|
||||
// Periodic neighbors publication is PSRAM-only: it needs a persistent ~10 KB JSON
|
||||
// buffer plus a second transient one while the mesh builds the table, and it keys
|
||||
// off the mesh neighbor cache (sized by MAX_NEIGHBOURS). Every neighbors-specific
|
||||
// member, method, and code block in this bridge is gated on WITH_MQTT_NEIGHBORS.
|
||||
#if defined(BOARD_HAS_PSRAM) && defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0
|
||||
// Periodic neighbors publication keys off the mesh neighbor cache (sized by
|
||||
// MAX_NEIGHBOURS) and needs a persistent JSON buffer plus a second transient one
|
||||
// while the mesh builds the table. PSRAM boards get it automatically; non-PSRAM
|
||||
// boards must opt in per variant with MQTT_NEIGHBORS_WITHOUT_PSRAM, which spends
|
||||
// internal DRAM the TLS stack also needs (see NEIGHBORS_JSON_BUFFER_SIZE). Every
|
||||
// neighbors-specific member, method, and code block here is gated on
|
||||
// WITH_MQTT_NEIGHBORS.
|
||||
#if defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0 && \
|
||||
(defined(BOARD_HAS_PSRAM) || defined(MQTT_NEIGHBORS_WITHOUT_PSRAM))
|
||||
#define WITH_MQTT_NEIGHBORS 1
|
||||
#endif
|
||||
|
||||
@@ -349,6 +354,10 @@ private:
|
||||
unsigned long _last_memory_check;
|
||||
bool _memory_pressure = false; // Cached max-alloc verdict; re-sampled at most once per interval in publishPacket() so the heap walk isn't paid per-packet under pressure
|
||||
int _skipped_publishes; // Exposed via SNMP; count of publishes skipped when max_alloc is too low
|
||||
// Packets rejected by the per-slot filters before reaching the queue. Written
|
||||
// on Core 1 (radio callbacks), read on Core 0 for `mqtt.stats`; a torn read of
|
||||
// a diagnostic counter is harmless, so no atomic is warranted.
|
||||
unsigned long _filtered_packets = 0;
|
||||
|
||||
// Status publish retry tracking
|
||||
unsigned long _last_status_retry; // Track last retry attempt (separate from successful publish)
|
||||
@@ -393,6 +402,8 @@ private:
|
||||
enum MQTTMessageType { MSG_STATUS, MSG_PACKETS, MSG_RAW, MSG_NEIGHBORS };
|
||||
bool buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size);
|
||||
bool substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size);
|
||||
uint8_t eligiblePacketSlots(uint8_t packet_type, MQTTMessageType type);
|
||||
bool shouldQueuePacketType(uint8_t packet_type, bool& filtered);
|
||||
|
||||
// Internal methods - slot management
|
||||
// Lifetime model (Phase 1 of MQTT memory-defrag):
|
||||
@@ -428,10 +439,10 @@ private:
|
||||
void mqttTaskLoop(); // Main loop for MQTT task
|
||||
void initializeWiFiInTask(); // WiFi initialization moved to task
|
||||
#endif
|
||||
bool publishPacket(mesh::Packet* packet, bool is_tx,
|
||||
bool publishPacket(mesh::Packet* packet, bool is_tx, bool& has_eligible_target,
|
||||
const uint8_t* raw_data = nullptr, int raw_len = 0,
|
||||
float snr = 0.0f, float rssi = 0.0f);
|
||||
bool publishRaw(mesh::Packet* packet);
|
||||
bool publishRaw(mesh::Packet* packet, bool& has_eligible_target);
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
// Publishes the pending _neighbors_json_buffer to every connected slot's
|
||||
// neighbors topic. Runs on the MQTT task (Core 0) only.
|
||||
@@ -537,7 +548,38 @@ public:
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
// Single source of truth for the neighbors JSON size, used by both the bridge's
|
||||
// persistent buffer and the mesh's transient build buffer.
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
static const size_t NEIGHBORS_JSON_BUFFER_SIZE = 10240;
|
||||
#else
|
||||
// Without PSRAM all three neighbors allocations (persistent buffer, transient
|
||||
// build buffer, ArduinoJson pool) come out of internal DRAM, which each TLS
|
||||
// slot also needs ~40 KB of. A quarter-size buffer keeps the peak near 13 KB
|
||||
// instead of ~35 KB; the builder drops the tail and sets "truncated" once the
|
||||
// next entry will not fit, so the table degrades instead of failing.
|
||||
static const size_t NEIGHBORS_JSON_BUFFER_SIZE = 4096;
|
||||
#endif
|
||||
|
||||
// The ArduinoJson pool is NOT bounded by the text buffer: v7 hands out pool
|
||||
// blocks in fixed 4096-byte chunks, so a table that just fits the text buffer
|
||||
// can still need well over it in pool. Budgeting the pool at the text size
|
||||
// starves it, and a starved pool sets doc.overflowed() -- which drops the whole
|
||||
// publish instead of truncating. Measured need is 12541 B for 50 entries and
|
||||
// 4286 B for 21, so allow a spare block over each.
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
static const size_t NEIGHBORS_DOC_POOL_BUDGET = 16384;
|
||||
#else
|
||||
static const size_t NEIGHBORS_DOC_POOL_BUDGET = 12288;
|
||||
#endif
|
||||
|
||||
// Entries per publish. One 4096-byte pool block holds 21, so staying under
|
||||
// that keeps the non-PSRAM pool to a single block (~4.3 KB rather than
|
||||
// ~8.4 KB) and the whole publish peak near 13 KB of internal DRAM. PSRAM
|
||||
// boards are limited only by the text buffer and the neighbour cache.
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
static const int NEIGHBORS_MAX_PUBLISH_ENTRIES = MAX_NEIGHBOURS;
|
||||
#else
|
||||
static const int NEIGHBORS_MAX_PUBLISH_ENTRIES = 20;
|
||||
#endif
|
||||
|
||||
// Called by the mesh (Core 1) once a neighbor-discovery pass has built the
|
||||
// table JSON. Copies it into the persistent PSRAM buffer and raises the
|
||||
@@ -629,6 +671,9 @@ public:
|
||||
bool has_publish_counts;
|
||||
unsigned long publish_ok;
|
||||
unsigned long publish_err;
|
||||
// Raw packet-type allowlist. Kept as the mask rather than canonical text so
|
||||
// the 1 KB stats JSON stays compact; the portal renders it.
|
||||
uint16_t filter_mask;
|
||||
};
|
||||
static bool getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out);
|
||||
/** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */
|
||||
|
||||
+1719
-1334
File diff suppressed because it is too large.
Load diff
@@ -20,6 +20,7 @@
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
#include <helpers/MQTTPrefs.h>
|
||||
#include <helpers/MQTTPresets.h>
|
||||
#include <helpers/MQTTPacketFilter.h>
|
||||
#include <helpers/bridges/MQTTBridge.h>
|
||||
#endif
|
||||
|
||||
@@ -58,6 +59,89 @@ static bool isSecretKey(const char* key) {
|
||||
return key && wcIsSecretKey(key);
|
||||
}
|
||||
|
||||
// Commands whose CLI handler never returns would take the node down mid-drain,
|
||||
// before the client could read a single result. `reboot` is deferred instead:
|
||||
// it is not passed to the CLI at all, and the batch arms the ordinary reboot
|
||||
// path once the operator has read the results. The rest (clkreboot, poweroff,
|
||||
// ota update) do real work on the way down and cannot be faked, so they run
|
||||
// normally and the connection drops -- the UI warns before sending them.
|
||||
// CommonCLI dispatches on a 6-byte PREFIX (memcmp(command, "reboot", 6)), so
|
||||
// `reboot now` and `rebooted` reach Board::reboot() too. Matching exactly here
|
||||
// let those through to the CLI, which took the node down mid-drain with no
|
||||
// results and no deferral -- the precise failure the deferral exists to avoid.
|
||||
// Whatever CommonCLI would treat as a reboot, this must intercept.
|
||||
static inline bool wcIsDeferredReboot(const char* cmd) {
|
||||
return strncmp(cmd, "reboot", 6) == 0;
|
||||
}
|
||||
|
||||
// Commands the CLI reaches but the portal cannot honestly serve. Rejected at
|
||||
// POST so nothing in the sequence runs, rather than failing halfway with a
|
||||
// reply that does not explain itself. Returns the reason, or NULL if fine.
|
||||
static const char* wcCliUnavailable(const char* cmd) {
|
||||
// ESP32Board::startOTAUpdate() does `new AsyncWebServer(80)` with no bind
|
||||
// check and answers "Started" regardless. The portal already holds port 80,
|
||||
// so from here it can only leak the allocation, inhibit sleep, and lie.
|
||||
if (strncmp(cmd, "start ota", 9) == 0) {
|
||||
return "start ota needs port 80, which this portal is using. "
|
||||
"Run it from the serial console, or use `ota update`.";
|
||||
}
|
||||
// `clock sync` sets the clock from the CALLER's timestamp. Web requests carry
|
||||
// none (execCommand passes 0), so CommonCLI always rejects it as moving the
|
||||
// clock backwards. `time <epoch>` is the one that works over this transport.
|
||||
if (strncmp(cmd, "clock sync", 10) == 0) {
|
||||
return "clock sync takes its time from the caller, which a web request has "
|
||||
"no way to supply. Use `time <epoch-seconds>` instead.";
|
||||
}
|
||||
// Both write their real output to Serial and hand back a stub the terminal
|
||||
// would render as success. Bare `log` also streams a whole file from the loop
|
||||
// task, stalling the mesh and the radio while it does.
|
||||
if (strcmp(cmd, "log") == 0) {
|
||||
return "log writes the packet log to the serial console, not here, and "
|
||||
"blocks the radio while it does. Use `log start` / `log stop`.";
|
||||
}
|
||||
if (strcmp(cmd, "get acl") == 0) {
|
||||
return "get acl writes to the serial console, not here.";
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
// The `password` command echoes the new password back in its reply, and replies
|
||||
// are served to the client over the open setup AP. The config path overwrites it
|
||||
// by key; a CLI entry has no key, so match on the command itself.
|
||||
static inline bool wcCliEchoesSecret(const char* cmd) {
|
||||
return strncmp(cmd, "password ", 9) == 0;
|
||||
}
|
||||
|
||||
// CommonCLI splits its surface by CALLER, not by command: a serial caller
|
||||
// (sender_timestamp 0, physical access) reads secrets in plaintext, while a
|
||||
// remote one gets "******** (serial only)". Its own comments say so -- "Serial
|
||||
// only (WiFi creds grant LAN access); remote sees set/unset".
|
||||
//
|
||||
// execCommand passes 0, which is what makes `erase`, `stats-*` and `set freq`
|
||||
// reachable from the terminal at all. Left alone, that also claims
|
||||
// physical-access trust for an HTTP request: `get prv.key` would hand this
|
||||
// node's identity to anyone associated with the open setup AP, and `get
|
||||
// wifi.pwd` would hand over the operator's network. Reading a secret and
|
||||
// writing one are not the same capability -- the wizard has always been able to
|
||||
// REPLACE these; nothing in the portal could ever READ them, because
|
||||
// /api/config masks them (wcIsSecretKey).
|
||||
//
|
||||
// So the command surface stays whole and only the READ is masked, restoring the
|
||||
// distinction CommonCLI intended for a caller who is not at the serial port.
|
||||
// Which commands those are lives in WebConfigKeys.h (wcIsSecretReadCommand),
|
||||
// beside the rest of the secret classification and host-tested with it.
|
||||
|
||||
// Keep the set/unset signal, which is the useful part and what CommonCLI itself
|
||||
// reports remotely; only the value goes. A getter answers "> value".
|
||||
static void wcMaskSecretReply(char* reply) {
|
||||
const char* val = reply;
|
||||
if (val[0] == '>' && val[1] == ' ') val += 2;
|
||||
const bool unset = (val[0] == 0 || strcmp(val, "(not set)") == 0);
|
||||
strcpy(reply, unset ? "> (not set)" : "> ******** (serial only)");
|
||||
}
|
||||
// Reply classification lives in WebConfigBatch.h with the rest of the decisions
|
||||
// (WebConfigBatch::cliReplyIsFailure / cliReplyGatesReboot / cliWriteSucceeded),
|
||||
// so the shapes CommonCLI actually emits are enumerated in one host-tested place.
|
||||
|
||||
// Constant-time-ish comparison so login timing doesn't leak a prefix match.
|
||||
static bool fixedTimeEquals(const char* a, const char* b, size_t max_len) {
|
||||
size_t la = strnlen(a, max_len), lb = strnlen(b, max_len);
|
||||
@@ -90,13 +174,29 @@ struct WCLock {
|
||||
WebConfigServer* WebConfigServer::_active = NULL;
|
||||
AsyncWebServer* WebConfigServer::_host = NULL;
|
||||
static volatile bool webconfig_button_toggle_requested = false;
|
||||
|
||||
// Out-of-line definitions for the in-class-initialised constants. An in-class
|
||||
// initialiser is only a declaration under C++11 (what the xtensa-esp32
|
||||
// toolchain builds with), so any use that binds a reference rather than reading
|
||||
// the value -- ArduinoJson takes its argument as `const T&` -- needs the symbol to
|
||||
// exist. Comparisons like `count >= MAX_BATCH` never did, which is why this only
|
||||
// surfaced when MAX_BATCH started being reported in JSON, and then only on the
|
||||
// targets where the compiler happened not to fold it.
|
||||
const int WebConfigServer::MAX_BATCH;
|
||||
const size_t WebConfigServer::MAX_BODY;
|
||||
const uint32_t WebConfigServer::STOP_WARN_MS;
|
||||
|
||||
// Protects the permanent route host's active-session pointer and handler
|
||||
// references across the loop and async_tcp cores. The critical sections only
|
||||
// copy a pointer/update a counter; handlers themselves never run under it.
|
||||
static portMUX_TYPE s_wc_route_mux = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool owns_wifi,
|
||||
const uint8_t* pub_key, const char* fw_ver,
|
||||
const char* build_date,
|
||||
const char* role, const char* board_name)
|
||||
: _cb(callbacks), _mqtt_prefs(mqtt_prefs), _owns_wifi(owns_wifi), _pub_key(pub_key),
|
||||
_fw_ver(fw_ver), _role(role), _board_name(board_name) {
|
||||
_fw_ver(fw_ver), _build_date(build_date), _role(role), _board_name(board_name) {
|
||||
_mux = xSemaphoreCreateMutex();
|
||||
_cli_enabled = loadCliEnabled(true);
|
||||
|
||||
@@ -645,10 +745,8 @@ void WebConfigServer::finalizeTeardown() {
|
||||
_setup_wifi_handoff_pending = false;
|
||||
_setup_wifi_handoff_deadline = 0;
|
||||
_setup_wifi_handoff_ip[0] = 0;
|
||||
_cli_state = CLI_IDLE;
|
||||
_cli_reqid[0] = 0;
|
||||
_cli_command[0] = 0;
|
||||
_cli_reply[0] = 0;
|
||||
_batch_kind = BATCH_CONFIG;
|
||||
_admin_pwd_set = false;
|
||||
_session_token[0] = 0;
|
||||
_stats_json[0] = 0;
|
||||
if (_cb) _cb->onWebConfigStopped();
|
||||
@@ -763,7 +861,6 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
|
||||
if (_dns) _dns->processNextRequest();
|
||||
|
||||
if (_cli_state == CLI_PENDING) drainCliCommand();
|
||||
if (_setup_wifi_handoff_pending) serviceSetupWiFiHandoff(now);
|
||||
if (_batch_state == BATCH_PENDING && !_setup_wifi_handoff_pending) {
|
||||
drainBatch(now);
|
||||
@@ -818,26 +915,6 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
}
|
||||
}
|
||||
|
||||
void WebConfigServer::drainCliCommand() {
|
||||
char command[160];
|
||||
{
|
||||
WCLock lock(_mux);
|
||||
if (_cli_state != CLI_PENDING) return;
|
||||
strncpy(command, _cli_command, sizeof(command) - 1);
|
||||
command[sizeof(command) - 1] = 0;
|
||||
}
|
||||
|
||||
char reply[160] = "";
|
||||
_cb->execAdminCommand(command, reply);
|
||||
if (reply[0] == 0) strcpy(reply, "(no reply)");
|
||||
|
||||
WCLock lock(_mux);
|
||||
if (_cli_state != CLI_PENDING) return;
|
||||
strncpy(_cli_reply, reply, sizeof(_cli_reply) - 1);
|
||||
_cli_reply[sizeof(_cli_reply) - 1] = 0;
|
||||
_cli_state = CLI_DONE;
|
||||
}
|
||||
|
||||
void WebConfigServer::finishBatch(uint32_t now) {
|
||||
WCLock lock(_mux);
|
||||
_batch_state = BATCH_DONE;
|
||||
@@ -916,10 +993,28 @@ void WebConfigServer::drainBatch(uint32_t now) {
|
||||
BatchEntry& e = _batch[_batch_next++];
|
||||
e.reply[0] = 0;
|
||||
uint32_t t0 = millis();
|
||||
const bool admin_pwd = wcIsAdminPasswordKey(e.key);
|
||||
const char* value = admin_pwd ? e.cmd + strlen("password ")
|
||||
: e.cmd + strlen("set ") + strlen(e.key) + 1;
|
||||
if ((_mqtt_prefs == NULL || !_owns_wifi) && strcmp(e.key, "wifi.ssid") == 0) {
|
||||
if (_batch_kind == BATCH_CLI) {
|
||||
if (wcIsDeferredReboot(e.cmd)) {
|
||||
strcpy(e.reply, "OK - reboot queued");
|
||||
} else {
|
||||
_cb->execAdminCommand(e.cmd, e.reply);
|
||||
}
|
||||
if (e.reply[0] == 0) strcpy(e.reply, "(no reply)");
|
||||
const bool set_admin_pwd = wcCliEchoesSecret(e.cmd);
|
||||
if (set_admin_pwd) {
|
||||
strcpy(e.reply, "OK");
|
||||
_admin_pwd_set = true;
|
||||
}
|
||||
if (wcIsSecretReadCommand(e.cmd)) wcMaskSecretReply(e.reply);
|
||||
if (WebConfigBatch::cliReplyGatesReboot(e.cmd)) {
|
||||
_batch_all_ok = WebConfigBatch::nextAllOk(
|
||||
_batch_all_ok, WebConfigBatch::cliWriteSucceeded(e.reply));
|
||||
}
|
||||
} else {
|
||||
const bool admin_pwd = wcIsAdminPasswordKey(e.key);
|
||||
const char* value = admin_pwd ? e.cmd + strlen("password ")
|
||||
: e.cmd + strlen("set ") + strlen(e.key) + 1;
|
||||
if ((_mqtt_prefs == NULL || !_owns_wifi) && strcmp(e.key, "wifi.ssid") == 0) {
|
||||
if (!value[0] || strlen(value) >= sizeof(_wifi_ssid)) {
|
||||
strcpy(e.reply, "Error: WiFi SSID must be 1-31 characters");
|
||||
} else {
|
||||
@@ -967,13 +1062,23 @@ void WebConfigServer::drainBatch(uint32_t now) {
|
||||
} else if (strncmp(e.reply, "OK", 2) == 0 && strcmp(e.key, "wifi.powersave") == 0) {
|
||||
_wifi_power_save = strcmp(value, "max") == 0 ? 2 : strcmp(value, "min") == 0 ? 0 : 1;
|
||||
}
|
||||
}
|
||||
if (e.reply[0] == 0) strcpy(e.reply, "OK");
|
||||
_batch_all_ok = WebConfigBatch::nextAllOk(
|
||||
_batch_all_ok, strncmp(e.reply, "OK", 2) == 0);
|
||||
}
|
||||
if (e.reply[0] == 0) strcpy(e.reply, "OK");
|
||||
_batch_all_ok = WebConfigBatch::nextAllOk(
|
||||
_batch_all_ok, strncmp(e.reply, "OK", 2) == 0);
|
||||
_batch_last_cmd = millis();
|
||||
Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count,
|
||||
e.key, (unsigned long)(_batch_last_cmd - t0));
|
||||
// Config entries are named by their (non-secret) key. A CLI command is
|
||||
// deliberately not logged: the operator can see what they typed, and a
|
||||
// `set wifi.pwd` or `password` from the terminal must not reach the serial
|
||||
// log, which is a different audience from the browser session.
|
||||
if (_batch_kind == BATCH_CLI) {
|
||||
Serial.printf("WC: cli %d/%d took %lums\n", (int)_batch_next, (int)_batch_count,
|
||||
(unsigned long)(_batch_last_cmd - t0));
|
||||
} else {
|
||||
Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count,
|
||||
e.key, (unsigned long)(_batch_last_cmd - t0));
|
||||
}
|
||||
if (!WebConfigBatch::drainFinished(_batch_next, _batch_count)) {
|
||||
return; // more commands next tick
|
||||
}
|
||||
@@ -1218,6 +1323,9 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
|
||||
for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]);
|
||||
doc["node_id"] = node_id;
|
||||
doc["fw"] = _fw_ver;
|
||||
// The page shows a trimmed version -- base + build number + channel -- and
|
||||
// pairs it with this, the way `ver` does. Both come from the same defines.
|
||||
doc["build_date"] = _build_date;
|
||||
doc["role"] = _role;
|
||||
doc["board"] = _board_name;
|
||||
doc["uptime_s"] = millis() / 1000;
|
||||
@@ -1234,6 +1342,10 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
|
||||
doc["cli"] = _cli_enabled && _mode == MODE_LAN
|
||||
&& WiFi.status() == WL_CONNECTED && _cb->supportsCliTerminal();
|
||||
doc["capabilities"] = node.capabilities;
|
||||
// Commands the terminal may submit at once. The CLI shares the config
|
||||
// batch's fixed slot, so the cap is MAX_BATCH -- reported rather than
|
||||
// duplicated in the page, which cannot know how this build was sized.
|
||||
doc["max_cmds"] = MAX_BATCH;
|
||||
|
||||
AsyncResponseStream* res = req->beginResponseStream("application/json");
|
||||
serializeJson(doc, *res);
|
||||
@@ -1374,6 +1486,15 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
|
||||
s["token"] = obs->mqtt_slot_token[i][0] ? SECRET_SENTINEL : "";
|
||||
s["topic"] = (const char*)obs->mqtt_slot_topic[i];
|
||||
s["audience"] = (const char*)obs->mqtt_slot_audience[i];
|
||||
char filter_text[MQTTPacketFilter::kFilterTextSize];
|
||||
if (MQTTPacketFilter::format(obs->mqtt_slot_packet_filter[i],
|
||||
filter_text, sizeof(filter_text))) {
|
||||
// Mutable char input is copied into the ArduinoJson document; the
|
||||
// stack buffer is reused on the next slot.
|
||||
s["filter"] = filter_text;
|
||||
} else {
|
||||
s["filter"] = "all";
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1406,13 +1527,9 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
|
||||
JsonObject set = doc["set"];
|
||||
|
||||
WCLock lock(_mux);
|
||||
if (_cli_state == CLI_PENDING) {
|
||||
req->send(409, "application/json",
|
||||
"{\"error\":\"CLI command is still running\"}");
|
||||
return;
|
||||
}
|
||||
const WebConfigBatch::State bstate = toSpecState(_batch_state);
|
||||
const bool reqid_matches = strcmp(reqid, _batch_reqid) == 0;
|
||||
const bool reqid_matches = _batch_kind == BATCH_CONFIG
|
||||
&& strcmp(reqid, _batch_reqid) == 0;
|
||||
const WebConfigBatch::PostOutcome pre =
|
||||
WebConfigBatch::classifyPost(bstate, reqid_matches, 1, reboot_after);
|
||||
if (pre == WebConfigBatch::PostOutcome::Replay) {
|
||||
@@ -1476,11 +1593,32 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
|
||||
// smuggle in a second command. Admin password uses the top-level command.
|
||||
int pos = admin_pwd ? snprintf(e.cmd, sizeof(e.cmd), "password ")
|
||||
: snprintf(e.cmd, sizeof(e.cmd), "set %s ", key);
|
||||
for (const char* p = val; *p && pos < (int)sizeof(e.cmd) - 1; p++) {
|
||||
bool overflow = false;
|
||||
for (const char* p = val; *p; p++) {
|
||||
if (*p == '\r' || *p == '\n') continue;
|
||||
if (pos >= (int)sizeof(e.cmd) - 1) { overflow = true; break; }
|
||||
e.cmd[pos++] = *p;
|
||||
}
|
||||
e.cmd[pos] = 0;
|
||||
// A value that doesn't fit used to be truncated here and applied anyway.
|
||||
// For length-checked keys the CLI would reject the remainder, but a key
|
||||
// whose grammar stays valid when clipped (mqttN.filter: "advert,2" cut to
|
||||
// "advert") would silently persist a *different* setting and still answer
|
||||
// OK. Refuse the batch instead -- the caller can shorten and retry.
|
||||
if (overflow) {
|
||||
// Name the key, like the "bad key" rejection above: a 20-field batch is
|
||||
// rejected whole, so without it the operator has nothing to correct.
|
||||
char safe_key[33];
|
||||
strncpy(safe_key, key, sizeof(safe_key) - 1);
|
||||
safe_key[sizeof(safe_key) - 1] = 0;
|
||||
StaticJsonDocument<128> ed;
|
||||
ed["error"] = "value too long";
|
||||
ed["key"] = safe_key;
|
||||
String out;
|
||||
serializeJson(ed, out);
|
||||
req->send(400, "application/json", out);
|
||||
return;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
if (WebConfigBatch::classifyPost(bstate, reqid_matches, count, reboot_after) ==
|
||||
@@ -1488,6 +1626,7 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
|
||||
req->send(400, "application/json", "{\"error\":\"no changes\"}");
|
||||
return;
|
||||
}
|
||||
_batch_kind = BATCH_CONFIG;
|
||||
_batch_count = count;
|
||||
_batch_next = 0;
|
||||
_batch_reboot = reboot_after;
|
||||
@@ -1531,9 +1670,13 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
// fires but no branch print follows, the handler is blocked on _mux.
|
||||
Serial.printf("WC: result entry mode=%d state=%d\n", (int)_mode, (int)_batch_state);
|
||||
WCLock lock(_mux);
|
||||
const WebConfigBatch::ResultOutcome outcome = WebConfigBatch::classifyResult(
|
||||
toSpecState(_batch_state),
|
||||
strcmp(requested_reqid.c_str(), _batch_reqid) == 0);
|
||||
// A CLI sequence occupying the shared slot is not a config save, whatever the
|
||||
// reqid says: its entries have no `key` and its results belong to the
|
||||
// terminal's reader. Treat it as unknown here (and vice versa there).
|
||||
const bool mine = (_batch_kind == BATCH_CONFIG) &&
|
||||
(strcmp(requested_reqid.c_str(), _batch_reqid) == 0);
|
||||
const WebConfigBatch::ResultOutcome outcome =
|
||||
WebConfigBatch::classifyResult(toSpecState(_batch_state), mine);
|
||||
if (outcome == WebConfigBatch::ResultOutcome::Idle) {
|
||||
Serial.println("WC: result read -> idle");
|
||||
StaticJsonDocument<64> idle;
|
||||
@@ -1605,6 +1748,22 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
req->send(res);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI terminal (/api/cli). Same 202 + reqid + poll contract as a config save,
|
||||
// and for the same reason: CommonCLI touches prefs, the radio and the
|
||||
// filesystem, none of which may be reached from the async_tcp task. The
|
||||
// commands go into the shared deferred slot and tick() drains them.
|
||||
//
|
||||
// Unlike a save this is not allowlisted. It is restricted to authenticated LAN
|
||||
// mode and uses the remote-admin callback, so serial-only secrets stay hidden.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Commands whose CLI handler never returns would take the node down mid-drain,
|
||||
// before the client could read a single result. `reboot` is deferred instead:
|
||||
// it is not passed to the CLI at all, and the batch arms the ordinary reboot
|
||||
// path once the operator has read the results. The rest (clkreboot, poweroff,
|
||||
// ota update) do real work on the way down and cannot be faked, so they run
|
||||
// normally and the connection drops -- the UI warns before sending them.
|
||||
void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
|
||||
if (_mode != MODE_LAN) {
|
||||
req->send(403, "application/json",
|
||||
@@ -1621,69 +1780,137 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
|
||||
"{\"error\":\"terminal disabled; set wifi.cli on\"}");
|
||||
return;
|
||||
}
|
||||
if (!checkAuth(req)) {
|
||||
req->send(401, "application/json", "{\"error\":\"auth\"}");
|
||||
return;
|
||||
}
|
||||
if (req->contentLength() > 512) {
|
||||
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
|
||||
if (req->contentLength() > MAX_BODY || req->_tempObject == NULL) {
|
||||
req->send(413, "application/json", "{\"error\":\"body too large\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
const char* body = (const char*)req->_tempObject;
|
||||
DynamicJsonDocument doc(512);
|
||||
DynamicJsonDocument doc(6144);
|
||||
if (!body || deserializeJson(doc, body) != DeserializationError::Ok) {
|
||||
req->send(400, "application/json", "{\"error\":\"bad json\"}");
|
||||
return;
|
||||
}
|
||||
const char* reqid = doc["reqid"] | "";
|
||||
const char* command = doc["command"] | "";
|
||||
if (!wcIsValidReqId(reqid)) {
|
||||
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
|
||||
return;
|
||||
}
|
||||
if (!wcIsValidCliCommand(command)) {
|
||||
req->send(400, "application/json",
|
||||
"{\"error\":\"command must be one nonblank line up to 159 bytes\"}");
|
||||
JsonArray cmds = doc["cmds"];
|
||||
if (cmds.isNull()) {
|
||||
req->send(400, "application/json", "{\"error\":\"no commands\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
WCLock lock(_mux);
|
||||
const bool same_request = strcmp(reqid, _cli_reqid) == 0;
|
||||
if (_cli_state == CLI_PENDING || same_request) {
|
||||
if (!same_request) {
|
||||
StaticJsonDocument<96> busy;
|
||||
busy["error"] = "busy";
|
||||
busy["reqid"] = (const char*)_cli_reqid;
|
||||
String out;
|
||||
serializeJson(busy, out);
|
||||
req->send(409, "application/json", out);
|
||||
return;
|
||||
}
|
||||
StaticJsonDocument<96> replay;
|
||||
replay["state"] = _cli_state == CLI_DONE ? "done" : "pending";
|
||||
replay["reqid"] = (const char*)_cli_reqid;
|
||||
// Replay/Busy exactly as a config save classifies them: a repeated POST is
|
||||
// acknowledged rather than executed twice, and a different sequence while one
|
||||
// is still draining is refused.
|
||||
const WebConfigBatch::State bstate = toSpecState(_batch_state);
|
||||
const bool reqid_matches = _batch_kind == BATCH_CLI
|
||||
&& strcmp(reqid, _batch_reqid) == 0;
|
||||
const WebConfigBatch::PostOutcome pre =
|
||||
WebConfigBatch::classifyPost(bstate, reqid_matches, 1 /* count unknown yet */, false);
|
||||
if (pre == WebConfigBatch::PostOutcome::Replay) {
|
||||
StaticJsonDocument<96> ack;
|
||||
ack["state"] = (bstate == WebConfigBatch::State::Done) ? "done" : "running";
|
||||
ack["total"] = _batch_count;
|
||||
ack["reqid"] = (const char*)_batch_reqid;
|
||||
String out;
|
||||
serializeJson(replay, out);
|
||||
serializeJson(ack, out);
|
||||
req->send(202, "application/json", out);
|
||||
return;
|
||||
}
|
||||
if (_batch_state == BATCH_PENDING) {
|
||||
req->send(409, "application/json",
|
||||
"{\"error\":\"configuration change is still running\"}");
|
||||
if (pre == WebConfigBatch::PostOutcome::Busy) {
|
||||
StaticJsonDocument<96> bd;
|
||||
bd["error"] = "busy";
|
||||
bd["reqid"] = (const char*)_batch_reqid;
|
||||
String out;
|
||||
serializeJson(bd, out);
|
||||
req->send(409, "application/json", out);
|
||||
return;
|
||||
}
|
||||
|
||||
strncpy(_cli_reqid, reqid, sizeof(_cli_reqid) - 1);
|
||||
_cli_reqid[sizeof(_cli_reqid) - 1] = 0;
|
||||
strncpy(_cli_command, command, sizeof(_cli_command) - 1);
|
||||
_cli_command[sizeof(_cli_command) - 1] = 0;
|
||||
_cli_reply[0] = 0;
|
||||
_cli_state = CLI_PENDING;
|
||||
int count = 0;
|
||||
bool defer_reboot = false, seq_sets_pwd = false, seq_sets_ssid = false;
|
||||
for (JsonVariant v : cmds) {
|
||||
const char* raw = v.as<const char*>();
|
||||
if (!raw) continue;
|
||||
if (count >= MAX_BATCH) {
|
||||
StaticJsonDocument<96> ed;
|
||||
ed["error"] = "too many commands";
|
||||
ed["max"] = MAX_BATCH;
|
||||
String out;
|
||||
serializeJson(ed, out);
|
||||
req->send(413, "application/json", out);
|
||||
return;
|
||||
}
|
||||
// Strip CR/LF so one entry cannot smuggle a second command past the
|
||||
// operator's confirmation, and skip whatever is left blank.
|
||||
BatchEntry& e = _batch[count];
|
||||
int pos = 0;
|
||||
for (const char* p = raw; *p; p++) {
|
||||
if (*p == '\r' || *p == '\n') continue;
|
||||
if (pos == 0 && (*p == ' ' || *p == '\t')) continue; // leading space
|
||||
if (pos >= (int)sizeof(e.cmd) - 1) {
|
||||
req->send(400, "application/json", "{\"error\":\"command too long\"}");
|
||||
return;
|
||||
}
|
||||
e.cmd[pos++] = *p;
|
||||
}
|
||||
while (pos > 0 && (e.cmd[pos - 1] == ' ' || e.cmd[pos - 1] == '\t')) pos--;
|
||||
e.cmd[pos] = 0;
|
||||
if (pos == 0) continue;
|
||||
// Reject before anything runs, so a sequence never half-applies and then
|
||||
// stops on a command that was never going to work here.
|
||||
const char* why = wcCliUnavailable(e.cmd);
|
||||
if (why) {
|
||||
StaticJsonDocument<256> ed;
|
||||
ed["error"] = why;
|
||||
String out;
|
||||
serializeJson(ed, out);
|
||||
req->send(400, "application/json", out);
|
||||
return;
|
||||
}
|
||||
e.key[0] = 0; // CLI entries have no config key
|
||||
if (wcIsDeferredReboot(e.cmd)) defer_reboot = true;
|
||||
if (strncmp(e.cmd, "password ", 9) == 0) seq_sets_pwd = true;
|
||||
if (strncmp(e.cmd, "set wifi.ssid ", 14) == 0) seq_sets_ssid = true;
|
||||
count++;
|
||||
}
|
||||
if (count == 0) {
|
||||
req->send(400, "application/json", "{\"error\":\"no commands\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// The same invariant handleConfigPost enforces, and for the same reason: the
|
||||
// reboot is what commits first onboarding, and a node that reboots onto the
|
||||
// LAN still holding the factory password is a known credential on someone
|
||||
// else's network. The terminal warned about this client-side, which is a
|
||||
// reminder, not a rule -- a pasted script or a direct POST ignored it.
|
||||
if (_mode == MODE_SETUP && _initial_setup && !seq_sets_pwd && !_admin_pwd_set &&
|
||||
(defer_reboot || seq_sets_ssid)) {
|
||||
req->send(400, "application/json",
|
||||
"{\"error\":\"admin password required for initial setup -- "
|
||||
"run `password <new-password>` first\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
_batch_kind = BATCH_CLI;
|
||||
_batch_count = count;
|
||||
_batch_next = 0;
|
||||
_batch_reboot = defer_reboot;
|
||||
_batch_reboot_armed = false;
|
||||
_batch_all_ok = true;
|
||||
strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1);
|
||||
_batch_reqid[sizeof(_batch_reqid) - 1] = 0;
|
||||
_batch_state = BATCH_PENDING; // tick() picks it up on the loop task
|
||||
Serial.printf("WC: cli POST accepted, %d cmds, reboot=%d\n", count, (int)defer_reboot);
|
||||
|
||||
StaticJsonDocument<96> ack;
|
||||
ack["state"] = "pending";
|
||||
ack["reqid"] = (const char*)_cli_reqid;
|
||||
ack["state"] = "running";
|
||||
ack["total"] = count;
|
||||
ack["reqid"] = (const char*)_batch_reqid;
|
||||
String out;
|
||||
serializeJson(ack, out);
|
||||
req->send(202, "application/json", out);
|
||||
@@ -1700,10 +1927,12 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
|
||||
"{\"error\":\"terminal unavailable on this role\"}");
|
||||
return;
|
||||
}
|
||||
if (!checkAuth(req)) {
|
||||
req->send(401, "application/json", "{\"error\":\"auth\"}");
|
||||
if (!_cli_enabled) {
|
||||
req->send(403, "application/json",
|
||||
"{\"error\":\"terminal disabled; set wifi.cli on\"}");
|
||||
return;
|
||||
}
|
||||
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
|
||||
if (!req->hasParam("reqid")) {
|
||||
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
|
||||
return;
|
||||
@@ -1713,9 +1942,21 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
|
||||
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
|
||||
return;
|
||||
}
|
||||
int from = 0;
|
||||
if (req->hasParam("from")) {
|
||||
from = req->getParam("from")->value().toInt();
|
||||
if (from < 0) from = 0;
|
||||
}
|
||||
|
||||
WCLock lock(_mux);
|
||||
if (_cli_state == CLI_IDLE) {
|
||||
// A config save occupying the slot is not this client's sequence, whatever
|
||||
// the reqid says; treat it as unknown rather than serving `set` results
|
||||
// through the terminal's reader.
|
||||
const bool mine = (_batch_kind == BATCH_CLI) &&
|
||||
(strcmp(requested_reqid.c_str(), _batch_reqid) == 0);
|
||||
const WebConfigBatch::ResultOutcome outcome =
|
||||
WebConfigBatch::classifyResult(toSpecState(_batch_state), mine);
|
||||
if (outcome == WebConfigBatch::ResultOutcome::Idle) {
|
||||
StaticJsonDocument<64> idle;
|
||||
idle["state"] = "idle";
|
||||
idle["reqid"] = requested_reqid;
|
||||
@@ -1724,15 +1965,42 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
|
||||
req->send(200, "application/json", out);
|
||||
return;
|
||||
}
|
||||
if (strcmp(requested_reqid.c_str(), _cli_reqid) != 0) {
|
||||
if (outcome == WebConfigBatch::ResultOutcome::Unknown) {
|
||||
req->send(404, "application/json", "{\"error\":\"unknown request\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
DynamicJsonDocument doc(512);
|
||||
doc["state"] = _cli_state == CLI_DONE ? "done" : "pending";
|
||||
doc["reqid"] = (const char*)_cli_reqid;
|
||||
if (_cli_state == CLI_DONE) doc["reply"] = (const char*)_cli_reply;
|
||||
// Results stream: hand back whatever has drained since the client's cursor,
|
||||
// capped so the document stays small on the async_tcp task.
|
||||
const int produced = _batch_next;
|
||||
const int page = WebConfigBatch::cliPageCount(from, produced, WebConfigBatch::kCliResultPage);
|
||||
const bool final_read = WebConfigBatch::cliReadIsFinal(toSpecState(_batch_state),
|
||||
from, page, _batch_count);
|
||||
DynamicJsonDocument doc(4096);
|
||||
doc["state"] = final_read ? "done" : "running";
|
||||
doc["reqid"] = (const char*)_batch_reqid;
|
||||
doc["total"] = _batch_count;
|
||||
doc["from"] = from;
|
||||
JsonArray results = doc.createNestedArray("results");
|
||||
for (int i = from; i < from + page; i++) {
|
||||
JsonObject r = results.createNestedObject();
|
||||
// The command is deliberately NOT echoed: it may hold a password or token,
|
||||
// and the client already has the sequence it sent. It matches by index.
|
||||
r["ok"] = !WebConfigBatch::cliReplyIsFailure(_batch[i].reply);
|
||||
r["reply"] = (const char*)_batch[i].reply;
|
||||
}
|
||||
if (final_read) {
|
||||
doc["all_ok"] = _batch_all_ok;
|
||||
const bool rebooting = WebConfigBatch::cliRebootAllowed(_batch_reboot, _batch_all_ok);
|
||||
doc["reboot"] = rebooting;
|
||||
// Tell the operator why a `reboot` they asked for is not happening.
|
||||
if (_batch_reboot && !_batch_all_ok) doc["reboot_withheld"] = true;
|
||||
if (WebConfigBatch::shouldArmConfirmReboot(toSpecState(_batch_state), _batch_reboot,
|
||||
_batch_all_ok, _batch_reboot_armed)) {
|
||||
_batch_reboot_armed = true;
|
||||
_reboot_at = WebConfigBatch::confirmRebootAt(millis());
|
||||
}
|
||||
}
|
||||
AsyncResponseStream* res = req->beginResponseStream("application/json");
|
||||
serializeJson(doc, *res);
|
||||
req->send(res);
|
||||
|
||||
@@ -117,6 +117,7 @@ public:
|
||||
// repeater/room-server builds), false when the companion/MQTT runtime owns it.
|
||||
WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool owns_wifi,
|
||||
const uint8_t* pub_key, const char* fw_ver,
|
||||
const char* build_date,
|
||||
const char* role, const char* board_name);
|
||||
~WebConfigServer();
|
||||
|
||||
@@ -179,18 +180,26 @@ private:
|
||||
static const size_t MAX_BODY = 4096;
|
||||
static const uint32_t STOP_WARN_MS = WebConfigBatch::kStopWarnMs;
|
||||
enum BatchState : uint8_t { BATCH_IDLE = 0, BATCH_PENDING, BATCH_DONE };
|
||||
enum CliState : uint8_t { CLI_IDLE = 0, CLI_PENDING, CLI_DONE };
|
||||
static WebConfigBatch::State toSpecState(BatchState state) {
|
||||
switch (state) {
|
||||
// What filled the shared slot. A config save comes from allowlisted form
|
||||
// fields; a CLI sequence is arbitrary commands typed into the terminal. They
|
||||
// share the slot (see WebConfigBatch.h) but differ in how results are read
|
||||
// and in whether `key` means anything, so every reader checks the kind.
|
||||
enum BatchKind : uint8_t { BATCH_CONFIG = 0, BATCH_CLI };
|
||||
|
||||
// BatchState and WebConfigBatch::State are deliberately kept as separate
|
||||
// types (the enum is stored in a volatile member and used in prints); this
|
||||
// is the one conversion point.
|
||||
static WebConfigBatch::State toSpecState(BatchState s) {
|
||||
switch (s) {
|
||||
case BATCH_PENDING: return WebConfigBatch::State::Pending;
|
||||
case BATCH_DONE: return WebConfigBatch::State::Done;
|
||||
default: return WebConfigBatch::State::Idle;
|
||||
}
|
||||
}
|
||||
struct BatchEntry {
|
||||
char key[24]; // allowlisted config key (echoed back to the UI)
|
||||
char key[24]; // allowlisted config key (echoed back to the UI); empty for CLI entries
|
||||
char cmd[160]; // full CLI command (may contain secrets - never echoed)
|
||||
char reply[160];
|
||||
char reply[160]; // CLI reply budget, same 160 bytes the serial console gets
|
||||
};
|
||||
|
||||
Callbacks* _cb;
|
||||
@@ -198,6 +207,7 @@ private:
|
||||
bool _owns_wifi;
|
||||
const uint8_t* _pub_key;
|
||||
const char* _fw_ver;
|
||||
const char* _build_date;
|
||||
const char* _role;
|
||||
const char* _board_name;
|
||||
|
||||
@@ -213,6 +223,10 @@ private:
|
||||
char _wifi_password[64] = {0};
|
||||
uint8_t _wifi_power_save = 1;
|
||||
bool _cli_enabled = true;
|
||||
// A `password` command has succeeded this session. Lets the CLI satisfy the
|
||||
// initial-setup invariant across separate submissions; the form batch always
|
||||
// sends the password with the rest, so it never needed the memory.
|
||||
bool _admin_pwd_set = false;
|
||||
char _ap_ssid[33] = {0};
|
||||
WiFiReconnectPolicy::Tracker _wifi_reconnect_tracker;
|
||||
bool _retry_saved_wifi_in_setup = false;
|
||||
@@ -227,6 +241,7 @@ private:
|
||||
|
||||
// Command batch: filled by async_tcp under _mux, drained by tick().
|
||||
volatile BatchState _batch_state = BATCH_IDLE;
|
||||
volatile BatchKind _batch_kind = BATCH_CONFIG;
|
||||
uint8_t _batch_count = 0;
|
||||
uint8_t _batch_next = 0; // drain progress (one command per tick)
|
||||
uint32_t _batch_last_cmd = 0;
|
||||
@@ -240,13 +255,6 @@ private:
|
||||
char _setup_wifi_handoff_ip[16] = {0};
|
||||
BatchEntry _batch[MAX_BATCH];
|
||||
|
||||
// Browser terminal command: filled by async_tcp under _mux and executed by
|
||||
// tick() on the loop task. Separate from the allowlisted config batch.
|
||||
volatile CliState _cli_state = CLI_IDLE;
|
||||
char _cli_reqid[24] = {0};
|
||||
char _cli_command[160] = {0};
|
||||
char _cli_reply[160] = {0};
|
||||
|
||||
// LAN-mode session (single slot; new login evicts the old session)
|
||||
char _session_token[33] = {0};
|
||||
uint32_t _session_last_seen = 0;
|
||||
@@ -282,7 +290,6 @@ private:
|
||||
void drainBatch(uint32_t now);
|
||||
void serviceSetupWiFiHandoff(uint32_t now);
|
||||
void finishBatch(uint32_t now);
|
||||
void drainCliCommand();
|
||||
void finalizeTeardown();
|
||||
bool checkAuth(AsyncWebServerRequest* req);
|
||||
static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len,
|
||||
|
||||
@@ -29,6 +29,7 @@ bool SH1106Display::begin()
|
||||
// preferred alternate explicitly; otherwise try the other address in the
|
||||
// pair. Always run the Adafruit init so its buffer and device objects exist,
|
||||
// even when no panel answers the probe.
|
||||
_initialized = false;
|
||||
uint8_t addr = 0;
|
||||
if (i2c_probe(Wire, DISPLAY_ADDRESS)) {
|
||||
addr = DISPLAY_ADDRESS;
|
||||
@@ -43,17 +44,23 @@ bool SH1106Display::begin()
|
||||
addr = DISPLAY_ADDRESS ^ 1;
|
||||
}
|
||||
bool ok = display.begin(addr ? addr : DISPLAY_ADDRESS, true);
|
||||
return addr != 0 && ok;
|
||||
_initialized = addr != 0 && ok;
|
||||
return _initialized;
|
||||
}
|
||||
|
||||
void SH1106Display::turnOn()
|
||||
{
|
||||
if (!_initialized) return;
|
||||
display.oled_command(SH110X_DISPLAYON);
|
||||
_isOn = true;
|
||||
}
|
||||
|
||||
void SH1106Display::turnOff()
|
||||
{
|
||||
if (!_initialized) {
|
||||
_isOn = false;
|
||||
return;
|
||||
}
|
||||
display.oled_command(SH110X_DISPLAYOFF);
|
||||
_isOn = false;
|
||||
}
|
||||
|
||||
@@ -17,16 +17,20 @@
|
||||
class SH1106Display : public DisplayDriver
|
||||
{
|
||||
Adafruit_SH1106G display;
|
||||
bool _initialized;
|
||||
bool _isOn;
|
||||
uint8_t _color;
|
||||
|
||||
bool i2c_probe(TwoWire &wire, uint8_t addr);
|
||||
|
||||
public:
|
||||
SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { _isOn = false; }
|
||||
SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) {
|
||||
_initialized = false;
|
||||
_isOn = false;
|
||||
}
|
||||
bool begin();
|
||||
|
||||
bool isOn() override { return _isOn; }
|
||||
bool isOn() override { return _initialized && _isOn; }
|
||||
void turnOn() override;
|
||||
void turnOff() override;
|
||||
void clear() override;
|
||||
|
||||
+3
-2
@@ -28,14 +28,15 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, admin-password and browser-terminal command validation, slot-index bounds, and short-input guards |
|
||||
| `test_webconfig_batch` | `src/helpers/WebConfigBatch.h` | config POST/replay/busy decisions; paced command drain; setup WiFi validation/IP handoff; reboot confirmation/fallback; stop/refcount gating; exact timing and rollover boundaries |
|
||||
| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz |
|
||||
| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank packets-only behavior; required identifiers; invalid inputs/slots; exact buffer boundaries |
|
||||
| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank all types except raw; required identifiers; invalid inputs/slots; exact buffer boundaries |
|
||||
| `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover |
|
||||
| `test_wifi_reconnect_policy` | `src/helpers/WiFiReconnectPolicy.h` | five-minute forced reconnect cadence, connection resets, duplicate disconnect observations, and 32-bit `millis()` rollover |
|
||||
| `test_radio_liveness` | `src/helpers/RadioLivenessTracker.h`, `src/helpers/radiolib/RxBoostedGainDefaults.h` | staged radio recovery, activity reset and rollover handling; target-specific RX boosted-gain defaults and SX126x precedence |
|
||||
| `test_lr1110_rx_recovery` | `src/helpers/radiolib/LR1110RxRecovery.h` | LR1110 four-byte RX-buffer shift signature; captured and accumulated shifts; ordinary/scoped packet exclusions |
|
||||
| `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover |
|
||||
| `test_mqtt_packet_filter` | `src/helpers/MQTTPacketFilter.h` | per-slot 0-15 allowlist parsing/formatting, numeric and named spellings; exact bounds; membership; candidate/eligible split and retry-completion policy; pre-queue union gate; default-mask detection |
|
||||
| `test_mqtt_runtime_buffer_lifecycle` | `src/helpers/MQTTRuntimeBufferLifecycle.h` | idempotent allocation/release; partial-allocation degradation; retry of only missing buffers |
|
||||
| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation |
|
||||
| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation; shortest-payload write policy (default filters stay downgrade-readable) |
|
||||
| `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation |
|
||||
| `test_prefs_save_routing` | `src/helpers/PrefsSaveRouting.h` | runtime common/observer setters write only their owning preference image; mixed-owner setters and migrations can deliberately write both |
|
||||
| `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads |
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
class TwoWire { };
|
||||
@@ -0,0 +1,303 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "helpers/MQTTPacketFilter.h"
|
||||
|
||||
namespace Filter = MQTTPacketFilter;
|
||||
|
||||
TEST(MQTTPacketFilter, EmptyAndAllMeanEveryType) {
|
||||
for (const char* value : {"", " ", "\tall\r\n"}) {
|
||||
uint16_t mask = 0;
|
||||
ASSERT_TRUE(Filter::parse(value, &mask)) << value;
|
||||
EXPECT_EQ(Filter::kAllPacketTypes, mask);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, NoneMeansNoTypes) {
|
||||
uint16_t mask = Filter::kAllPacketTypes;
|
||||
ASSERT_TRUE(Filter::parse(" none ", &mask));
|
||||
EXPECT_EQ(0u, mask);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, ParsesDecimalCsvWithWhitespaceAndDuplicates) {
|
||||
uint16_t mask = 0;
|
||||
ASSERT_TRUE(Filter::parse(" 2, 4,\t15,2 ", &mask));
|
||||
EXPECT_EQ(static_cast<uint16_t>((1u << 2) | (1u << 4) | (1u << 15)), mask);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, FullNumericListCanonicalizesToAll) {
|
||||
uint16_t mask = 0;
|
||||
ASSERT_TRUE(Filter::parse("0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15", &mask));
|
||||
EXPECT_EQ(Filter::kAllPacketTypes, mask);
|
||||
char output[Filter::kFilterTextSize];
|
||||
ASSERT_TRUE(Filter::format(mask, output, sizeof(output)));
|
||||
EXPECT_STREQ("all", output);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, ParsesPayloadTypeNames) {
|
||||
uint16_t mask = 0;
|
||||
ASSERT_TRUE(Filter::parse("advert", &mask));
|
||||
EXPECT_EQ(static_cast<uint16_t>(1u << 4), mask);
|
||||
|
||||
ASSERT_TRUE(Filter::parse(" txt_msg , grp_txt ", &mask));
|
||||
EXPECT_EQ(static_cast<uint16_t>((1u << 2) | (1u << 5)), mask);
|
||||
|
||||
// Names and numbers are interchangeable within one list.
|
||||
ASSERT_TRUE(Filter::parse("advert,2,raw_custom", &mask));
|
||||
EXPECT_EQ(static_cast<uint16_t>((1u << 4) | (1u << 2) | (1u << 15)), mask);
|
||||
|
||||
// Output stays numeric regardless of how the value was spelled.
|
||||
char output[Filter::kFilterTextSize];
|
||||
ASSERT_TRUE(Filter::format(mask, output, sizeof(output)));
|
||||
EXPECT_STREQ("2,4,15", output);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, EveryNamedTypeResolvesToItsPacketHeaderValue) {
|
||||
// Pins the table against src/Packet.h's PAYLOAD_TYPE_* values.
|
||||
const struct { const char* name; uint8_t type; } expected[] = {
|
||||
{"req", 0}, {"response", 1}, {"txt_msg", 2}, {"ack", 3}, {"advert", 4},
|
||||
{"grp_txt", 5}, {"grp_data", 6}, {"anon_req", 7}, {"path", 8},
|
||||
{"trace", 9}, {"multipart", 10}, {"control", 11}, {"raw_custom", 15},
|
||||
};
|
||||
for (const auto& e : expected) {
|
||||
uint16_t mask = 0;
|
||||
ASSERT_TRUE(Filter::parse(e.name, &mask)) << e.name;
|
||||
EXPECT_EQ(static_cast<uint16_t>(1u << e.type), mask) << e.name;
|
||||
}
|
||||
// Reserved types have no name and stay reachable by number only.
|
||||
size_t count = 0;
|
||||
const Filter::NamedPacketType* names = Filter::namedPacketTypes(&count);
|
||||
EXPECT_EQ(sizeof(expected) / sizeof(expected[0]), count);
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
EXPECT_TRUE(names[i].type < 12 || names[i].type == 15) << names[i].name;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, RejectsMalformedOrOutOfRangeValuesWithoutChangingOutput) {
|
||||
const char* invalid[] = {
|
||||
",", "1,", ",1", "1,,2", "1, ,2", "-1", "+1", "0x2", "16",
|
||||
"999999999999999999999", "ALL", "All", "all,2", "none,2", "2-4", "2x",
|
||||
// Names are lowercase, exact, and are not whole-value keywords.
|
||||
"ADVERT", "Advert", "advert_", "adver", "advertx", "reserved", "12_reserved",
|
||||
"2,all", "advert,none", "advert advert", "advert;2",
|
||||
};
|
||||
for (const char* value : invalid) {
|
||||
uint16_t mask = 0x1234;
|
||||
EXPECT_FALSE(Filter::parse(value, &mask)) << value;
|
||||
EXPECT_EQ(0x1234, mask) << value;
|
||||
}
|
||||
EXPECT_FALSE(Filter::parse(nullptr, nullptr));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, FormatsSubsetsInAscendingOrder) {
|
||||
const uint16_t mask = static_cast<uint16_t>(
|
||||
(1u << 15) | (1u << 2) | (1u << 10) | (1u << 0));
|
||||
char output[Filter::kFilterTextSize];
|
||||
ASSERT_TRUE(Filter::format(mask, output, sizeof(output)));
|
||||
EXPECT_STREQ("0,2,10,15", output);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, FormatsNoneAndHonorsExactBufferBoundaries) {
|
||||
char none[5];
|
||||
ASSERT_TRUE(Filter::format(0, none, sizeof(none)));
|
||||
EXPECT_STREQ("none", none);
|
||||
|
||||
char too_small[4] = {'x', 'x', 'x', '\0'};
|
||||
EXPECT_FALSE(Filter::format(0, too_small, sizeof(too_small)));
|
||||
EXPECT_STREQ("", too_small);
|
||||
|
||||
char all_types[Filter::kFilterTextSize];
|
||||
const uint16_t subset = static_cast<uint16_t>(Filter::kAllPacketTypes & ~(1u << 14));
|
||||
ASSERT_TRUE(Filter::format(subset, all_types, sizeof(all_types)));
|
||||
EXPECT_STREQ("0,1,2,3,4,5,6,7,8,9,10,11,12,13,15", all_types);
|
||||
char one_short[34];
|
||||
EXPECT_FALSE(Filter::format(subset, one_short, sizeof(one_short)));
|
||||
EXPECT_STREQ("", one_short);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, MembershipIsBoundedToFourBitTypes) {
|
||||
EXPECT_TRUE(Filter::allows(static_cast<uint16_t>(1u << 0), 0));
|
||||
EXPECT_TRUE(Filter::allows(static_cast<uint16_t>(1u << 15), 15));
|
||||
EXPECT_FALSE(Filter::allows(Filter::kAllPacketTypes, 16));
|
||||
EXPECT_FALSE(Filter::allows(0, 0));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, EligibilityIgnoresConnectionButRequiresTopicAndFilter) {
|
||||
const uint16_t only_advert = static_cast<uint16_t>(1u << 4);
|
||||
EXPECT_TRUE(Filter::slotEligible(true, true, only_advert, 4));
|
||||
EXPECT_FALSE(Filter::slotEligible(false, true, only_advert, 4));
|
||||
EXPECT_FALSE(Filter::slotEligible(true, false, only_advert, 4));
|
||||
EXPECT_FALSE(Filter::slotEligible(true, true, only_advert, 2));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, AllFilteredOrTopicIncompatibleTargetsCompleteIntentionally) {
|
||||
const uint16_t only_text = static_cast<uint16_t>(1u << 2);
|
||||
const bool filtered = Filter::slotEligible(true, true, only_text, 4);
|
||||
const bool topic_incompatible =
|
||||
Filter::slotEligible(true, false, Filter::kAllPacketTypes, 4);
|
||||
EXPECT_FALSE(filtered || topic_incompatible);
|
||||
EXPECT_TRUE(Filter::publishComplete(filtered || topic_incompatible, false));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, EligibleDisconnectedTargetStillRequiresRetry) {
|
||||
const uint16_t only_advert = static_cast<uint16_t>(1u << 4);
|
||||
// Connection state is intentionally absent from slotEligible(): the first
|
||||
// slot remains a target while disconnected. A second connected slot whose
|
||||
// filter rejects the advert cannot turn that into intentional completion.
|
||||
const bool disconnected_eligible =
|
||||
Filter::slotEligible(true, true, only_advert, 4);
|
||||
const bool connected_filtered =
|
||||
Filter::slotEligible(true, true, static_cast<uint16_t>(1u << 2), 4);
|
||||
ASSERT_TRUE(disconnected_eligible);
|
||||
ASSERT_FALSE(connected_filtered);
|
||||
EXPECT_FALSE(Filter::publishComplete(
|
||||
disconnected_eligible || connected_filtered, false));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, PublishFailureRetriesAndAnySuccessCompletes) {
|
||||
EXPECT_FALSE(Filter::publishComplete(true, false));
|
||||
EXPECT_TRUE(Filter::publishComplete(true, true));
|
||||
EXPECT_TRUE(Filter::publishComplete(false, true)); // defensive: success wins
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, UnsupportedRawPathCannotHideEligiblePacketFailure) {
|
||||
const bool packet_eligible = true;
|
||||
const bool packet_published = false;
|
||||
const bool raw_eligible = false; // e.g. MeshRank has no raw topic
|
||||
const bool raw_published = false;
|
||||
|
||||
EXPECT_FALSE(Filter::publishComplete(
|
||||
packet_eligible || raw_eligible,
|
||||
packet_published || raw_published));
|
||||
EXPECT_TRUE(Filter::publishComplete(false, false));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, PacketAndRawCanShareTheSameMaskDecision) {
|
||||
const uint16_t text_and_advert = static_cast<uint16_t>((1u << 2) | (1u << 4));
|
||||
for (uint8_t type = 0; type <= 15; ++type) {
|
||||
const bool expected = type == 2 || type == 4;
|
||||
EXPECT_EQ(expected, Filter::allows(text_and_advert, type)) << unsigned(type);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, CandidateIsTheTopicFreePartOfEligibility) {
|
||||
const uint16_t only_advert = static_cast<uint16_t>(1u << 4);
|
||||
EXPECT_TRUE(Filter::slotCandidate(true, only_advert, 4));
|
||||
EXPECT_FALSE(Filter::slotCandidate(false, only_advert, 4));
|
||||
EXPECT_FALSE(Filter::slotCandidate(true, only_advert, 2));
|
||||
// A candidate is exactly an eligible slot minus the topic-support term. The
|
||||
// bridge evaluates the cheap half first and only builds a topic for slots
|
||||
// that pass it, so the two must stay in this exact relationship.
|
||||
for (int enabled = 0; enabled <= 1; ++enabled) {
|
||||
for (int topic = 0; topic <= 1; ++topic) {
|
||||
for (uint8_t type = 0; type <= 15; ++type) {
|
||||
EXPECT_EQ(Filter::slotCandidate(enabled != 0, only_advert, type) && topic != 0,
|
||||
Filter::slotEligible(enabled != 0, topic != 0, only_advert, type));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The gate has to resolve topic support before anything expensive runs: a
|
||||
// connected slot that cannot form a topic (MeshRank raw, or a meshcore preset
|
||||
// with no IATA) must reach "intentionally complete" without a serialisation.
|
||||
TEST(MQTTPacketFilter, TopicIncompatibleTargetsAreCompleteWithoutPublishing) {
|
||||
const uint16_t all = Filter::kAllPacketTypes;
|
||||
for (uint8_t type = 0; type <= 15; ++type) {
|
||||
const bool eligible = Filter::slotEligible(true, false, all, type);
|
||||
EXPECT_FALSE(eligible) << unsigned(type);
|
||||
EXPECT_TRUE(Filter::publishComplete(eligible, false)) << unsigned(type);
|
||||
}
|
||||
// With topic support restored the same slot becomes a retry target again.
|
||||
EXPECT_TRUE(Filter::slotEligible(true, true, all, 4));
|
||||
EXPECT_FALSE(Filter::publishComplete(Filter::slotEligible(true, true, all, 4), false));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, EnabledUnionIgnoresDisabledSlotsAndGatesTheQueue) {
|
||||
const uint16_t masks[3] = {
|
||||
static_cast<uint16_t>(1u << 2), // text only
|
||||
static_cast<uint16_t>(1u << 4), // adverts only
|
||||
Filter::kAllPacketTypes, // everything, but disabled below
|
||||
};
|
||||
const bool enabled[3] = {true, true, false};
|
||||
|
||||
const uint16_t combined = Filter::enabledUnion(masks, enabled, 3);
|
||||
EXPECT_EQ(static_cast<uint16_t>((1u << 2) | (1u << 4)), combined);
|
||||
EXPECT_TRUE(Filter::allows(combined, 2));
|
||||
EXPECT_TRUE(Filter::allows(combined, 4));
|
||||
EXPECT_FALSE(Filter::allows(combined, 3)); // no enabled slot wants ACKs
|
||||
|
||||
// A null `enabled` array counts every slot; no slots means no destination.
|
||||
EXPECT_EQ(Filter::kAllPacketTypes, Filter::enabledUnion(masks, nullptr, 3));
|
||||
EXPECT_EQ(0u, Filter::enabledUnion(masks, enabled, 0));
|
||||
EXPECT_EQ(0u, Filter::enabledUnion(nullptr, enabled, 3));
|
||||
|
||||
// The union must never reject a type any single slot would have published.
|
||||
for (uint8_t type = 0; type <= 15; ++type) {
|
||||
const bool any_slot_allows =
|
||||
(enabled[0] && Filter::allows(masks[0], type)) ||
|
||||
(enabled[1] && Filter::allows(masks[1], type)) ||
|
||||
(enabled[2] && Filter::allows(masks[2], type));
|
||||
EXPECT_EQ(any_slot_allows, Filter::allows(combined, type)) << unsigned(type);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, AllMasksDefaultDrivesTheShortPrefsPayload) {
|
||||
uint16_t masks[6];
|
||||
for (int i = 0; i < 6; ++i) masks[i] = Filter::kAllPacketTypes;
|
||||
EXPECT_TRUE(Filter::allMasksDefault(masks, 6));
|
||||
EXPECT_TRUE(Filter::allMasksDefault(nullptr, 6));
|
||||
EXPECT_TRUE(Filter::allMasksDefault(masks, 0));
|
||||
|
||||
// Any slot off the default -- including "none" -- opts into the longer payload.
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
masks[i] = 0;
|
||||
EXPECT_FALSE(Filter::allMasksDefault(masks, 6)) << i;
|
||||
masks[i] = static_cast<uint16_t>(Filter::kAllPacketTypes & ~1u);
|
||||
EXPECT_FALSE(Filter::allMasksDefault(masks, 6)) << i;
|
||||
masks[i] = Filter::kAllPacketTypes;
|
||||
}
|
||||
EXPECT_TRUE(Filter::allMasksDefault(masks, 6));
|
||||
}
|
||||
|
||||
// countTypes() backs the diag line's fallback when the exact list won't fit,
|
||||
// so it has to agree with what format() would have emitted.
|
||||
TEST(MQTTPacketFilter, CountTypesMatchesTheFormattedList) {
|
||||
EXPECT_EQ(0, Filter::countTypes(0));
|
||||
EXPECT_EQ(16, Filter::countTypes(Filter::kAllPacketTypes));
|
||||
EXPECT_EQ(1, Filter::countTypes(static_cast<uint16_t>(1u << 15)));
|
||||
|
||||
for (uint32_t mask = 0; mask <= 0xFFFFu; ++mask) {
|
||||
uint8_t expected = 0;
|
||||
for (uint8_t type = 0; type <= 15; ++type) {
|
||||
if ((mask & (1u << type)) != 0) expected++;
|
||||
}
|
||||
ASSERT_EQ(expected, Filter::countTypes(static_cast<uint16_t>(mask))) << mask;
|
||||
}
|
||||
}
|
||||
|
||||
// The diag fallback exists because a clipped list parses as a real, different
|
||||
// allowlist. Pin that hazard so the fallback is never "simplified" away.
|
||||
TEST(MQTTPacketFilter, ClippedListStillParsesAsADifferentAllowlist) {
|
||||
const uint16_t configured = static_cast<uint16_t>(Filter::kAllPacketTypes & ~1u);
|
||||
char text[Filter::kFilterTextSize];
|
||||
ASSERT_TRUE(Filter::format(configured, text, sizeof(text)));
|
||||
ASSERT_STREQ("1,2,3,4,5,6,7,8,9,10,11,12,13,14,15", text);
|
||||
|
||||
// Drop the trailing "15" the way a bounded reply buffer would.
|
||||
char clipped[Filter::kFilterTextSize];
|
||||
memcpy(clipped, text, sizeof(clipped));
|
||||
clipped[strlen("1,2,3,4,5,6,7,8,9,10,11,12,13,14,")] = '\0';
|
||||
|
||||
uint16_t misread = 0;
|
||||
ASSERT_FALSE(Filter::parse(clipped, &misread)); // trailing comma is rejected
|
||||
clipped[strlen("1,2,3,4,5,6,7,8,9,10,11,12,13,14")] = '\0';
|
||||
ASSERT_TRUE(Filter::parse(clipped, &misread)); // but one char earlier reads clean
|
||||
EXPECT_NE(configured, misread) << "a clipped list must not be mistaken for the real one";
|
||||
EXPECT_EQ(14, Filter::countTypes(misread));
|
||||
EXPECT_EQ(15, Filter::countTypes(configured));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -13,6 +13,12 @@ namespace {
|
||||
|
||||
constexpr const char* kTimestamp = "2026-07-18T12:34:56.123456+00:00";
|
||||
|
||||
struct RejectAllJsonAllocations : ArduinoJson::Allocator {
|
||||
void* allocate(size_t) override { return nullptr; }
|
||||
void deallocate(void*) override {}
|
||||
void* reallocate(void*, size_t) override { return nullptr; }
|
||||
};
|
||||
|
||||
static int buildMinimalStatus(JsonDocument& scratch, char* buffer, size_t buffer_size) {
|
||||
return MQTTPayloadBuilder::buildStatusMessage(
|
||||
scratch, "DEN Repeater", "0123456789ABCDEF", "Heltec V3", "v1.16.0",
|
||||
@@ -250,8 +256,8 @@ TEST(MQTTPayloadBuilder, NeighborsMessageRoundTripsSelfAndEntries) {
|
||||
JsonDocument scratch;
|
||||
char buffer[1024];
|
||||
int len = MQTTPayloadBuilder::buildNeighborsMessage(
|
||||
scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS",
|
||||
neighbors, 2, buffer, sizeof(buffer));
|
||||
scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS", "*",
|
||||
neighbors, 2, buffer, sizeof(buffer), 5, 2, true);
|
||||
|
||||
ASSERT_GT(len, 0);
|
||||
EXPECT_EQ(static_cast<size_t>(len), strlen(buffer));
|
||||
@@ -261,6 +267,10 @@ TEST(MQTTPayloadBuilder, NeighborsMessageRoundTripsSelfAndEntries) {
|
||||
EXPECT_STREQ("DEN Repeater", parsed["origin"].as<const char*>());
|
||||
EXPECT_STREQ("0123456789ABCDEF", parsed["origin_id"].as<const char*>());
|
||||
EXPECT_STREQ("DEN,APRS", parsed["self"]["scopes"].as<const char*>());
|
||||
EXPECT_STREQ("*", parsed["self"]["default_scope"].as<const char*>());
|
||||
EXPECT_EQ(5, parsed["total_neighbors"].as<int>());
|
||||
EXPECT_EQ(2, parsed["queried_neighbors"].as<int>());
|
||||
EXPECT_TRUE(parsed["truncated"].as<bool>());
|
||||
|
||||
JsonArray arr = parsed["neighbors"].as<JsonArray>();
|
||||
ASSERT_EQ(2U, arr.size());
|
||||
@@ -274,17 +284,128 @@ TEST(MQTTPayloadBuilder, NeighborsMessageRoundTripsSelfAndEntries) {
|
||||
EXPECT_STREQ("stale", arr[1]["status"].as<const char*>());
|
||||
}
|
||||
|
||||
TEST(MQTTPayloadBuilder, NeighborsMessageMeasurementsMatchCompletePayload) {
|
||||
MQTTPayloadBuilder::NeighborsMessageEntry neighbors[] = {
|
||||
{"0011223344556677", 9.75f, UINT32_MAX, "DEN,APRS", "responded"},
|
||||
{"8899AABBCCDDEEFF", -3.5f, UINT32_MAX, "", "timeout"},
|
||||
};
|
||||
|
||||
size_t measured =
|
||||
MQTTPayloadBuilder::measureNeighborsMessageBase(
|
||||
"DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS", "*", 2)
|
||||
+ MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbors[0])
|
||||
+ 1 // array comma
|
||||
+ MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbors[1]);
|
||||
|
||||
JsonDocument scratch;
|
||||
char buffer[1024];
|
||||
int len = MQTTPayloadBuilder::buildNeighborsMessage(
|
||||
scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS", "*",
|
||||
neighbors, 2, buffer, sizeof(buffer), 2, 2, false);
|
||||
|
||||
ASSERT_GT(len, 0);
|
||||
EXPECT_EQ(measured, static_cast<size_t>(len));
|
||||
}
|
||||
|
||||
TEST(MQTTPayloadBuilder, NeighborsMessageMeasuredPrefixStopsBeforeOverflow) {
|
||||
MQTTPayloadBuilder::NeighborsMessageEntry neighbors[20];
|
||||
static char keys[20][17];
|
||||
static char long_scopes[96];
|
||||
memset(long_scopes, 'S', sizeof(long_scopes) - 1);
|
||||
long_scopes[sizeof(long_scopes) - 1] = '\0';
|
||||
for (int i = 0; i < 20; i++) {
|
||||
snprintf(keys[i], sizeof(keys[i]), "%016X", i);
|
||||
neighbors[i] = {
|
||||
keys[i], static_cast<float>(i), UINT32_MAX,
|
||||
long_scopes, "responded"};
|
||||
}
|
||||
|
||||
constexpr size_t kBufferSize = 512;
|
||||
size_t used = MQTTPayloadBuilder::measureNeighborsMessageBase(
|
||||
"node", "id", kTimestamp, "DEN", "*", 20);
|
||||
int published = 0;
|
||||
int queried = 0;
|
||||
while (queried < 20) {
|
||||
size_t added =
|
||||
MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbors[queried])
|
||||
+ (published > 0 ? 1U : 0U);
|
||||
queried++;
|
||||
if (used + added >= kBufferSize) break;
|
||||
used += added;
|
||||
published++;
|
||||
}
|
||||
|
||||
ASSERT_GT(published, 0);
|
||||
ASSERT_LT(published, 20);
|
||||
ASSERT_EQ(published + 1, queried); // only the non-fitting candidate was extra
|
||||
|
||||
JsonDocument scratch;
|
||||
char buffer[kBufferSize];
|
||||
int len = MQTTPayloadBuilder::buildNeighborsMessage(
|
||||
scratch, "node", "id", kTimestamp, "DEN", "*",
|
||||
neighbors, published, buffer, sizeof(buffer),
|
||||
20, queried, true);
|
||||
|
||||
ASSERT_GT(len, 0);
|
||||
EXPECT_LT(static_cast<size_t>(len), sizeof(buffer));
|
||||
JsonDocument parsed;
|
||||
ASSERT_FALSE(deserializeJson(parsed, buffer));
|
||||
EXPECT_EQ(static_cast<size_t>(published),
|
||||
parsed["neighbors"].as<JsonArray>().size());
|
||||
EXPECT_EQ(20, parsed["total_neighbors"].as<int>());
|
||||
EXPECT_EQ(queried, parsed["queried_neighbors"].as<int>());
|
||||
EXPECT_TRUE(parsed["truncated"].as<bool>());
|
||||
}
|
||||
|
||||
TEST(MQTTPayloadBuilder, NeighborsMessageFallbackMarksTruncated) {
|
||||
MQTTPayloadBuilder::NeighborsMessageEntry neighbors[] = {
|
||||
{"0011223344556677", 9.75f, 1, "DEN", "responded"},
|
||||
{"8899AABBCCDDEEFF", -3.5f, 2, "APRS", "responded"},
|
||||
};
|
||||
size_t first_only_size =
|
||||
MQTTPayloadBuilder::measureNeighborsMessageBase(
|
||||
"node", "id", kTimestamp, "DEN", "*", 2)
|
||||
+ MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbors[0]);
|
||||
std::vector<char> buffer(first_only_size + 1);
|
||||
|
||||
JsonDocument scratch;
|
||||
int len = MQTTPayloadBuilder::buildNeighborsMessage(
|
||||
scratch, "node", "id", kTimestamp, "DEN", "*",
|
||||
neighbors, 2, buffer.data(), buffer.size(),
|
||||
2, 2, false);
|
||||
|
||||
ASSERT_GT(len, 0);
|
||||
JsonDocument parsed;
|
||||
ASSERT_FALSE(deserializeJson(parsed, buffer.data()));
|
||||
EXPECT_EQ(1U, parsed["neighbors"].as<JsonArray>().size());
|
||||
EXPECT_TRUE(parsed["truncated"].as<bool>());
|
||||
}
|
||||
|
||||
TEST(MQTTPayloadBuilder, NeighborsMessageFailsCleanlyOnAllocationFailure) {
|
||||
MQTTPayloadBuilder::NeighborsMessageEntry neighbor = {
|
||||
"0011223344556677", 9.75f, 1, "DEN", "responded"};
|
||||
RejectAllJsonAllocations allocator;
|
||||
JsonDocument scratch(&allocator);
|
||||
char buffer[512];
|
||||
|
||||
EXPECT_EQ(0, MQTTPayloadBuilder::buildNeighborsMessage(
|
||||
scratch, "node", "id", kTimestamp, "DEN", "*",
|
||||
&neighbor, 1, buffer, sizeof(buffer),
|
||||
1, 1, false));
|
||||
}
|
||||
|
||||
TEST(MQTTPayloadBuilder, NeighborsMessageHandlesEmptyTableAndNullScopes) {
|
||||
JsonDocument scratch;
|
||||
char buffer[256];
|
||||
int len = MQTTPayloadBuilder::buildNeighborsMessage(
|
||||
scratch, "node", "id", kTimestamp, nullptr, nullptr, 0,
|
||||
scratch, "node", "id", kTimestamp, nullptr, nullptr, nullptr, 0,
|
||||
buffer, sizeof(buffer));
|
||||
|
||||
ASSERT_GT(len, 0);
|
||||
JsonDocument parsed;
|
||||
ASSERT_FALSE(deserializeJson(parsed, buffer));
|
||||
EXPECT_STREQ("", parsed["self"]["scopes"].as<const char*>());
|
||||
EXPECT_STREQ("", parsed["self"]["default_scope"].as<const char*>());
|
||||
JsonArray arr = parsed["neighbors"].as<JsonArray>();
|
||||
ASSERT_TRUE(arr.isNull() == false);
|
||||
EXPECT_EQ(0U, arr.size());
|
||||
@@ -300,6 +421,7 @@ TEST(MQTTPayloadBuilder, NeighborsMessageDropsTailWhenBufferFills) {
|
||||
neighbors[i].pubkey_hex = keys[i];
|
||||
neighbors[i].snr = static_cast<float>(i);
|
||||
neighbors[i].heard_secs_ago = static_cast<uint32_t>(i) * 10U;
|
||||
neighbors[i].heard_unknown = false;
|
||||
neighbors[i].scopes = "DEN";
|
||||
neighbors[i].status = "active";
|
||||
}
|
||||
@@ -307,7 +429,7 @@ TEST(MQTTPayloadBuilder, NeighborsMessageDropsTailWhenBufferFills) {
|
||||
JsonDocument scratch;
|
||||
char buffer[512];
|
||||
int len = MQTTPayloadBuilder::buildNeighborsMessage(
|
||||
scratch, "node", "id", kTimestamp, "DEN", neighbors, 20,
|
||||
scratch, "node", "id", kTimestamp, "DEN", "*", neighbors, 20,
|
||||
buffer, sizeof(buffer));
|
||||
|
||||
ASSERT_GT(len, 0);
|
||||
@@ -318,10 +440,52 @@ TEST(MQTTPayloadBuilder, NeighborsMessageDropsTailWhenBufferFills) {
|
||||
ASSERT_FALSE(arr.isNull());
|
||||
EXPECT_GT(arr.size(), 0U);
|
||||
EXPECT_LT(arr.size(), 20U);
|
||||
EXPECT_FALSE(parsed["truncated"].is<JsonVariant>());
|
||||
// Kept entries are the head of the input, in order.
|
||||
EXPECT_STREQ(keys[0], arr[0]["pubkey"].as<const char*>());
|
||||
}
|
||||
|
||||
TEST(MQTTPayloadBuilder, NeighborsMessageRendersUnknownHeardAgeAsNull) {
|
||||
// A neighbour stamped before the clock was set has no usable age. The key
|
||||
// stays present and null so a consumer cannot read it as "heard just now".
|
||||
MQTTPayloadBuilder::NeighborsMessageEntry neighbors[2];
|
||||
neighbors[0] = {"0011223344556677", 9.75f, 42, "DEN", "responded"};
|
||||
neighbors[1] = {"8899AABBCCDDEEFF", -3.5f, 0, "DEN", "responded"};
|
||||
neighbors[1].heard_unknown = true;
|
||||
|
||||
JsonDocument scratch;
|
||||
char buffer[1024];
|
||||
int len = MQTTPayloadBuilder::buildNeighborsMessage(
|
||||
scratch, "node", "id", kTimestamp, "DEN", "*", neighbors, 2,
|
||||
buffer, sizeof(buffer), 2, 2, false);
|
||||
|
||||
ASSERT_GT(len, 0);
|
||||
JsonDocument parsed;
|
||||
ASSERT_FALSE(deserializeJson(parsed, buffer));
|
||||
JsonArray arr = parsed["neighbors"].as<JsonArray>();
|
||||
ASSERT_EQ(2U, arr.size());
|
||||
EXPECT_EQ(42U, arr[0]["heard_secs_ago"].as<uint32_t>());
|
||||
EXPECT_FALSE(arr[0]["heard_secs_ago"].isNull());
|
||||
EXPECT_TRUE(arr[1]["heard_secs_ago"].isNull());
|
||||
EXPECT_NE(nullptr, strstr(buffer, "\"heard_secs_ago\":null"));
|
||||
// The rest of the entry still renders.
|
||||
EXPECT_STREQ("8899AABBCCDDEEFF", arr[1]["pubkey"].as<const char*>());
|
||||
EXPECT_STREQ("responded", arr[1]["status"].as<const char*>());
|
||||
}
|
||||
|
||||
TEST(MQTTPayloadBuilder, NeighborsMessageUnknownHeardAgeFitsMeasuredWidth) {
|
||||
// Paced discovery measures each entry with a known UINT32_MAX age; a null age
|
||||
// must never serialize wider than what that reserved.
|
||||
MQTTPayloadBuilder::NeighborsMessageEntry measured = {
|
||||
"0011223344556677", 9.75f, UINT32_MAX, "DEN", "responded"};
|
||||
MQTTPayloadBuilder::NeighborsMessageEntry unknown = measured;
|
||||
unknown.heard_unknown = true;
|
||||
unknown.heard_secs_ago = 0;
|
||||
|
||||
EXPECT_LE(MQTTPayloadBuilder::measureNeighborsMessageEntry(unknown),
|
||||
MQTTPayloadBuilder::measureNeighborsMessageEntry(measured));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#define WITH_MQTT_BRIDGE 1
|
||||
#include "helpers/MQTTPrefsCodec.h"
|
||||
#include "helpers/MQTTPacketFilter.h"
|
||||
|
||||
namespace Codec = MQTTPrefsCodec;
|
||||
|
||||
@@ -20,6 +21,7 @@ MQTTPrefs defaults() {
|
||||
prefs.wifi_power_save = 1;
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
strncpy(prefs.mqtt_slot_preset[i], "none", sizeof(prefs.mqtt_slot_preset[i]) - 1);
|
||||
prefs.mqtt_slot_packet_filter[i] = MQTTPacketFilter::kAllPacketTypes;
|
||||
}
|
||||
strncpy(prefs.snmp_community, "public", sizeof(prefs.snmp_community) - 1);
|
||||
prefs.radio_watchdog_minutes = 5;
|
||||
@@ -218,6 +220,10 @@ TEST(MQTTPrefsCodec, MigratesAllLegacySixSlotPrefixesWithoutClobberingDefaults)
|
||||
} else {
|
||||
EXPECT_STREQ("time.example", prefs.mqtt_ntp_server);
|
||||
}
|
||||
for (int slot = 0; slot < MQTT_PREFS_SLOT_COUNT; ++slot) {
|
||||
EXPECT_EQ(MQTTPacketFilter::kAllPacketTypes,
|
||||
prefs.mqtt_slot_packet_filter[slot]) << size << ":" << slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +234,9 @@ TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) {
|
||||
strncpy(source.alert_region, "PNW", sizeof(source.alert_region) - 1);
|
||||
source.mqtt_neighbors_enabled = 1;
|
||||
source.mqtt_neighbors_interval = MQTT_NEIGHBORS_MAX_INTERVAL_MS;
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
source.mqtt_slot_packet_filter[i] = static_cast<uint16_t>(1u << i);
|
||||
}
|
||||
std::vector<uint8_t> bytes(Codec::kEncodedSize);
|
||||
ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size()));
|
||||
|
||||
@@ -240,6 +249,114 @@ TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) {
|
||||
EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source)));
|
||||
}
|
||||
|
||||
// /mqtt_prefs also carries the WiFi credentials, so a payload older firmware
|
||||
// rejects strands a downgraded node with no network and no way to save. The
|
||||
// filter tail is only written once it actually holds something.
|
||||
TEST(MQTTPrefsCodec, DefaultFiltersKeepTheDowngradeReadablePayloadLength) {
|
||||
MQTTPrefs source = defaults();
|
||||
strncpy(source.wifi_ssid, "home-net", sizeof(source.wifi_ssid) - 1);
|
||||
strncpy(source.mqtt_slot_preset[0], "analyzer-us", sizeof(source.mqtt_slot_preset[0]) - 1);
|
||||
|
||||
ASSERT_EQ(Codec::kV1PreFilterPayloadSize, Codec::payloadLenFor(source));
|
||||
std::vector<uint8_t> bytes(Codec::kEncodedSize, 0xEE);
|
||||
const size_t written = Codec::encode(source, bytes.data(), bytes.size());
|
||||
ASSERT_EQ(sizeof(MQTTPrefsHeader) + Codec::kV1PreFilterPayloadSize, written);
|
||||
EXPECT_LT(written, Codec::kEncodedSize);
|
||||
|
||||
MQTTPrefsHeader header;
|
||||
memcpy(&header, bytes.data(), sizeof(header));
|
||||
EXPECT_EQ(Codec::kV1PreFilterPayloadSize, header.payload_len);
|
||||
// Nothing past the boundary was touched, so the file really is the short one.
|
||||
for (size_t i = written; i < bytes.size(); ++i) {
|
||||
EXPECT_EQ(0xEE, bytes[i]) << i;
|
||||
}
|
||||
|
||||
bytes.resize(written);
|
||||
const Codec::DecodePlan plan = classify(bytes);
|
||||
ASSERT_EQ(Codec::Source::Current, plan.source);
|
||||
ASSERT_EQ(Codec::kV1PreFilterPayloadSize, plan.payload_len);
|
||||
ASSERT_FALSE(plan.preserve_file);
|
||||
|
||||
MQTTPrefs loaded = defaults();
|
||||
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len);
|
||||
EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source)));
|
||||
}
|
||||
|
||||
TEST(MQTTPrefsCodec, AnyNonDefaultFilterOptsIntoTheLongerPayload) {
|
||||
MQTTPrefs source = defaults();
|
||||
strncpy(source.wifi_ssid, "home-net", sizeof(source.wifi_ssid) - 1);
|
||||
|
||||
// "none" is as much a real setting as a subset, and must survive a reload.
|
||||
for (int slot = 0; slot < MQTT_PREFS_SLOT_COUNT; ++slot) {
|
||||
for (uint16_t mask : {static_cast<uint16_t>(0),
|
||||
static_cast<uint16_t>(1u << 4),
|
||||
static_cast<uint16_t>(MQTTPacketFilter::kAllPacketTypes & ~1u)}) {
|
||||
source.mqtt_slot_packet_filter[slot] = mask;
|
||||
ASSERT_EQ(Codec::kV1BaselinePayloadSize, Codec::payloadLenFor(source)) << slot;
|
||||
|
||||
std::vector<uint8_t> bytes(Codec::kEncodedSize);
|
||||
ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size()));
|
||||
const Codec::DecodePlan plan = classify(bytes);
|
||||
ASSERT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len);
|
||||
|
||||
MQTTPrefs loaded = defaults();
|
||||
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len);
|
||||
EXPECT_EQ(mask, loaded.mqtt_slot_packet_filter[slot]) << slot;
|
||||
EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source)));
|
||||
}
|
||||
source.mqtt_slot_packet_filter[slot] = MQTTPacketFilter::kAllPacketTypes;
|
||||
}
|
||||
// Clearing the last non-default filter returns the node to the short payload.
|
||||
EXPECT_EQ(Codec::kV1PreFilterPayloadSize, Codec::payloadLenFor(source));
|
||||
}
|
||||
|
||||
TEST(MQTTPrefsCodec, EncodeRefusesAnOutputBufferShorterThanItsChosenPayload) {
|
||||
MQTTPrefs shortest = defaults();
|
||||
const size_t short_size = sizeof(MQTTPrefsHeader) + Codec::kV1PreFilterPayloadSize;
|
||||
std::vector<uint8_t> bytes(Codec::kEncodedSize);
|
||||
|
||||
EXPECT_EQ(0u, Codec::encode(shortest, bytes.data(), short_size - 1));
|
||||
EXPECT_EQ(short_size, Codec::encode(shortest, bytes.data(), short_size));
|
||||
EXPECT_EQ(0u, Codec::encode(shortest, nullptr, bytes.size()));
|
||||
|
||||
// A buffer that fits the short payload is not enough for the long one.
|
||||
MQTTPrefs longest = defaults();
|
||||
longest.mqtt_slot_packet_filter[0] = 0;
|
||||
EXPECT_EQ(0u, Codec::encode(longest, bytes.data(), short_size));
|
||||
EXPECT_EQ(Codec::kEncodedSize, Codec::encode(longest, bytes.data(), bytes.size()));
|
||||
}
|
||||
|
||||
TEST(MQTTPrefsCodec, PreFilterV1PayloadDefaultsEverySlotToAllTypes) {
|
||||
MQTTPrefs source = defaults();
|
||||
strncpy(source.mqtt_origin, "pre-filter-node", sizeof(source.mqtt_origin) - 1);
|
||||
source.mqtt_neighbors_enabled = 1;
|
||||
source.mqtt_neighbors_interval = MQTT_NEIGHBORS_MAX_INTERVAL_MS;
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
source.mqtt_slot_packet_filter[i] = 0;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> bytes(sizeof(MQTTPrefsHeader) + Codec::kV1PreFilterPayloadSize, 0);
|
||||
writeHeader(&bytes, MQTT_PREFS_VERSION,
|
||||
static_cast<uint16_t>(Codec::kV1PreFilterPayloadSize));
|
||||
memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, Codec::kV1PreFilterPayloadSize);
|
||||
|
||||
const Codec::DecodePlan plan = classify(bytes);
|
||||
ASSERT_EQ(Codec::Source::Current, plan.source);
|
||||
ASSERT_EQ(Codec::kV1PreFilterPayloadSize, plan.payload_len);
|
||||
ASSERT_TRUE(plan.observer_fields_present);
|
||||
ASSERT_FALSE(plan.preserve_file);
|
||||
|
||||
MQTTPrefs loaded = defaults();
|
||||
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len);
|
||||
EXPECT_STREQ("pre-filter-node", loaded.mqtt_origin);
|
||||
EXPECT_EQ(1u, loaded.mqtt_neighbors_enabled);
|
||||
EXPECT_EQ(MQTT_NEIGHBORS_MAX_INTERVAL_MS, loaded.mqtt_neighbors_interval);
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
EXPECT_EQ(MQTTPacketFilter::kAllPacketTypes,
|
||||
loaded.mqtt_slot_packet_filter[i]) << i;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPrefsCodec, CompatibleShortV1PayloadPreservesDefaultsBeyondObserverBoundary) {
|
||||
MQTTPrefs source = defaults();
|
||||
strncpy(source.mqtt_origin, "short-v1-node", sizeof(source.mqtt_origin) - 1);
|
||||
@@ -304,6 +421,10 @@ TEST(MQTTPrefsCodec, PreNeighborsV1PayloadLoadsObserverFieldsAndDefaultsNeighbor
|
||||
// Interval begins at 2860 (beyond the read) -> keeps the caller's default.
|
||||
EXPECT_EQ(0u, loaded.mqtt_neighbors_enabled);
|
||||
EXPECT_EQ(MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS, loaded.mqtt_neighbors_interval);
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
EXPECT_EQ(MQTTPacketFilter::kAllPacketTypes,
|
||||
loaded.mqtt_slot_packet_filter[i]) << i;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPrefsCodec, CorruptOrShortVersionedInputsArePreserved) {
|
||||
@@ -378,18 +499,83 @@ TEST(MQTTPrefsCodec, UnsupportedHeaderlessSizesArePreserved) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPrefsCodec, NewerAndSameVersionExtendedPayloadsAreHeld) {
|
||||
// A different version tag means the layout may have changed shape, so it is
|
||||
// still refused outright. That refusal is what makes reading longer same-version
|
||||
// payloads safe, so the two belong in one test.
|
||||
TEST(MQTTPrefsCodec, ADifferentVersionTagIsStillRefusedAndHeld) {
|
||||
std::vector<uint8_t> newer(sizeof(MQTTPrefsHeader), 0);
|
||||
writeHeader(&newer, MQTT_PREFS_VERSION + 1, 0);
|
||||
Codec::DecodePlan plan = classify(newer);
|
||||
const Codec::DecodePlan plan = classify(newer);
|
||||
EXPECT_EQ(Codec::Source::UnsupportedVersion, plan.source);
|
||||
EXPECT_TRUE(plan.preserve_file);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> extended(sizeof(MQTTPrefsHeader) + Codec::kV1BaselinePayloadSize + 1, 0);
|
||||
writeHeader(&extended, MQTT_PREFS_VERSION,
|
||||
static_cast<uint16_t>(Codec::kV1BaselinePayloadSize + 1));
|
||||
plan = classify(extended);
|
||||
EXPECT_EQ(Codec::Source::UnsupportedVersion, plan.source);
|
||||
// The downgrade contract: a v1 payload longer than this build's baseline was
|
||||
// appended to by a later build, so the baseline prefix is present verbatim.
|
||||
// Read it and drop the tail -- never refuse the file, which would cost the
|
||||
// operator WiFi and every broker slot to save settings they don't understand.
|
||||
TEST(MQTTPrefsCodec, LongerSameVersionPayloadLoadsTheBaselineAndIgnoresTheTail) {
|
||||
MQTTPrefs source = defaults();
|
||||
strncpy(source.mqtt_origin, "future-node", sizeof(source.mqtt_origin) - 1);
|
||||
strncpy(source.wifi_ssid, "field-ssid", sizeof(source.wifi_ssid) - 1);
|
||||
strncpy(source.wifi_password, "field-secret", sizeof(source.wifi_password) - 1);
|
||||
strncpy(source.mqtt_iata, "SEA", sizeof(source.mqtt_iata) - 1);
|
||||
strncpy(source.mqtt_slot_preset[0], "meshrank", sizeof(source.mqtt_slot_preset[0]) - 1);
|
||||
source.mqtt_neighbors_enabled = 1;
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
source.mqtt_slot_packet_filter[i] = static_cast<uint16_t>(1u << i);
|
||||
}
|
||||
|
||||
// Baseline image plus a 40-byte tail of fields this build has never heard of.
|
||||
const size_t kTail = 40;
|
||||
const size_t payload_len = Codec::kV1BaselinePayloadSize + kTail;
|
||||
std::vector<uint8_t> bytes(sizeof(MQTTPrefsHeader) + payload_len, 0xA5);
|
||||
writeHeader(&bytes, MQTT_PREFS_VERSION, static_cast<uint16_t>(payload_len));
|
||||
memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, sizeof(source));
|
||||
|
||||
const Codec::DecodePlan plan = classify(bytes);
|
||||
ASSERT_EQ(Codec::Source::Current, plan.source);
|
||||
EXPECT_FALSE(plan.preserve_file) << "refusing the file would strand the node";
|
||||
EXPECT_TRUE(plan.observer_fields_present);
|
||||
EXPECT_FALSE(plan.rewrite_legacy);
|
||||
ASSERT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len);
|
||||
|
||||
// Reading plan.payload_len bytes recovers this build's whole struct exactly,
|
||||
// and cannot run past it into the unknown tail.
|
||||
MQTTPrefs loaded = defaults();
|
||||
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len);
|
||||
EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source)));
|
||||
EXPECT_STREQ("future-node", loaded.mqtt_origin);
|
||||
EXPECT_STREQ("field-ssid", loaded.wifi_ssid);
|
||||
EXPECT_STREQ("field-secret", loaded.wifi_password);
|
||||
EXPECT_STREQ("meshrank", loaded.mqtt_slot_preset[0]);
|
||||
EXPECT_EQ(1u, loaded.mqtt_neighbors_enabled);
|
||||
EXPECT_EQ(1u, loaded.mqtt_slot_packet_filter[0]);
|
||||
}
|
||||
|
||||
// The rule has to hold for any appended size, including a single byte and a
|
||||
// tail far larger than the baseline.
|
||||
TEST(MQTTPrefsCodec, EveryLongerSameVersionLengthReadsTheBaseline) {
|
||||
for (const size_t tail : {size_t(1), size_t(2), size_t(12), size_t(64),
|
||||
size_t(512), size_t(4096)}) {
|
||||
const size_t payload_len = Codec::kV1BaselinePayloadSize + tail;
|
||||
std::vector<uint8_t> bytes(sizeof(MQTTPrefsHeader) + payload_len, 0);
|
||||
writeHeader(&bytes, MQTT_PREFS_VERSION, static_cast<uint16_t>(payload_len));
|
||||
const Codec::DecodePlan plan = classify(bytes);
|
||||
EXPECT_EQ(Codec::Source::Current, plan.source) << tail;
|
||||
EXPECT_FALSE(plan.preserve_file) << tail;
|
||||
EXPECT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len) << tail;
|
||||
}
|
||||
}
|
||||
|
||||
// A length that merely *declares* a longer payload without the bytes to back it
|
||||
// is still corrupt -- the header/file-size agreement check must run first.
|
||||
TEST(MQTTPrefsCodec, LongerDeclaredLengthWithoutTheBytesIsStillCorrupt) {
|
||||
std::vector<uint8_t> bytes(sizeof(MQTTPrefsHeader) + Codec::kV1BaselinePayloadSize, 0);
|
||||
writeHeader(&bytes, MQTT_PREFS_VERSION,
|
||||
static_cast<uint16_t>(Codec::kV1BaselinePayloadSize + 16));
|
||||
const Codec::DecodePlan plan = classify(bytes);
|
||||
EXPECT_EQ(Codec::Source::Corrupt, plan.source);
|
||||
EXPECT_TRUE(plan.preserve_file);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,24 +44,27 @@ TEST(MQTTTopicRouter, EveryMeshCorePresetSupportsEveryPublicationType) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTTopicRouter, MeshRankContractIsPacketsOnly) {
|
||||
// Guards the raw exclusion: a merge from observer-firmware must not re-enable it.
|
||||
TEST(MQTTTopicRouter, MeshRankTakesEveryTypeExceptRaw) {
|
||||
const MQTTPresetDef* preset = findMQTTPreset("meshrank");
|
||||
ASSERT_NE(nullptr, preset);
|
||||
ASSERT_EQ(MQTT_TOPIC_MESHRANK, preset->topic_style);
|
||||
|
||||
char topic[128];
|
||||
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_STATUS,
|
||||
nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic)));
|
||||
EXPECT_STREQ("", topic);
|
||||
ASSERT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS,
|
||||
nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic)));
|
||||
EXPECT_STREQ("meshrank/uplink/account-token/0123456789ABCDEF/packets", topic);
|
||||
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_RAW,
|
||||
nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic)));
|
||||
EXPECT_STREQ("", topic);
|
||||
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_NEIGHBORS,
|
||||
nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic)));
|
||||
EXPECT_STREQ("", topic);
|
||||
for (const TypeCase& type : kTypes) {
|
||||
char topic[128];
|
||||
if (type.type == MQTT_PUBLICATION_RAW) {
|
||||
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, type.type, nullptr,
|
||||
IATA, DEVICE, TOKEN, topic, sizeof(topic)));
|
||||
EXPECT_STREQ("", topic);
|
||||
continue;
|
||||
}
|
||||
ASSERT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, type.type, nullptr,
|
||||
IATA, DEVICE, TOKEN, topic, sizeof(topic)))
|
||||
<< type.name;
|
||||
EXPECT_EQ(std::string("meshrank/uplink/account-token/0123456789ABCDEF/") + type.name,
|
||||
topic)
|
||||
<< type.name;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTTopicRouter, MeshCoreRequiresUsableIataAndDevice) {
|
||||
|
||||
@@ -210,6 +210,108 @@ TEST(WebConfigBatch, StopWarnsOnceAfterTheDeadlineThenKeepsWaiting) {
|
||||
EXPECT_EQ(Batch::StopAction::Wait, Batch::stopStep(2, false, 0, 999999));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CLI sequences (/api/cli), which share the deferred-command slot
|
||||
// --------------------------------------------------------------------------
|
||||
TEST(WebConfigBatch, CliReadPagesResultsAndNeverOverrunsWhatHasDrained) {
|
||||
const int page = Batch::kCliResultPage;
|
||||
// Nothing drained past the cursor yet.
|
||||
EXPECT_EQ(0, Batch::cliPageCount(/*from=*/0, /*produced=*/0, page));
|
||||
EXPECT_EQ(0, Batch::cliPageCount(/*from=*/3, /*produced=*/3, page));
|
||||
// Partial progress: hand back exactly what exists.
|
||||
EXPECT_EQ(3, Batch::cliPageCount(0, 3, page));
|
||||
EXPECT_EQ(2, Batch::cliPageCount(5, 7, page));
|
||||
// More available than fits in one read: cap at the page size.
|
||||
EXPECT_EQ(page, Batch::cliPageCount(0, page + 5, page));
|
||||
// A cursor beyond what has drained (stale or crafted) yields nothing rather
|
||||
// than a negative count that would index backwards through the batch.
|
||||
EXPECT_EQ(0, Batch::cliPageCount(/*from=*/9, /*produced=*/4, page));
|
||||
}
|
||||
|
||||
TEST(WebConfigBatch, CliReadIsDoneOnlyOnceEveryResultHasBeenHandedOver) {
|
||||
// Still executing: never final, however much has been read.
|
||||
EXPECT_FALSE(Batch::cliReadIsFinal(State::Pending, /*from=*/0, /*page=*/8, /*total=*/8));
|
||||
// Execution finished but the client has only seen the first page. Reporting
|
||||
// "done" here would make a client that stops polling lose the rest.
|
||||
EXPECT_FALSE(Batch::cliReadIsFinal(State::Done, /*from=*/0, /*page=*/8, /*total=*/20));
|
||||
EXPECT_FALSE(Batch::cliReadIsFinal(State::Done, /*from=*/8, /*page=*/8, /*total=*/20));
|
||||
// The read that hands over the last result is the final one.
|
||||
EXPECT_TRUE(Batch::cliReadIsFinal(State::Done, /*from=*/16, /*page=*/4, /*total=*/20));
|
||||
// Re-reading past the end stays final (polls after the last page).
|
||||
EXPECT_TRUE(Batch::cliReadIsFinal(State::Done, /*from=*/20, /*page=*/0, /*total=*/20));
|
||||
}
|
||||
|
||||
// Every string below is a literal lifted from CommonCLI.cpp /
|
||||
// CommonCLI_Observer.cpp. Testing only for an "Err" prefix passed five of these
|
||||
// off as success, which both coloured them green and let a queued reboot go
|
||||
// ahead after them.
|
||||
TEST(WebConfigBatch, CliFailureRepliesAreRecognisedInEveryShapeCommonCLIEmits) {
|
||||
const char* failures[] = {
|
||||
"Err - bad params", // MyMesh setperm
|
||||
"ERR: bad pubkey", // neighbor.remove
|
||||
"Error: IATA code must be exactly 3 letters",// observer setters
|
||||
"(ERR: clock cannot go backwards)", // clock sync, parenthesised
|
||||
"Unknown command", // top-level fallthrough
|
||||
"unknown config: mqtt.nope", // set fallthrough
|
||||
"??: mqtt.nope", // get fallthrough
|
||||
"Can't find GPS", // gps
|
||||
"File system erase: Err", // failure reported at the end
|
||||
};
|
||||
for (const char* f : failures) {
|
||||
EXPECT_TRUE(Batch::cliReplyIsFailure(f)) << f;
|
||||
}
|
||||
|
||||
const char* successes[] = {
|
||||
"OK",
|
||||
"OK - slot 1 preset: meshrank",
|
||||
"> 22", // getter value
|
||||
"> msgs: on, 1: analyzer-us (ok)", // getter, contains "ok"
|
||||
"File system erase: OK", // same shape, succeeded
|
||||
"Free: 142832, Min: 126808", // memory
|
||||
"v1.16.0 (Build: 6 Jun 2026)", // ver
|
||||
};
|
||||
for (const char* s : successes) {
|
||||
EXPECT_FALSE(Batch::cliReplyIsFailure(s)) << s;
|
||||
}
|
||||
// An empty reply is normalised to "OK" before it ever reaches the client.
|
||||
EXPECT_FALSE(Batch::cliReplyIsFailure(""));
|
||||
EXPECT_FALSE(Batch::cliReplyIsFailure(NULL));
|
||||
}
|
||||
|
||||
TEST(WebConfigBatch, OnlyWritesGateTheDeferredReboot) {
|
||||
// Writes gate it: these are what can leave a config not worth rebooting into.
|
||||
EXPECT_TRUE(Batch::cliReplyGatesReboot("set tx 22"));
|
||||
EXPECT_TRUE(Batch::cliReplyGatesReboot("set mqtt1.preset meshrank"));
|
||||
EXPECT_TRUE(Batch::cliReplyGatesReboot("password hunter2"));
|
||||
// Diagnostics do not. `memory` answering "Free: ..." must not be read as a
|
||||
// failure and strand the operator's reboot, and a getter's "> value" must not
|
||||
// be read as a success either -- neither is asked.
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot("memory"));
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot("get tx"));
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot("reboot"));
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot("advert"));
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot(NULL));
|
||||
// "settle" must not be mistaken for a `set`; the space is part of the token.
|
||||
EXPECT_FALSE(Batch::cliReplyGatesReboot("settle"));
|
||||
|
||||
// A write counts only on the "OK" prefix every setter keeps.
|
||||
EXPECT_TRUE(Batch::cliWriteSucceeded("OK"));
|
||||
EXPECT_TRUE(Batch::cliWriteSucceeded("OK - reboot to apply"));
|
||||
EXPECT_FALSE(Batch::cliWriteSucceeded("unknown config: nope"));
|
||||
EXPECT_FALSE(Batch::cliWriteSucceeded("Error: expected a number"));
|
||||
EXPECT_FALSE(Batch::cliWriteSucceeded(""));
|
||||
}
|
||||
|
||||
TEST(WebConfigBatch, CliRebootIsWithheldWhenAnyCommandInTheSequenceFailed) {
|
||||
EXPECT_TRUE(Batch::cliRebootAllowed(/*has_reboot=*/true, /*all_ok=*/true));
|
||||
// Same rule a config save follows: do not reboot into a half-applied config
|
||||
// over a link the operator may not get back.
|
||||
EXPECT_FALSE(Batch::cliRebootAllowed(true, false));
|
||||
// No `reboot` in the sequence: nothing to allow either way.
|
||||
EXPECT_FALSE(Batch::cliRebootAllowed(false, true));
|
||||
EXPECT_FALSE(Batch::cliRebootAllowed(false, false));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Wrap-around guard shared with the production _reboot_at assignments
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -22,6 +22,7 @@ TEST(WebConfigKeys, AllowsPerSlotKeys) {
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.preset"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.server"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.token"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt2.filter"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.audience")); // MAX_MQTT_SLOTS == 6
|
||||
}
|
||||
|
||||
@@ -102,6 +103,7 @@ TEST(WebConfigKeys, NonSecretKeysNotFlagged) {
|
||||
EXPECT_FALSE(wcIsSecretKey("wifi.ssid"));
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt1.username")); // username is not masked
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt1.server"));
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt1.filter"));
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt.origin"));
|
||||
EXPECT_FALSE(wcIsSecretKey("name"));
|
||||
EXPECT_FALSE(wcIsSecretKey(""));
|
||||
@@ -117,6 +119,45 @@ TEST(WebConfigKeys, EverySecretKeyIsAlsoAllowed) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CLI secret reads ----------------------------------------------------
|
||||
// The web CLI runs commands with sender_timestamp 0, which is how CommonCLI
|
||||
// recognises the serial console and answers secret getters in plaintext. These
|
||||
// are the reads that must be masked back down for an HTTP caller.
|
||||
|
||||
TEST(WebConfigKeys, MasksEverySecretReadTheCliCanReach) {
|
||||
const char* masked[] = {
|
||||
"get prv.key", // this node's identity -- the worst one to leak
|
||||
"get wifi.pwd", // grants the operator's LAN, not just the node
|
||||
"get guest.password",
|
||||
"get alert.psk",
|
||||
"get bridge.secret",
|
||||
"get mqtt1.password", "get mqtt1.token",
|
||||
"get mqtt6.password", "get mqtt6.token",
|
||||
"get wifi.pwd", // extra space after the verb
|
||||
};
|
||||
for (const char* c : masked) EXPECT_TRUE(wcIsSecretReadCommand(c)) << c;
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, DoesNotMaskReadsThatCarryNoSecret) {
|
||||
const char* plain[] = {
|
||||
"get wifi.ssid", "get mqtt1.username", "get mqtt1.server", "get tx",
|
||||
"get public.key", // public half, safe to read
|
||||
"get mqtt.owner", // an owner's public key, not a credential
|
||||
};
|
||||
for (const char* c : plain) EXPECT_FALSE(wcIsSecretReadCommand(c)) << c;
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, OnlyMasksReads) {
|
||||
// Writing a secret has always been the portal's job and reveals nothing;
|
||||
// only the read is restricted. Nor may a prefix be mistaken for a `get`.
|
||||
EXPECT_FALSE(wcIsSecretReadCommand("set wifi.pwd hunter2"));
|
||||
EXPECT_FALSE(wcIsSecretReadCommand("set prv.key aabb"));
|
||||
EXPECT_FALSE(wcIsSecretReadCommand("password hunter2"));
|
||||
EXPECT_FALSE(wcIsSecretReadCommand("getwifi.pwd"));
|
||||
EXPECT_FALSE(wcIsSecretReadCommand("get"));
|
||||
EXPECT_FALSE(wcIsSecretReadCommand(""));
|
||||
}
|
||||
|
||||
// ---- request correlation -------------------------------------------------
|
||||
|
||||
TEST(WebConfigKeys, AcceptsExactHexRequestIds) {
|
||||
|
||||
@@ -113,7 +113,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D MESH_PACKET_LOGGING=1
|
||||
@@ -206,7 +205,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -185,7 +185,8 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
@@ -221,7 +222,8 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
@@ -257,8 +259,9 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
@@ -294,8 +297,9 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
|
||||
@@ -115,6 +115,7 @@ lib_deps =
|
||||
[env:Heltec_v3_repeater_observer_mqtt]
|
||||
extends = Heltec_lora32_v3
|
||||
upload_port = /dev/cu.usbserial-4
|
||||
; External I2C sensors (BMP280 etc.) on header pins 33/34, same as the Heltec_v3_sensor env
|
||||
extra_scripts =
|
||||
${esp32_base.extra_scripts}
|
||||
pre:scripts/generate_cert_bundle.py
|
||||
@@ -129,7 +130,8 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; Periodic 30s heap/pub-stats serial log -- enable only for debugging (use `get mqtt.stats` on demand instead).
|
||||
@@ -147,6 +149,8 @@ build_flags =
|
||||
# -D MQTT_PORT=1883
|
||||
# -D MQTT_USERNAME='"your-username"'
|
||||
# -D MQTT_PASSWORD='"your-password"'
|
||||
-D ENV_PIN_SDA=33
|
||||
-D ENV_PIN_SCL=34
|
||||
build_src_filter = ${Heltec_lora32_v3.build_src_filter}
|
||||
+<helpers/bridges/MQTTBridge.cpp>
|
||||
+<helpers/MQTTMessageBuilder.cpp>
|
||||
@@ -195,6 +199,7 @@ lib_deps =
|
||||
|
||||
[env:Heltec_v3_room_server_observer_mqtt]
|
||||
extends = Heltec_lora32_v3
|
||||
; External I2C sensors (BMP280 etc.) on header pins 33/34, same as the Heltec_v3_sensor env
|
||||
extra_scripts =
|
||||
${esp32_base.extra_scripts}
|
||||
pre:scripts/generate_cert_bundle.py
|
||||
@@ -209,8 +214,9 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -219,6 +225,8 @@ build_flags =
|
||||
-D ESP32_CPU_FREQ=160
|
||||
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
|
||||
-D WITH_SNMP=1
|
||||
-D ENV_PIN_SDA=33
|
||||
-D ENV_PIN_SCL=34
|
||||
build_src_filter = ${Heltec_lora32_v3.build_src_filter}
|
||||
+<helpers/ui/SSD1306Display.cpp>
|
||||
+<helpers/bridges/MQTTBridge.cpp>
|
||||
@@ -542,7 +550,8 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; Periodic 30s heap/pub-stats serial log -- enable only for debugging (use `get mqtt.stats` on demand instead).
|
||||
@@ -591,8 +600,9 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; Keep default observer profile less verbose to reduce runtime contention.
|
||||
|
||||
@@ -163,7 +163,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MQTT_MEMORY_DEBUG=1
|
||||
@@ -214,7 +213,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MQTT_MEMORY_DEBUG=1
|
||||
@@ -317,7 +315,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -360,7 +357,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -118,7 +118,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
; -D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -164,7 +163,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -214,7 +214,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D PERSISTANT_GPS=1
|
||||
-D ENV_SKIP_GPS_DETECT=1
|
||||
@@ -261,7 +260,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D PERSISTANT_GPS=1
|
||||
-D ENV_SKIP_GPS_DETECT=1
|
||||
|
||||
@@ -171,7 +171,6 @@ build_flags =
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; retain scope support within this observer's tight internal DRAM
|
||||
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
; -D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -214,7 +213,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -167,7 +167,6 @@ build_flags =
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; retain scope support within this observer's tight internal DRAM
|
||||
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
; -D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -210,7 +209,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -122,7 +122,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D MESH_PACKET_LOGGING=1
|
||||
@@ -165,7 +164,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
@@ -186,7 +186,6 @@ build_flags =
|
||||
-D WEBCONFIG_DISABLED=1 ; async portal exceeds this 4 MB target's dual-OTA app slot; use CLI setup
|
||||
-D FLOOD_CHANNEL_SCOPE_SLOTS=4 ; no-PSRAM observer must retain internal DRAM for MQTT/TLS
|
||||
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_TASK_STACK_SIZE=16384
|
||||
-D ESP32_CPU_FREQ=240
|
||||
@@ -234,7 +233,6 @@ build_flags =
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D WEBCONFIG_DISABLED=1 ; async portal exceeds this 4 MB target's dual-OTA app slot; use CLI setup
|
||||
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_TASK_STACK_SIZE=16384
|
||||
-D ESP32_CPU_FREQ=240
|
||||
|
||||
@@ -100,6 +100,7 @@ lib_deps =
|
||||
|
||||
[env:RAK_3112_repeater_observer_mqtt]
|
||||
extends = rak3112
|
||||
; External I2C sensors on pins 33/34, same as the RAK_3112_sensor env
|
||||
extra_scripts =
|
||||
${esp32_base.extra_scripts}
|
||||
pre:scripts/generate_cert_bundle.py
|
||||
@@ -113,7 +114,8 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D MESH_PACKET_LOGGING=1
|
||||
@@ -128,6 +130,8 @@ build_flags =
|
||||
# -D MQTT_PORT=1883
|
||||
# -D MQTT_USERNAME='"your-username"'
|
||||
# -D MQTT_PASSWORD='"your-password"'
|
||||
-D ENV_PIN_SDA=33
|
||||
-D ENV_PIN_SCL=34
|
||||
build_src_filter = ${rak3112.build_src_filter}
|
||||
+<helpers/bridges/MQTTBridge.cpp>
|
||||
+<helpers/MQTTMessageBuilder.cpp>
|
||||
@@ -163,6 +167,7 @@ lib_deps =
|
||||
|
||||
[env:RAK_3112_room_server_observer_mqtt]
|
||||
extends = rak3112
|
||||
; External I2C sensors on pins 33/34, same as the RAK_3112_sensor env
|
||||
extra_scripts =
|
||||
${esp32_base.extra_scripts}
|
||||
pre:scripts/generate_cert_bundle.py
|
||||
@@ -176,8 +181,9 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h).
|
||||
-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
@@ -186,6 +192,8 @@ build_flags =
|
||||
-D ESP32_CPU_FREQ=160
|
||||
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
|
||||
-D WITH_SNMP=1
|
||||
-D ENV_PIN_SDA=33
|
||||
-D ENV_PIN_SCL=34
|
||||
build_src_filter = ${rak3112.build_src_filter}
|
||||
+<helpers/bridges/MQTTBridge.cpp>
|
||||
+<helpers/MQTTMessageBuilder.cpp>
|
||||
|
||||
@@ -26,8 +26,8 @@ build_flags =
|
||||
-D PIN_BOARD_SDA=5
|
||||
-D PIN_BOARD_SCL=6
|
||||
-D PIN_USER_BTN=38
|
||||
-D PIN_GPS_RX=7
|
||||
-D PIN_GPS_TX=15
|
||||
-D PIN_GPS_RX=15
|
||||
-D PIN_GPS_TX=7
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=true
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=1.8
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
@@ -202,7 +202,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
@@ -346,7 +345,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
# -D MESH_PACKET_LOGGING=1
|
||||
# -D MESH_DEBUG=1
|
||||
|
||||
@@ -160,3 +160,90 @@ lib_deps =
|
||||
extends = Station_G3_ESP32
|
||||
build_src_filter = ${Station_G3_ESP32.build_src_filter}
|
||||
+<../examples/kiss_modem/>
|
||||
|
||||
[env:Station_G3_ESP32_repeater_observer_mqtt]
|
||||
extends = Station_G3_ESP32
|
||||
board_build.partitions = default_16MB.csv ; standard 16MB partition table
|
||||
extra_scripts =
|
||||
${esp32_base.extra_scripts}
|
||||
pre:scripts/generate_cert_bundle.py
|
||||
board_ssl_cert_source = adafruit-full
|
||||
board_build.embed_files = src/certs/x509_crt_bundle.bin
|
||||
build_flags =
|
||||
${Station_G3_ESP32.build_flags}
|
||||
-D ADVERT_NAME='"MQTT Observer"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
# -D MESH_PACKET_LOGGING=1
|
||||
# -D MESH_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
-D ESP32_CPU_FREQ=160
|
||||
-D WITH_SNMP=1
|
||||
# -D WIFI_SSID='"ssid"'
|
||||
# -D WIFI_PWD='"password"'
|
||||
# -D MQTT_SERVER='"your-mqtt-broker.com"'
|
||||
# -D MQTT_PORT=1883
|
||||
# -D MQTT_USERNAME='"your-username"'
|
||||
# -D MQTT_PASSWORD='"your-password"'
|
||||
build_src_filter = ${Station_G3_ESP32.build_src_filter}
|
||||
+<helpers/bridges/MQTTBridge.cpp>
|
||||
+<helpers/MQTTMessageBuilder.cpp>
|
||||
+<helpers/JWTHelper.cpp>
|
||||
+<helpers/SNMPAgent.cpp>
|
||||
+<helpers/ui/SH1106Display.cpp>
|
||||
+<../examples/simple_repeater>
|
||||
lib_deps =
|
||||
${Station_G3_ESP32.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
elims/PsychicMqttClient@^0.2.4
|
||||
bblanchon/ArduinoJson @ 7.4.3
|
||||
arduino-libraries/NTPClient
|
||||
JChristensen/Timezone
|
||||
paulstoffregen/Time@1.6.1
|
||||
0neblock/SNMP_Agent
|
||||
|
||||
[env:Station_G3_ESP32_room_server_observer_mqtt]
|
||||
extends = Station_G3_ESP32
|
||||
board_build.partitions = default_16MB.csv ; standard 16MB partition table
|
||||
extra_scripts =
|
||||
${esp32_base.extra_scripts}
|
||||
pre:scripts/generate_cert_bundle.py
|
||||
board_ssl_cert_source = adafruit-full
|
||||
board_build.embed_files = src/certs/x509_crt_bundle.bin
|
||||
build_flags =
|
||||
${Station_G3_ESP32.build_flags}
|
||||
-D ADVERT_NAME='"Station G3 ESP32 Room Observer"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
-D ESP32_CPU_FREQ=160
|
||||
-D WITH_SNMP=1
|
||||
build_src_filter = ${Station_G3_ESP32.build_src_filter}
|
||||
+<helpers/bridges/MQTTBridge.cpp>
|
||||
+<helpers/MQTTMessageBuilder.cpp>
|
||||
+<helpers/JWTHelper.cpp>
|
||||
+<helpers/ui/SH1106Display.cpp>
|
||||
+<helpers/SNMPAgent.cpp>
|
||||
+<../examples/simple_room_server>
|
||||
lib_deps =
|
||||
${Station_G3_ESP32.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
elims/PsychicMqttClient@^0.2.4
|
||||
bblanchon/ArduinoJson @ 7.4.3
|
||||
arduino-libraries/NTPClient
|
||||
JChristensen/Timezone
|
||||
paulstoffregen/Time@1.6.1
|
||||
0neblock/SNMP_Agent
|
||||
@@ -28,8 +28,9 @@ build_flags = ${esp32_base.build_flags}
|
||||
-D P_LORA_TX_LED=46 ; blue
|
||||
-D LR11X0_DIO_AS_RF_SWITCH=true
|
||||
-D LR11X0_DIO3_TCXO_VOLTAGE=1.8
|
||||
; No blanket +<helpers/*.cpp> here: it would undo arduino_base's exclusion of the
|
||||
; MQTT-only sources, which need Timezone/NTPClient that only observer envs pull in.
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<helpers/*.cpp>
|
||||
+<helpers/sensors/EnvironmentSensorManager.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../variants/thinknode_m7>
|
||||
@@ -167,7 +168,7 @@ build_src_filter = ${ThinkNode_M7.build_src_filter}
|
||||
${ThinkNode_M7_ethernet.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<helpers/ui/NullDisplayDriver.cpp>
|
||||
+<helpers/ui/NullDisplayDriver.cpp> ; defines UIColor::window_bkg for DISPLAY_CLASS
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-orig/*.cpp>
|
||||
lib_deps = ${ThinkNode_M7.lib_deps}
|
||||
@@ -178,3 +179,95 @@ lib_deps = ${ThinkNode_M7.lib_deps}
|
||||
extends = ThinkNode_M7
|
||||
build_src_filter = ${ThinkNode_M7.build_src_filter}
|
||||
+<../examples/kiss_modem/>
|
||||
|
||||
; MQTT observer envs are WiFi-only: the bridge's link management is bound to the
|
||||
; WiFi station API, so the onboard CH390 cannot carry MQTT yet. The board has
|
||||
; PSRAM, so MAX_NEIGHBOURS enables WITH_MQTT_NEIGHBORS (see MQTTBridge.h).
|
||||
[env:ThinkNode_M7_repeater_observer_mqtt]
|
||||
extends = ThinkNode_M7
|
||||
extra_scripts =
|
||||
${esp32_base.extra_scripts}
|
||||
pre:scripts/generate_cert_bundle.py
|
||||
board_ssl_cert_source = adafruit-full
|
||||
board_build.embed_files = src/certs/x509_crt_bundle.bin
|
||||
build_flags =
|
||||
${ThinkNode_M7.build_flags}
|
||||
-D ADVERT_NAME='"MQTT Observer"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
# -D MESH_PACKET_LOGGING=1
|
||||
# -D MESH_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
-D ESP32_CPU_FREQ=160
|
||||
-D WITH_SNMP=1
|
||||
# -D WIFI_SSID='"ssid"'
|
||||
# -D WIFI_PWD='"password"'
|
||||
# -D MQTT_SERVER='"your-mqtt-broker.com"'
|
||||
# -D MQTT_PORT=1883
|
||||
# -D MQTT_USERNAME='"your-username"'
|
||||
# -D MQTT_PASSWORD='"your-password"'
|
||||
build_src_filter = ${ThinkNode_M7.build_src_filter}
|
||||
+<helpers/bridges/MQTTBridge.cpp>
|
||||
+<helpers/MQTTMessageBuilder.cpp>
|
||||
+<helpers/JWTHelper.cpp>
|
||||
+<helpers/SNMPAgent.cpp>
|
||||
+<../examples/simple_repeater>
|
||||
lib_deps =
|
||||
${ThinkNode_M7.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
elims/PsychicMqttClient@^0.2.4
|
||||
bblanchon/ArduinoJson @ 7.4.3
|
||||
arduino-libraries/NTPClient
|
||||
JChristensen/Timezone
|
||||
paulstoffregen/Time@1.6.1
|
||||
0neblock/SNMP_Agent
|
||||
|
||||
[env:ThinkNode_M7_room_server_observer_mqtt]
|
||||
extends = ThinkNode_M7
|
||||
extra_scripts =
|
||||
${esp32_base.extra_scripts}
|
||||
pre:scripts/generate_cert_bundle.py
|
||||
board_ssl_cert_source = adafruit-full
|
||||
board_build.embed_files = src/certs/x509_crt_bundle.bin
|
||||
build_flags =
|
||||
${ThinkNode_M7.build_flags}
|
||||
-D ADVERT_NAME='"ThinkNode M7 Room Observer"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
# -D MESH_PACKET_LOGGING=1
|
||||
# -D MESH_DEBUG=1
|
||||
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
-D ESP32_CPU_FREQ=160
|
||||
-D WITH_SNMP=1
|
||||
# -D WIFI_SSID='"ssid"'
|
||||
# -D WIFI_PWD='"password"'
|
||||
# -D MQTT_SERVER='"your-mqtt-broker.com"'
|
||||
# -D MQTT_PORT=1883
|
||||
# -D MQTT_USERNAME='"your-username"'
|
||||
# -D MQTT_PASSWORD='"your-password"'
|
||||
build_src_filter = ${ThinkNode_M7.build_src_filter}
|
||||
+<helpers/bridges/MQTTBridge.cpp>
|
||||
+<helpers/MQTTMessageBuilder.cpp>
|
||||
+<helpers/JWTHelper.cpp>
|
||||
+<helpers/SNMPAgent.cpp>
|
||||
+<../examples/simple_room_server>
|
||||
lib_deps =
|
||||
${ThinkNode_M7.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
elims/PsychicMqttClient@^0.2.4
|
||||
bblanchon/ArduinoJson @ 7.4.3
|
||||
arduino-libraries/NTPClient
|
||||
JChristensen/Timezone
|
||||
paulstoffregen/Time@1.6.1
|
||||
0neblock/SNMP_Agent
|
||||
@@ -4,7 +4,9 @@ board = seeed_xiao_esp32s3
|
||||
board_check = true
|
||||
board_build.mcu = esp32s3
|
||||
build_flags = ${esp32_base.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/xiao_s3_wio
|
||||
-UENV_INCLUDE_GPS
|
||||
-D SEEED_XIAO_S3
|
||||
-D PIN_VBAT_READ=1 ; D0
|
||||
-D P_LORA_DIO_1=39
|
||||
@@ -16,6 +18,8 @@ build_flags = ${esp32_base.build_flags}
|
||||
-D P_LORA_MOSI=9
|
||||
-D PIN_USER_BTN=21
|
||||
-D PIN_STATUS_LED=48
|
||||
-D PIN_BOARD_SDA=D4
|
||||
-D PIN_BOARD_SCL=D5
|
||||
-D SX126X_RXEN=38
|
||||
-D SX126X_TXEN=RADIOLIB_NC
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=true
|
||||
@@ -29,6 +33,9 @@ build_flags = ${esp32_base.build_flags}
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/xiao_s3_wio>
|
||||
+<helpers/sensors>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
|
||||
[env:Xiao_S3_WIO_repeater]
|
||||
extends = Xiao_S3_WIO
|
||||
@@ -60,8 +67,9 @@ lib_deps =
|
||||
; -D ADMIN_PASSWORD='"password"'
|
||||
; -D MAX_NEIGHBOURS=50
|
||||
; -D WITH_RS232_BRIDGE=Serial2
|
||||
; -D WITH_RS232_BRIDGE_RX=5
|
||||
; -D WITH_RS232_BRIDGE_TX=6
|
||||
; RS232 bridge Pins have been relocated from 5,6 which is the i2c bus on xiao_s3
|
||||
; -D WITH_RS232_BRIDGE_RX=3
|
||||
; -D WITH_RS232_BRIDGE_TX=4
|
||||
; -D BRIDGE_DEBUG=1
|
||||
; ; -D MESH_PACKET_LOGGING=1
|
||||
; ; -D MESH_DEBUG=1
|
||||
@@ -104,7 +112,6 @@ build_flags =
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
-D MESH_PACKET_LOGGING=1
|
||||
@@ -148,7 +155,6 @@ build_flags =
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
-D WITH_MQTT_BRIDGE=1
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D MAX_MQTT_BROKERS=3
|
||||
-D MQTT_MAX_PACKET_SIZE=1024
|
||||
-D MQTT_DEBUG=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
|
||||
+961
-196
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user