Merge observer firmware and webconfig updates

# Conflicts:
#	MQTT_INTERNALS.md
#	build.sh
#	docs/cli_commands.md
#	examples/simple_repeater/MyMesh.cpp
#	examples/simple_repeater/MyMesh.h
#	examples/simple_repeater/UITask.cpp
#	examples/simple_room_server/MyMesh.cpp
#	examples/simple_room_server/MyMesh.h
#	examples/simple_room_server/UITask.cpp
#	examples/simple_sensor/SensorMesh.cpp
#	platformio.ini
#	scripts/generate_webconfig_html.py
#	src/MeshCore.h
#	src/helpers/CommonCLI.cpp
#	src/helpers/CommonCLI.h
#	src/helpers/CommonCLI_Observer.cpp
#	src/helpers/ESP32Board.h
#	src/helpers/MQTTMessageBuilder.cpp
#	src/helpers/MQTTPresets.h
#	src/helpers/NRF52Board.cpp
#	src/helpers/NRF52Board.h
#	src/helpers/bridges/MQTTBridge.cpp
#	src/helpers/bridges/MQTTBridge.h
#	src/helpers/esp32/WebConfigServer.cpp
#	src/helpers/esp32/WebConfigServer.h
#	src/helpers/radiolib/RadioLibWrappers.cpp
#	src/helpers/radiolib/RadioLibWrappers.h
#	variants/lilygo_tbeam_SX1262/platformio.ini
#	variants/lilygo_tbeam_SX1276/platformio.ini
#	variants/lilygo_tlora_v2_1/platformio.ini
#	webui/index.html
This commit is contained in:
mikecarper committed 2026-07-24 02:16:47 -07:00
commit c1caa5ad81
109 files changed
+14844 -2297

No files matched your search

@@ -0,0 +1,349 @@
name: Build MQTT Observer Firmwares (BETA channel)
permissions:
contents: write
# Push-triggered on the dev line, mirroring build-observer-firmwares.yml.
#
# This is NOT the original design -- dispatch-only was, so that publishing to real
# nodes stayed an explicit act. That does not work in this repo: this fork's
# default branch is `dev` (an upstream mirror that carries none of the observer
# workflows), and GitHub only surfaces `workflow_dispatch` for workflows present
# on the DEFAULT branch. A dispatch-only workflow here would never appear in the
# Actions UI. Adding fork-specific workflows to `dev` would pollute the upstream
# mirror and conflict on every upstream sync, so the push trigger is the correct
# mechanism -- the same one production already relies on.
#
# workflow_dispatch is kept as well: harmless now, and it starts working if the
# default branch ever changes.
#
# Consequence to be aware of: every push to `observer-firmware-dev` publishes a
# dev-channel build. That is defensible for a channel users opt into, but if you
# want staging commits without publishing, work on a side branch and fast-forward
# `observer-firmware-dev` when you intend to release.
on:
workflow_dispatch:
push:
branches:
- observer-firmware-dev
# Same rationale as production: docs/CI-only changes do not alter binaries.
paths-ignore:
- '**.md'
- 'docs/**'
- 'scripts/gen_changelog.py'
- '.github/**'
- '.gitignore'
- '.gitattributes'
- '.editorconfig'
- 'LICENSE'
- '.vscode/**'
- '.claude/**'
# Shared with build-observer-firmwares.yml and sync-flasher-content.yml so the
# workflows never push to the flasher repo at the same time.
concurrency:
group: flasher-publish
cancel-in-progress: false
env:
# MUST stay equal to the production channel's FIRMWARE_VERSION. The observer's
# 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
# 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
# its tag, so sharing production's tag would make each channel delete the
# other's assets.
RELEASE_TAG: observer-mqtt-beta-latest
# The channel itself. Firmware fetches <OTA_MANIFEST_BASE>/<OTA_VARIANT>.json,
# so this URL is what keeps beta nodes on beta.
OTA_MANIFEST_BASE_URL: https://observer.gessaman.com/beta/v
# Marks the embedded version, e.g. v1.16.0.3-observer-beta-dev-abc1234, so `ver`
# (and the MQTT firmware_version / SNMP) identify BOTH the channel and its
# provenance: this channel is built from the upstream-dev-merged line, so "dev"
# is carried in the string rather than left to be inferred from the branch name.
# Does not affect OTA version parsing: ota_parseVersion() reads to the first '-'
# and ota_extractHash() takes the token after the last, so tags in between are
# transparent.
OTA_CHANNEL_TAG: beta-dev
# Marks the asset *filenames*, e.g. <env>-v1.16.0-dev-abc1234.bin, so a
# downloaded file identifies its channel at a glance. Lowercase letters only --
# the filename parsers (gen-slim ASSET_RE, the /releases Worker, flasher.js)
# accept exactly (?:-[a-z]+)? between version and hash.
FILENAME_CHANNEL_TAG: "-dev"
# Beta's own build counter, so the two channels' build numbers never interleave.
COUNTER_URL: https://observer.gessaman.com/observer-beta-build-counter.json
COUNTER_FILE: observer-beta-build-counter.json
# Where beta artifacts live in the flasher repo. MANIFEST_DIR must correspond to
# OTA_MANIFEST_BASE_URL's path, and STATIC_PATH must be a host/route serving the
# beta GitHub release (see cloudflare-worker).
MANIFEST_DIR: beta/v
STATIC_PATH: https://observer-fw-beta.gessaman.com
jobs:
enumerate:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.split.outputs.matrix }}
build_number: ${{ steps.buildnum.outputs.n }}
steps:
- name: Clone Repo
uses: actions/checkout@v4
- name: Split observer envs into shards
id: split
shell: bash
run: |
SHARDS=14
ENVS=$(grep -rhoE '^\[env:[^]]*observer_mqtt\]' platformio.ini variants/*/platformio.ini \
| sed -E 's/^\[env:(.*)\]$/\1/' | sort -u)
echo "Discovered envs:"; echo "$ENVS"
MATRIX=$(echo "$ENVS" | awk -v n="$SHARDS" '
{ shard[NR % n] = shard[NR % n] " " $0 }
END { for (i = 0; i < n; i++) { sub(/^ /, "", shard[i]);
printf "{\"idx\":%d,\"envs\":\"%s\"}\n", i, shard[i] } }' \
| jq -cs .)
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
- name: Compute beta build number
id: buildnum
shell: bash
run: |
# Same scheme as production but off the BETA counter, so the channels
# increment independently.
CUR=$(curl -fsSL "$COUNTER_URL" 2>/dev/null || echo '{}')
PREV_BASE=$(echo "$CUR" | jq -r '.baseVersion // ""')
PREV_BUILD=$(echo "$CUR" | jq -r '.build // 0')
if [ "$PREV_BASE" = "$FIRMWARE_VERSION" ]; then
N=$((PREV_BUILD + 1))
else
N=1
fi
echo "Base $FIRMWARE_VERSION; previous beta build $PREV_BUILD (base $PREV_BASE) -> N=$N"
echo "n=$N" >> "$GITHUB_OUTPUT"
build:
needs: enumerate
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(needs.enumerate.outputs.matrix) }}
steps:
- name: Clone Repo
uses: actions/checkout@v4
- name: Cache PlatformIO Toolchains
uses: actions/cache@v4
with:
path: |
~/.platformio/packages
~/.platformio/platforms
key: pio-toolchains-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
restore-keys: |
pio-toolchains-${{ runner.os }}-
- name: Setup Build Environment
uses: ./.github/actions/setup-build-environment
- name: Build Shard ${{ matrix.shard.idx }}
env:
FIRMWARE_BUILD_NUMBER: ${{ needs.enumerate.outputs.build_number }}
# OTA_MANIFEST_BASE_URL and OTA_CHANNEL_TAG (what actually make this a
# beta build) come from the workflow-level env: above, which every step
# inherits. Do NOT redeclare them as ${{ env.X }} here -- that is a
# self-reference, and if it resolved empty it would blank the channel.
run: /usr/bin/env bash build.sh build-firmware ${{ matrix.shard.envs }}
- name: Verify beta channel is baked in
shell: bash
run: |
# Fail fast rather than publish firmware that would OTA itself onto the
# production channel. Checks one built binary actually carries the beta
# manifest URL and does NOT carry the production one.
BIN=$(find .pio/build -name firmware.elf | head -1)
if [ -z "$BIN" ]; then echo "no ELF found to verify" >&2; exit 1; fi
if ! strings "$BIN" | grep -qF "$OTA_MANIFEST_BASE_URL"; then
echo "ERROR: beta manifest base missing from $BIN" >&2; exit 1
fi
if strings "$BIN" | grep -qE 'https://observer\.gessaman\.com/v"?$'; then
echo "ERROR: production manifest base present in a beta build" >&2; exit 1
fi
echo "OK: $BIN carries $OTA_MANIFEST_BASE_URL"
- name: Upload Shard Artifact
uses: actions/upload-artifact@v4
with:
name: fw-${{ matrix.shard.idx }}
path: out
if-no-files-found: error
release:
needs: [enumerate, build]
runs-on: ubuntu-latest
steps:
- name: Clone Repo
uses: actions/checkout@v4
# Shallow on purpose -- see the production workflow: `git rev-parse --short`
# must produce the same abbreviation build.sh used for the asset filenames.
- name: Download All Shard Artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Flatten into out/
run: |
mkdir -p out
find artifacts -type f -name '*.bin' -exec cp -f {} out/ \;
find artifacts -type f -name '*.partsig' -exec cp -f {} out/ \;
echo "Collected binaries:"; ls -1 out
- name: Compute Short SHA
id: sha
run: echo "short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Publish to Beta Rolling Release
env:
GH_TOKEN: ${{ github.token }}
run: |
# Retry wrapper for GitHub API calls. This job runs AFTER ~15 minutes of
# building across 14 runners, and every call below is an API write; with
# `bash -e`, a single transient 5xx throws all of that away. Observed
# 2026-07-19: `gh release create` got HTTP 503 during a GitHub incident
# and killed a run whose builds had all passed.
# `until` in a condition does not trip `-e`, so this is safe here.
gh_retry() {
local n=0 max=5 delay=10
until "$@"; do
n=$((n + 1))
if [ "$n" -ge "$max" ]; then
echo "::error::gh failed after $max attempts: $*" >&2
return 1
fi
echo "gh call failed (attempt $n/$max), retrying in ${delay}s: $*" >&2
sleep "$delay"
delay=$((delay * 2))
done
}
# Deliberately NOT retried: a plain "release does not exist" is the
# expected answer on the first run, and retrying it would just burn the
# backoff. A 5xx here instead makes us fall through to create, which is
# then tolerated below if the release actually did already exist.
if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
gh_retry gh release create "$RELEASE_TAG" --prerelease \
--title "MQTT Observer Firmwares (BETA)" \
--notes "Rolling BETA build. Separate channel from observer-mqtt-latest; beta nodes only OTA within this channel." \
|| gh release view "$RELEASE_TAG" >/dev/null 2>&1 \
|| { echo "::error::could not create or confirm $RELEASE_TAG" >&2; exit 1; }
fi
gh_retry 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, with the
# dev-channel warning prepended: the /releases feed serves the body as
# this channel's dropdown changelog. Non-fatal -- stale notes beat a
# red build whose binaries are already live.
{
printf '%s' '<p><b>&#9888; DEV/BETA CHANNEL:</b> pre-release firmware. A node flashed from this channel keeps receiving OTA updates from this channel until re-flashed by cable.</p>'
cat firmware-notes.html
} > /tmp/beta-notes.html
gh_retry gh release edit "$RELEASE_TAG" --notes-file /tmp/beta-notes.html \
|| echo "WARNING: release notes sync failed" >&2
# Pruning is housekeeping and runs AFTER the upload has succeeded. If the
# API is flaky here, skip it rather than fail the job -- old assets simply
# linger until the next run, which is strictly better than reporting
# failure for a build whose binaries are already published.
KEEP_BUILDS=2
if ! asset_list=$(gh_retry gh release view "$RELEASE_TAG" --json assets \
-q '.assets[] | "\(.createdAt) \(.name)"'); then
echo "::warning::could not list assets; skipping prune this run"
exit 0
fi
keep_hashes=$(printf '%s\n' "$asset_list" \
| sort -r \
| while read -r _ts name; do
printf '%s' "$name" | grep -oiE '[0-9a-f]{7,40}(-merged)?\.bin$' | grep -oiE '^[0-9a-f]{7,40}'
done \
| awk '!seen[$0]++' | head -n "$KEEP_BUILDS")
echo "Retaining build hashes:"; echo "$keep_hashes"
# Reuse asset_list rather than making a second API call (its lines are
# "<createdAt> <name>", so the name is field 2).
printf '%s\n' "$asset_list" | awk '{print $2}' \
| while read -r asset; do
ah=$(printf '%s' "$asset" | grep -oiE '[0-9a-f]{7,40}(-merged)?\.bin$' | grep -oiE '^[0-9a-f]{7,40}' || true)
if [ -n "$ah" ] && grep -qxF "$ah" <<<"$keep_hashes"; then
continue
fi
gh release delete-asset "$RELEASE_TAG" "$asset" --yes || true
done
- name: Checkout Flasher Repo
uses: actions/checkout@v4
with:
repository: agessaman/flasher.meshcore.io
token: ${{ secrets.FLASHER_DISPATCH_TOKEN }}
path: flasher
- name: Generate Beta Manifests
env:
BUILD_NUMBER: ${{ needs.enumerate.outputs.build_number }}
run: |
# config-beta.json is no longer derived here: the flasher's Version
# dropdown is feed-driven (/releases on the firmware-proxy Worker
# lists both channels), so the beta channel needs no config of its
# own and this workflow's flasher commit touches only beta/v/ and the
# counter.
mkdir -p "flasher/$MANIFEST_DIR"
# Slim manifests come from the build output in out/ (the assets
# actually uploaded to the release), not from config-beta.json -- see
# gen-slim-manifests.py's --bin-dir mode (flasher repo PR #1).
python3 flasher/scripts/gen-slim-manifests.py \
--bin-dir out \
--static-path "$STATIC_PATH" \
--out-dir "flasher/$MANIFEST_DIR" \
--base-version "$FIRMWARE_VERSION" \
--build "$BUILD_NUMBER" \
--partsig-dir out
printf '{\n "baseVersion": "%s",\n "build": %s\n}\n' \
"$FIRMWARE_VERSION" "$BUILD_NUMBER" > "flasher/$COUNTER_FILE"
echo "Beta build $FIRMWARE_VERSION.$BUILD_NUMBER"
- name: Verify beta manifests point at the beta channel
run: |
# Guards against a beta manifest handing out a production download URL.
SAMPLE=$(find "flasher/$MANIFEST_DIR" -name '*.json' | head -1)
echo "sample: $SAMPLE"; cat "$SAMPLE"
if ! grep -qF "$STATIC_PATH" "$SAMPLE"; then
echo "ERROR: beta manifest does not use $STATIC_PATH" >&2; exit 1
fi
# NOTE: production's "Generate Changelog" and "Sync Docs into Flasher" steps
# are deliberately omitted. Those rewrite site-wide content (CHANGELOG.md,
# MQTT_IMPLEMENTATION.md, ...) that the production channel owns; a beta build
# must not overwrite them.
- name: Commit & Push Beta Artifacts
working-directory: flasher
run: |
# Scoped add: beta only ever touches its own manifest dir and counter,
# so a stray edit elsewhere in the flasher checkout (in particular
# production's config.json) can never be published by this workflow.
git add -A "$MANIFEST_DIR" "$COUNTER_FILE"
if git diff --cached --quiet; then
echo "No beta changes to commit."
exit 0
fi
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
@@ -0,0 +1,90 @@
name: Check MQTT Preset Name Parity
# Ensures observer-firmware and observer-firmware-dev share the same built-in
# MQTT preset *names* (config details may differ). See scripts/check_mqtt_preset_parity.py.
permissions:
contents: read
on:
workflow_dispatch:
pull_request:
branches:
- observer-firmware
- observer-firmware-dev
paths:
- 'src/helpers/MQTTPresets.h'
- 'scripts/check_mqtt_preset_parity.py'
- '.github/workflows/check-mqtt-preset-parity.yml'
push:
branches:
- observer-firmware
- observer-firmware-dev
paths:
- 'src/helpers/MQTTPresets.h'
- 'scripts/check_mqtt_preset_parity.py'
- '.github/workflows/check-mqtt-preset-parity.yml'
jobs:
parity:
runs-on: ubuntu-latest
steps:
- name: Clone Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch channel branches
run: |
git fetch --no-tags origin observer-firmware observer-firmware-dev
- name: Materialize presets from both channels
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.ref }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
PRESET_PATH=src/helpers/MQTTPresets.h
mkdir -p /tmp/preset-parity
if [ "$EVENT_NAME" = "pull_request" ]; then
# Ensure the PR head commit is reachable (merge checkout may not keep it).
git fetch --no-tags origin "$PR_HEAD_SHA"
# Proposed state of the PR's base channel vs current tip of the sibling.
git show "${PR_HEAD_SHA}:${PRESET_PATH}" > /tmp/preset-parity/pr-head.h
case "$PR_BASE" in
observer-firmware-dev)
cp /tmp/preset-parity/pr-head.h /tmp/preset-parity/dev.h
git show "origin/observer-firmware:${PRESET_PATH}" > /tmp/preset-parity/prod.h
;;
observer-firmware)
git show "origin/observer-firmware-dev:${PRESET_PATH}" > /tmp/preset-parity/dev.h
cp /tmp/preset-parity/pr-head.h /tmp/preset-parity/prod.h
;;
*)
echo "::error::unexpected PR base branch: $PR_BASE" >&2
exit 1
;;
esac
else
# push / workflow_dispatch: compare current channel tips.
git show "origin/observer-firmware:${PRESET_PATH}" > /tmp/preset-parity/prod.h
git show "origin/observer-firmware-dev:${PRESET_PATH}" > /tmp/preset-parity/dev.h
fi
echo "=== production (observer-firmware) head preview ==="
head -n 5 /tmp/preset-parity/prod.h
echo "=== dev (observer-firmware-dev) head preview ==="
head -n 5 /tmp/preset-parity/dev.h
- name: Self-test checker
run: python3 scripts/check_mqtt_preset_parity.py --self-test
- name: Compare preset names
run: |
python3 scripts/check_mqtt_preset_parity.py \
/tmp/preset-parity/prod.h \
/tmp/preset-parity/dev.h \
--label-a observer-firmware \
--label-b observer-firmware-dev
+21
View File
@@ -5,17 +5,35 @@ on:
branches: [main, dev]
paths:
- 'src/**'
- 'include/**'
- 'lib/**'
- 'examples/**'
- 'variants/**'
- 'boards/**'
- 'arch/**'
- 'scripts/**'
- 'ssl_certs/**'
- 'webui/**'
- 'default_8MB.csv'
- 'platformio.ini'
- '.github/actions/setup-build-environment/**'
- '.github/workflows/pr-build-check.yml'
push:
branches: [main, dev]
paths:
- 'src/**'
- 'include/**'
- 'lib/**'
- 'examples/**'
- 'variants/**'
- 'boards/**'
- 'arch/**'
- 'scripts/**'
- 'ssl_certs/**'
- 'webui/**'
- 'default_8MB.csv'
- 'platformio.ini'
- '.github/actions/setup-build-environment/**'
- '.github/workflows/pr-build-check.yml'
jobs:
@@ -29,6 +47,9 @@ jobs:
- Heltec_v3_companion_radio_ble
- Heltec_v3_repeater
- Heltec_v3_room_server
# MQTT observer smoke builds: internal RAM and PSRAM coverage.
- Heltec_v3_repeater_observer_mqtt
- T_Beam_S3_Supreme_SX1262_repeater_observer_mqtt
# nRF52
- RAK_4631_companion_radio_ble
- RAK_4631_companion_radio_ethernet
+5
View File
@@ -19,6 +19,11 @@ jobs:
- name: Setup Build Environment
uses: ./.github/actions/setup-build-environment
- name: Verify ArduinoJson pin
run: |
python3 -B scripts/check_arduinojson_pin.py --self-test
python3 -B scripts/check_arduinojson_pin.py
- name: Run Unit Tests
run: pio test -e native -e native_kiss_modem -vv
+2
View File
@@ -1,5 +1,6 @@
.direnv
.pio
.build-wt-*
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
@@ -21,6 +22,7 @@ compile_commands.json
venv/
# Script-generated cert bundles (see scripts/generate_cert_bundle.py)
src/certs/x509_crt_bundle.bin
src/helpers/esp32/WebConfigHtml.h
ssl_certs/cacert.pem
platformio.local.ini
.cursor/*
+11 -1
View File
@@ -31,6 +31,16 @@ Alert floods ride the **repeater's default scope** by default (the same Transpor
A "recovered" message is sent once when the underlying connection comes back. After firing, a fault is rate-limited by `alert.interval` (default 60 minutes) before it can re-fire - this prevents flapping links from spamming the channel.
## OTA milestone alerts
Key `ota update` milestones are also broadcast on the alert channel (in addition to the Serial log), so an operator who triggered the update via remote management still gets feedback: the real OTA work runs ~2.5 s after the command and reboots on success, both **outside** the command's reply window.
- **Start** - `OTA update starting`, sent when the update is confirmed and scheduled (during the reply window, so it transmits before the flash blocks the loop).
- **Fail/abort** - `OTA aborted: MQTT stop unclean, bridge resumed` (the teardown barrier withheld flashing) or `OTA aborted: <reason>` (preflight/download error). The bridge is resumed either way.
- **Success** has no dedicated message: a successful flash reboots into the new image, so the node reappearing on the new version is the success signal.
These share the alert channel, scope (`alert.region`), and the `alert` master switch with fault alerts - if `alert` is `off` or no channel is configured, OTA milestones are silent. Unlike fault alerts they are **not** rate-limited (OTA is operator-initiated and rare), and they are limited to these start/fail milestones - routine MQTT slot connect/disconnect never triggers an OTA alert.
## Defaults
| Setting | Default | Notes |
@@ -67,7 +77,7 @@ Set:
- `set alert.interval <minutes>` (60-10080; 60-minute floor to protect mesh airtime)
Action:
- `alert test` - send a one-off `[test] alert channel ok` immediately on the configured channel; ignores `alert on/off` so operators can verify the channel before enabling fault firing. Returns an error if no channel is configured.
- `alert test` - send a one-off `[test] alert channel ok` immediately on the configured channel; ignores `alert on/off` so operators can verify the channel before enabling fault firing. Returns an error if no channel is configured. If the send succeeds but the `alert` master switch is still `off`, the reply says so (`OK - test sent, but automatic alerts are OFF`) - a working test alone does **not** mean automatic WiFi/MQTT/OTA alerts will fire.
- `alert test <message>` - send a custom test message: `[test] <message>`.
## Example: dedicated hashtag channel (recommended for operator groups)
+118
View File
@@ -202,6 +202,12 @@ pio run -e Heltec_v3_repeater_observer_mqtt
# Heltec V4
pio run -e heltec_v4_repeater_observer_mqtt
# Heltec Wireless Tracker v1.1 / v2
pio run -e heltec_tracker_v1_1_repeater_observer_mqtt
pio run -e heltec_tracker_v1_1_room_server_observer_mqtt
pio run -e heltec_tracker_v2_repeater_observer_mqtt
pio run -e heltec_tracker_v2_room_server_observer_mqtt
# Station G2
pio run -e Station_G2_repeater_observer_mqtt
@@ -434,6 +440,8 @@ These settings apply across all MQTT slots:
- `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.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)
@@ -451,6 +459,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.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
@@ -526,6 +536,86 @@ These are standard MeshCore commands, not MQTT-specific, but important for obser
See [MQTT_SNMP.md](MQTT_SNMP.md) for full SNMP documentation.
### Web Configuration Portal
The observer builds include a browser-based configuration portal so a node can
be provisioned and managed without the serial CLI. It is started from the CLI
(serial or remote admin) and is never on by default on a configured node.
#### CLI commands
- `start webconfig` -- start the portal. If WiFi is already configured and
connected, it binds to the node's **LAN** IP and requires the admin password
to log in. If WiFi is **not** configured (`wifi.ssid` empty), it raises the
setup AP instead (same as first boot).
- `start webconfig ap` -- force the **setup AP** even when WiFi is configured.
The MQTT bridge must be stopped first (`set bridge off`); the AP owns the
radio. Used for re-provisioning in the field.
- `stop webconfig` -- stop the portal and free its resources. LAN mode runs until
this is issued; the setup AP also auto-stops after an idle timeout (default 10
minutes with no station associated).
#### First-boot / setup-AP behavior
On a node with no WiFi configured, the portal comes up automatically as an open
SoftAP named `MeshCore-Setup-XXXX` (last two bytes of the public key), with a
captive-portal redirect. The device display shows the AP name and portal URL
(`http://192.168.4.1/`). Walk through the wizard (WiFi -> radio -> MQTT -> review),
then **Save & reboot**; the node reboots and joins the configured network.
Optionally set a WPA2 password for the setup AP at build time with
`-D WEBCONFIG_AP_PASSWORD='"yourpassword"'`.
#### Modes and authentication
- **Setup AP**: unauthenticated. Trust is based on physical proximity to the
open/PSK AP. Only the SoftAP interface serves the API -- on `start webconfig
ap` the STA is explicitly disassociated so the API is **not** exposed on the
LAN the node was attached to.
- **LAN**: requires the admin password (same one used for remote CLI admin).
Sessions use a cookie with a sliding idle expiry (default 20 minutes); five
failed logins trigger a 30-second lockout.
> **Security note:** the open setup AP transports WiFi/MQTT credentials over
> plain HTTP. Provision on a trusted, non-public frequency/location, set
> `WEBCONFIG_AP_PASSWORD` where feasible, and prefer LAN mode for ongoing
> management. The setup AP is intended for initial provisioning, not
> long-running operation.
#### Applying changes
- **Radio** (freq/BW/SF/CR): persisted but applied only on reboot; the UI shows
a "reboot to apply" hint.
- **WiFi SSID/password**: changing these in LAN mode saves and reboots so the
node reconnects on the new network (the page will drop; find the new IP on
your router). In the setup wizard, saving always reboots.
- **MQTT publishing toggles / slot config**: applied live to the running bridge
(no reboot needed).
- **NTP server**: saved immediately; the time sync runs in the background --
verify with `get mqtt.ntp.diag`.
#### Recovery
If provisioning fails or you're locked out of the portal, connect over USB
serial and use the CLI directly (e.g. `set wifi.ssid ...`, `set wifi.pwd ...`,
`get wifi.status`, `stop webconfig`). Serial access always works regardless of
the portal state.
### Local testing without hardware
Two ways to iterate on observer/WiFi functionality without flashing a device:
- **Portal UI** -- run the mock backend and open the real portal in a browser:
`python3 scripts/webconfig_mock_server.py` (add `--setup` for the first-boot
wizard), then browse to `http://localhost:8080/`. It serves `webui/index.html`
and mirrors the firmware's `/api/*` contract (reqid handshake, reboot gating,
validation, secret masking), so the portal JS runs against realistic
responses. Stdlib only; no account.
- **Boot / WiFi / MQTT / CLI / OLED** -- the Wokwi ESP32-S3 sim. Build
`pio run -e Heltec_v3_repeater_observer_mqtt_sim -t mergebin` (LoRa radio
stubbed via `SimRadio`, WiFi pre-seeded to `Wokwi-GUEST`), then run the sim
from `wokwi.toml`/`diagram.json` (VS Code Wokwi extension or `wokwi-cli`).
Outbound MQTT works on the free gateway; incoming (browser -> on-device portal)
needs Wokwi's paid Private Gateway -- use the mock backend above for portal UI.
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:
@@ -556,6 +646,12 @@ Full packet data with RF characteristics and metadata.
### Raw Topic: `meshcore/{IATA}/{DEVICE_PUBLIC_KEY}/raw`
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).
**Note**: `{DEVICE_PUBLIC_KEY}` is the device's public key in hexadecimal format (64 characters).
## JSON Message Formats
@@ -634,6 +730,28 @@ Minimal raw packet data for map integration.
}
```
### Neighbors Message
```json
{
"timestamp": "2024-01-01T12:00:00.000000+00:00",
"origin": "MeshCore-HOWL",
"origin_id": "A1B2C3D4E5F67890...",
"self": { "scopes": "DEN,APRS" },
"neighbors": [
{
"pubkey": "0011223344556677...",
"snr": 9.75,
"heard_secs_ago": 42,
"scopes": "DEN,APRS",
"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.
## Key Features
### Slot-Based Preset System
+33 -1
View File
@@ -57,6 +57,37 @@ scheduled time are expired at dequeue, so under throttle the queue holds only fr
traffic and admin responses reach the trickle of TX budget. Non-observer builds keep
the upstream pool behavior.
### Neighbors publication path (PSRAM only)
Periodic neighbors publishing is gated on `WITH_MQTT_NEIGHBORS`
(`defined(BOARD_HAS_PSRAM) && defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0`,
defined in `MQTTBridge.h`). It spans two subsystems and two cores:
- **Mesh side (Core 1), `MyMesh`**: the `loop()` runs a two-stage refresh driven by
`mqtt_neighbors_interval`. Stage 1 sends a zero-hop `sendNodeDiscoverReq()` and waits
out its 60 s collection window to refresh `neighbours[]`. Stage 2
(`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
`_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).
- **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.
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.
### `/mqtt_prefs` file format
`/mqtt_prefs` is written with an 8-byte `MQTTPrefsHeader` (`magic`, `version`,
@@ -81,7 +112,8 @@ default; a newer, longer one is truncated harmlessly.
- **`/mqtt_prefs`** - if the file has the version header it is read directly. Otherwise
it is a legacy headerless file and its layout is detected by size: pre-slot
(`OldMQTTPrefs`), 3-slot (`ThreeSlotMQTTPrefs`), or the 6-slot layout shipped on
`mqtt-bridge-implementation-flex` (`Legacy6SlotMQTTPrefs`). Each is field-copied into
`observer-firmware` back when it was named `mqtt-bridge-implementation-flex`
(`Legacy6SlotMQTTPrefs`). Each is field-copied into
the current compact `MQTTPrefs` and re-saved with the version header - which also
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
+185
View File
@@ -0,0 +1,185 @@
# MQTT Bridge Cross-Core Ownership Model
This document is the Phase 4 deliverable from `STABILITY_TESTABILITY_HANDOFF.md`:
it records **one owner for each mutable runtime domain**, maps every place a
non-owner reads owned state across cores today, and states the target primitive
for each. It is paired with the fork-owned lifecycle test seam
(`src/helpers/MQTTLifecycle.h`, `test/test_mqtt_lifecycle/`).
**Status:** ownership model documented and the lifecycle/teardown test seam
landed (Phase 4). **Phase 5 (branch `phase5/cooperative-mqtt-shutdown`) has now
implemented the cooperative shutdown and the OTA barrier -- hazard Section 4 below.**
Still **deferred** (carried to Phase 5b / Phase 6): publishing a plain-data
snapshot and repointing the Section 1/Section 2 consumers, and replacing the Section 3 `volatile`
handshakes with a command channel. This document is the plan; Section 1-Section 3 still
describe current behavior, while Section 4 is now resolved (see its note). Line
references are against the tree at the time of writing and should be re-verified
before editing.
## Execution contexts
- **MQTT task -- Core 0** (`"MQTTBridge"`, `xTaskCreatePinnedToCore(..., MQTT_TASK_CORE=0)`,
entry `mqttTask` -> `mqttTaskLoop`, `MQTTBridge.cpp:923`, `:991`). Owns all
WiFi / MQTT / NTP I/O and every mutation of `_slots[]`, connection state, and
NTP state.
- **Loop task -- Core 1** (`MyMesh::loop()`). Runs the CLI, WebConfig `tick()`,
and `AlertReporter::onLoop()`. `examples/simple_repeater/MyMesh.cpp:1529`
notes the bridge loop is *not* called here -- it lives on Core 0.
- **Producer / radio context.** Stages raw radio bytes before queue handoff
(`storeRawRadioData` -> `_staged_*`, consumed by `queuePacket`; both Core 1, in
guaranteed sequence, `MQTTBridge.h:232-239`).
- **Async TCP context.** WebConfig request parsing and immutable response
handoff only.
## Ownership model (one owner per mutable domain)
| Domain | Owner | Notes |
|--------|-------|-------|
| MQTT clients, slot connection state, publish counters, packet drain, NTP I/O | **MQTT task (Core 0)** | `_slots[]`, `PsychicMqttClient`s, `_ntp_client`, `_last_raw_*` |
| CLI execution, preference persistence, WebConfig batch draining, bridge lifecycle requests | **Loop task (Core 1)** | issues start/stop/reconfigure, drains the WebConfig batch |
| Packet staging before queue handoff | **Producer / radio (Core 1)** | `_staged_*`, no lock needed (sequential) |
| Request parse + immutable response | **Async TCP** | must not touch mutable bridge state |
The rule that follows: **loop/WebConfig/CLI/AlertReporter code must not directly
inspect mutable MQTT slot objects or client counters.** The MQTT task must
publish a plain-data snapshot they can read instead.
## Current cross-core hazards (to resolve in Phase 5)
All of the following run on **Core 1** and read state mutated by **Core 0**
without a lock or a published snapshot.
### 1. Diagnostic reads of live `_slots[]` via the singleton
Four `static` accessors reach the live object through the file-scope
`s_mqtt_bridge_instance` (`MQTTBridge.cpp:201`, set at `begin()` end `:849`,
nulled first in `end()` `:858` -- a plain, non-atomic pointer):
- `getSlotStatusSnapshot` (`:299`) -- despite its name, built on demand from
live `_slots[slot_index]`. **Refined premise:** the returned `name`/`state`
`const char*`s point at static rodata (string literals / `MQTT_PRESETS[]`),
so they are *not* dangling. The real hazards are (a) reading the
`slot.preset` **pointer value**, which Core 0 can null/reassign mid-read
(`applySlotPreset`, `teardownSlot`), and (b) `slot.client->getPublishOk()`,
a live client pointer Core 0 can `delete` during teardown (UAF window).
- `formatMqttStatusReply` (`:207`), `formatMqttStatsReply` (`:263`),
`formatSlotDiagReply` (`:397`) -- same singleton + live `_slots[]` reads.
Consumers: CLI (`CommonCLI_Observer.cpp:726, :728, :791`) and the app-layer
stats JSON (`examples/simple_repeater/MyMesh.cpp:1423`, mirrored in
`examples/simple_room_server/MyMesh.cpp:1050`). **Refined premise:**
`WebConfigServer.cpp` itself reads only a compile-time constant
(`getMaxActiveSlots`, `:479`); the live-bridge web reads are in `buildStatsJson`
in the `MyMesh.cpp` app layer.
### 2. Instance reads that survive `end()`
`AlertReporter` (Core 1, `onLoop`, `AlertReporter.cpp:208`) reads live `_slots[]`
via instance methods it reaches through its own `_bridge` pointer
(`AlertReporter.cpp:281` `isSlotEnabledAndAttempted`, `:285`
`getSlotCurrentOutageStartMs`, `:296`/`:311` `getSlotPresetName`). The stats
JSON also calls `bridge->getQueueSize()` (`MyMesh.cpp:1419`).
**Refined premise (teardown hazard):** `end()` nulls only
`s_mqtt_bridge_instance`. The app's `bridge` pointer and `AlertReporter::_bridge`
are **not** cleared by `end()`, so these instance reads can touch a torn-down
bridge. (`getConnectedBrokers()` at `MQTTBridge.cpp:3676` is defined but has
zero consumers.)
### 3. `volatile` cross-core handshakes
Plain `volatile` flags (no atomics/barriers), Core 1 sets the request, Core 0
clears/processes and writes a "done" flag last, Core 1 spins:
- `_slot_reconfigure_pending[]` -- set `MQTTBridge.cpp:2057` (Core 1), read/clear
`:1118` (Core 0).
- `_ntp_force_{requested,done,result}` -- request `:3298`, process `:1063-1068`,
spin-wait `:3308-3315`.
- `_ntp_diag_{requested,done}` (+ non-volatile `_ntp_diag_results[]`) -- request
`:3344`, process `:1073-1076`, spin-wait `:3347`.
The two blocking waiters (`requestForcedNtpSync` `:3298`, `ntpDiag` `:3344`) spin
on Core 1 while `end()` could tear down the singleton/task concurrently.
### 4. Abrupt teardown, no start guard -- RESOLVED in Phase 5
Original hazard (retained for context): `end()` used
`vTaskDelete(_mqtt_task_handle)` to kill the task wherever it was (possibly
mid-`_slots[]` mutation or inside mbedTLS), then ran slot/client cleanup *after*
deletion on the caller's context -- the OTA teardown heap-panic path. `begin()`
had no double-call guard, and lifecycle state was a single `_initialized` bool.
**Phase 5 resolution** (branch `phase5/cooperative-mqtt-shutdown`):
- `end()` now requests a cooperative stop through `MQTTLifecycle::Coordinator`.
The MQTT task (Core 0) tears down its own clients where the mbedTLS contexts
live, acknowledges via `_stop_acked`, and self-terminates; `end()` waits for
the ack before freeing the queue/buffers. The blind `vTaskDelete` survives
only as the bounded-timeout fallback, which sets a dirty latch that withholds
OTA flashing (`canFlashAfterStop()`).
- `begin()` has an idempotent double-call guard and drives the Coordinator to
`Running`; the lifecycle state now lives in the tested state machine, not a
bare bool.
- The OTA barrier gates `simple_repeater`'s deferred flash on a clean stop.
Not hardware-validated yet (Phase 7); `MQTT_STOP_TIMEOUT_MS` is a Phase-0
placeholder. Note the residual Section 1/Section 2 instance-pointer reads remain deferred, so
consumers can still (as before) touch a torn-down bridge -- that is unchanged by
Phase 5 and tracked above.
## Target primitives (Phase 5)
- **Task notifications or a command queue** for one-way lifecycle / reconfigure
/ NTP requests -- replacing every `volatile` flag in Section 3.
- **Immutable published snapshots** for WebConfig, CLI diagnostics, and alerting
-- the MQTT task publishes a plain-data `SlotStatusSnapshot` (owned char
buffers + scalars, no live pointers) that Section 1/Section 2 consumers read. This also
removes the "instance pointer survives `end()`" hazard because consumers stop
dereferencing the live bridge.
- **Atomics** only for truly independent scalar state.
- **A mutex** only where ownership transfer or snapshot publication cannot
express the operation cleanly.
## Lifecycle contract (the test seam)
`src/helpers/MQTTLifecycle.h` encodes the cooperative lifecycle Phase 5 must
implement, as a pure state machine plus a narrow injected `Ops` seam
(clock / task control / resource owner / OTA barrier). The invariants proven by
`test/test_mqtt_lifecycle/` -- and that Phase 5's production wiring must preserve:
- `Stopped -> Starting -> Running -> StopRequested -> Stopping -> Stopped`.
- Idempotent start and stop; safe restart only from `Stopped` (`mayRestart`).
- New connects/publishes/retries/reconfigurations cease once a stop is requested
(`acceptsNewWork`).
- Resources are released **only** after a stop acknowledgment (or the reviewed
timeout fallback) -- never mid-run.
- A late/stale callback consults `mayTouchOwnedState()` and is a no-op once the
owner has released resources.
- Bounded stop timeout -> reviewed fallback (models replacing the abrupt
`vTaskDelete`).
- **OTA barrier:** `mayBeginFlash()` is true only after a **clean** stop
acknowledgment; a timed-out stop leaves flashing blocked so OTA aborts rather
than writing under uncertain ownership.
### Phase 0 / hardware-pending items
Per the "derive from code + flag" discipline, these are **not** encoded as
constants and must be characterized on hardware before Phase 5 ships:
- The concrete stop timeout (injected as `Coordinator`'s `stop_timeout_ms`;
must be measured against mbedTLS teardown over `wss` with a down broker).
- Exact callback timing/ordering under a real TLS disconnect.
- Heap / largest-block / task-stack high-water and task/client counts across
start/stop/restart (Phase 7 gates).
## Deferred to Phase 5 (not done here)
- Publishing the plain-data snapshot and repointing the Section 1/Section 2 consumers at it.
- Replacing the Section 3 `volatile` handshakes with a command queue / task
notifications.
- The cooperative shutdown state machine in `MQTTBridge` (`end()` rewrite, the
`begin()` double-call guard) and the OTA teardown barrier.
`MQTTBridge.cpp` was intentionally left untouched in Phase 4 to keep the
merge-sensitive file (~3.7k lines) free of churn until the Phase 5 change lands
as a single reviewable unit.
+891
View File
@@ -0,0 +1,891 @@
# Stability, Testability, and Upstream-Merge Handoff
## Purpose
This document is the plan of record for refining the fork-owned WebConfig and
MQTT observer code after the initial policy extraction and host-test work. The
goal is to improve runtime stability, long-uptime confidence, and serviceability
without broad rewrites of upstream-heavy files or creating unnecessary merge
conflicts.
The order is deliberate: cheap CI and persistence guardrails land first, then
tests and ownership boundaries needed to make lifecycle work safe, and only
then the cooperative-shutdown refactor. Hardware soak testing validates the
result; it is not the first line of defense for the riskiest change.
## Roadmap Status
The guardrail phases have already landed on this branch; the remaining work is
the lifecycle refactor and its safety net. Phases are intentionally not
renumbered so cross-references and the completed acceptance criteria stay stable;
each phase below carries an explicit status line, and this table is the quick
index.
| Phase | Scope | Status |
|-------|-------|--------|
| 1 | PR CI smoke builds + ArduinoJson pin enforcement | Done (build-size gate and ASan/UBSan still pending) |
| 2 | PSRAM restart resource symmetry | Done |
| 3 | MQTT preference migration fixtures | Done (filesystem adapter still lives in `CommonCLI`) |
| 0 | Pre-change lifecycle characterization | Hardware run 2026-07-19 (see "Hardware Characterization Results"): teardown timing measured on V3+V4. Finding: flat `MQTT_STOP_TIMEOUT_MS=8000` too small -> **FIXED** with slot-scaled timeout (`5s + 8sxslots`), hardware-verified (2-slot stop now clean) |
| 4 | Ownership and teardown test seams | Seams + ownership doc + teardown tests done; production rewiring deferred to Phase 5 |
| 5 | Cooperative MQTT shutdown | Minimal cooperative `end()` + `begin()` guard + OTA barrier implemented on branch `phase5/cooperative-mqtt-shutdown` (native green, firmware smoke build green); NOT hardware-validated. Volatile-handshake replacement + snapshot-consumer repointing deferred |
| -- | 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 |
| -- | 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 |
Phases 0, 4, and 5 (with the OTA teardown barrier) are landed and
hardware-validated. **Remaining work, in execution order:**
1. Exercise the wired WebConfig batch machine over real HTTP (Phase 6) -- the
only untested part of a change that is already in the branch.
2. Drive the live `ota update` deferred-flash path on a bench node (OTA barrier
action; the latch input is already hardware-verified).
3. Close the remaining Phase 6 queue-orchestration coverage.
4. Phase 7 multi-day soak + task-stack-HWM build.
5. Promote `observer-firmware-dev` into `webconfig` once the items above are
met, then keep merging upstream after each phase rather than batching (see
"Upstream Merge Record").
Do not reopen a "Done" phase without a deliberate reason (see
"Change-Control Discipline").
## Hardware Characterization Results (Phase 0 & Phase 7) -- 2026-07-19
Hardware run of the outstanding Phase 0 (pre-change/cooperative teardown timing)
and Phase 7 (uptime/memory/fault-injection) items against two live observer
nodes. **Not yet committed as a plan change -- this section records measured
results and a release-gating recommendation for review.**
### Setup
- **V3** -- Heltec WiFi LoRa 32 **V3**, non-PSRAM (max 2 active slots), env
`Heltec_v3_repeater_observer_mqtt`, 1 configured slot (`meshmapper`, wss).
- **V4** -- Heltec WiFi LoRa 32 **V4**, **2 MB PSRAM** (max 5 active slots), env
`heltec_v4_repeater_observer_mqtt`, 3 configured slots (`analyzer-us`,
`cascadiamesh`, `waev`, all wss). mbedTLS + JSON/raw buffers allocate in PSRAM.
- Both flashed with this branch (`phase5/cooperative-mqtt-shutdown`) via
`pio run -t upload` (app-slot flash only; `nvs`+`spiffs` preserved, no erase).
Note: `FIRMWARE_VERSION`/`FIRMWARE_BUILD_DATE` are hardcoded in `MyMesh.h` and
were not bumped, so `ver` reads "v1.16.0 (6 Jun 2026)" on both old and new
builds -- confirm the flash by behavior (the `(clean)` / cooperative-stop log
lines), not by `ver`.
- Driven over the serial CLI (`set bridge.enabled off/on` = `end()`/`begin()`;
`set mqttN.*` = slot reconfigure; `get mqtt.stats` = `Free`/`Max`(largest
block)/queue/outbox/per-slot counters). Host-side timestamped log parsing.
### PRIMARY FINDING -- `MQTT_STOP_TIMEOUT_MS = 8000` is too small (release-gating)
Per-wss-slot teardown costs **~5-6 s**, applied **sequentially**
(`destroySlotClients()`: `disconnect()` -> 50 ms -> `esp_mqtt_client_destroy()`;
the ~5 s is the esp-mqtt task/network close, not the 50 ms settle). This is
unchanged from the pre-change path (same teardown code), so the cooperative
`end()` ack time scales with the number of connected slots:
| Config | Slots | Measured teardown | vs 8 s timeout | Result |
|--------|-------|-------------------|----------------|--------|
| V3 non-PSRAM | 1 wss | ack ~5.3-6.2 s (avg 5.8) | under | **clean** PASS |
| V3 non-PSRAM (design max) | 2 wss | ~11-12 s (cut off at 8 s) | **over** | **forced/dirty -> OTA withheld** FAIL |
| V4 PSRAM (normal config) | 3 wss | ~16 s (cut off at 8 s; per-slot disc @2.2/7.8/10.6 s) | **over** | **forced/dirty -> OTA withheld** FAIL |
| V4 PSRAM (design max) | 5 wss | ~27-30 s projected | **far over** | forced/dirty -> OTA withheld FAIL |
Pre-change (v1.16.0) reference teardown (same nodes, blocking `end()`): 1 slot
~6.1 s (V3), 3 slots ~16.4 s (V4) -- consistent with the above.
**Consequence:** at the non-PSRAM board's *designed maximum* of 2 wss slots, and
on a *normal healthy* 3-slot PSRAM node, an ordinary cooperative shutdown exceeds
8 s -> the coordinator fires `StopTimedOut` -> sets the dirty latch -> force-kills
the MQTT task (the exact mbedTLS-mid-teardown path Phase 5 exists to avoid) and
`canFlashAfterStop()` returns false. Because the OTA barrier only flashes after a
**clean** stop, this means **any multi-slot device would have `ota update`
permanently withheld** with the current timeout -- the barrier misclassifies
healthy stops as dirty. This inverts the barrier's intent and must be fixed
before Phase 5 ships.
**FIX APPLIED (slot-count-aware timeout) -- verified on hardware.**
The flat `MQTT_STOP_TIMEOUT_MS = 8000` is replaced by a slot-scaled budget
computed per stop in `MQTTBridge::end()`:
```
timeout = MQTT_STOP_TIMEOUT_BASE_MS (5000) + MQTT_STOP_TIMEOUT_PER_SLOT_MS (8000) x enabled_slots
```
-> 1 slot 13 s, 2 slots 21 s, 3 slots 29 s, 5 slots 45 s -- ~1.5-2x headroom over
the measured ~5-6 s/slot teardown. `end()` counts enabled slots and calls
`Coordinator::setStopTimeoutMs()` before `requestStop()`. Headroom is nearly free
because `end()` returns as soon as the task acks (`_stop_acked` is checked before
the timeout ticks), so a larger bound does not slow a healthy stop -- it only
lengthens the wait before force-killing a genuinely wedged task. (Files:
`MQTTBridge.cpp` constants + `end()`; `MQTTLifecycle.h` `setStopTimeoutMs()`.)
**Hardware verification (both memory paths):** the same configs that force-timed-
out at 8 s pre-fix now ack cleanly -- V3 non-PSRAM 2-slot logs `timeout 21000 ms`
and acks in ~11.7 s; V4 PSRAM 3-slot logs `timeout 29000 ms` and acks in ~16.4 s
-> **`MQTT Bridge stopped (clean)`** (OTA no longer withheld). Native suite + both
firmware builds green. Complementary future option (not done): shorten the
per-slot `esp_mqtt_client_destroy()` wait to reduce absolute teardown time.
### Phase 0 -- cooperative lifecycle (V3, non-PSRAM, this branch)
- 6x and 10x `begin->connect->end->restart` cycles: **all clean**, ack 5.3-6.2 s,
end() loop-task block <=6.9 s, restart->connect ~5.4 s.
- **No leak across full stop/start cycles:** free heap flat (~137-142 k, varies
only with the meshmapper outbox), largest free block stable (~115-129 k). This
satisfies Phase 5's "no downward heap/largest-block trend across start/stop
cycles" acceptance criterion on the non-PSRAM path.
### OTA teardown barrier
- Barrier **input** validated on hardware in both states: clean stops ->
`canFlashAfterStop()` true; forced/timeout stops log `OTA blocked` /
`OTA flashing withheld` (= `canFlashAfterStop()` false). Confirmed
`mayBeginFlash() == (Stopped && !_stop_timed_out)`; a fresh start clears the
latch; a dirty stop still permits restart.
- Barrier **action** (`simple_repeater` aborts+resumes vs. proceeds) is a
one-line gate on that latch (`MyMesh.cpp` deferred-OTA site) -- validated by
code + the Phase 4 host lifecycle tests.
- **Not exercised end-to-end on hardware:** the live `ota update` deferred-flash
path. A plain `pio run` build reports `ERR: OTA not configured (build via
build.sh)`, so `otaFromManifest` bails before scheduling; driving it needs a
`build.sh` firmware + a controlled manifest, and was not improvised on working
fleet nodes (risk of flashing a real fleet build). Recommend a dedicated
bench run for this.
### Phase 7 -- fault-injection matrix (V3 non-PSRAM; representative, not the soak)
Matrix: 10 clean stop/start + 5 forced 2-slot teardowns + 8 slot-reconfigure +
4 down-broker (`wss://192.0.2.1`) flaps. Time series of free/largest-block/
queue/outbox recorded per step; reboot/crash detection on.
- **No crash across the entire matrix** (incl. 5 repeated force-kill fallbacks) --
the reviewed dirty-stop fallback is robust on non-PSRAM.
- **Forced teardown reproducible:** every 2-slot stop timed out (acks 7.4-8.2 s),
reinforcing the primary finding.
- **Bounded fragmentation, fully recoverable:** slot-level reconfigure/flap
churn (which keeps a disabled slot's persistent client until a *full* bridge
restart) dropped the largest free block from ~125 k to ~72 k and held ~17 k of
free heap, but it **plateaued** (stable across 12 churn iterations, not an
unbounded leak) and a reboot fully restored ~141 k free / ~125 k largest block.
This is pre-existing bridge behavior (TLS-context lifecycle), not a Phase 5
regression.
### Phase 7 -- forced-teardown stress (V4 PSRAM, 3 wss slots, this branch)
8x `end()`/`begin()` cycles at the node's normal 3-slot config (every stop
exceeds 8 s -> forced/dirty):
- **All 8 forced, no leak, no crash.** Largest free block **rock-stable at
139 252 across all 8 cycles** (mbedTLS is PSRAM-allocated, so internal-heap
fragmentation is absent on the PSRAM path); free internal heap flat. The
force-kill fallback is robust on PSRAM too.
- **Loop-task stall is severe on the forced path:** `end()` blocked the loop
task **~15-27 s** per stop, because the forced path waits the full 8 s timeout
and *then* runs a complete redundant teardown on Core 1 (~ 8 s + ~16 s). During
that window the repeater's mesh/CLI/radio servicing is stalled. Sizing the
timeout so the cooperative (Core-0) teardown completes cleanly removes both the
OTA-withhold and this double-teardown stall.
- Both nodes left restored to their original config, bridge on, heap healthy.
### Limitations / not covered
- Task stack high-water mark and explicit task/client counts are not exposed by
the CLI; a dedicated `MQTT_MEMORY_DEBUG` build is needed for those (heap
stability was used as a proxy leak signal, and it held).
- The 72 h / 7-day soaks were not run (bounded representative cycles per agreed
scope); WiFi loss/recovery, TLS-handshake failure, queue saturation, JWT/NTP
failure, and `millis()`-rollover scenarios need external network control /
time injection and were not driven over serial.
- Both devices left restored to their original slot config with the bridge on.
## Current Baseline
The current branch has:
- Native tests (GoogleTest, `[env:native]`) for MQTT presets, validation, topic
templates and routing, connection policy, packet-queue policy, payload
construction, WebConfig keys, the WebConfig batch state machine, the MQTT
lifecycle/teardown seam, the `/mqtt_prefs` codec, the atomic prefs store, the
runtime-buffer lifecycle, and upstream `Utils::toHex` and mesh-table behavior.
15 suites as of the 2026-07-19 upstream merge.
- ArduinoJson pinned to 7.4.3 across the native and all firmware environments,
enforced in CI by `scripts/check_arduinojson_pin.py`.
- PR CI (`.github/workflows/`) that runs the native suite and compiles both
representative MQTT observer smoke builds:
`Heltec_v3_repeater_observer_mqtt` (non-PSRAM) and
`T_Beam_S3_Supreme_SX1262_repeater_observer_mqtt` (PSRAM).
- Symmetric PSRAM runtime buffers: `begin()` allocates through
`allocateRuntimeBuffers()` and `end()` frees and nulls through
`releaseRuntimeBuffers()`, so raw-data caching and the PSRAM JSON buffers are
restored after a restart (`MQTTRuntimeBufferLifecycle.h`).
- A versioned `/mqtt_prefs` loader extracted into fork-owned, host-tested seams
(`MQTTPrefsCodec.h`, `MQTTPrefsStorage.h`, `MQTTPrefsAtomicStore.h`) that
classifies every deployed layout, preserves an unknown or newer file without
overwriting it, and rejects corrupt input without out-of-bounds reads.
- WebConfig request/result correlation, failed-batch reboot gating, and a
process-lifetime HTTP listener that avoids deleting an object still referenced
by an asynchronous request.
- Pure MQTT policy helpers that reduce decision duplication while leaving the
production bridge as the integration point.
- A **wired** WebConfig batch/reboot/stop state machine: `WebConfigServer.cpp`
calls `WebConfigBatch.h` directly for POST classification, drain pacing and
all-ok accumulation, reboot scheduling/firing, result classification,
confirm-reboot arming, and stop gating; `MAX_BATCH`/`STOP_WARN_MS` alias the
spec constants. The host tests therefore cover production, not a parallel copy.
This is a solid guardrail and unit-test foundation, but it does not yet validate
cross-core state ownership beyond the Phase 5 stop path, the WebConfig batch
machine over real HTTP, or long-running heap behavior. Those are the remaining
phases.
## Constraints
1. MQTT preference-file layouts are fleet-critical. Unknown newer formats must
not be overwritten, and every supported old layout must remain recoverable.
2. Upstream MeshCore changes are merged regularly. Prefer additive fork-owned
helpers and small adapters over reorganizing upstream-heavy files.
3. The non-PSRAM profile is the memory-pressure baseline; the PSRAM profile is
a separate allocation/lifecycle path and must also be tested.
4. Risky lifecycle changes require fast deterministic tests before hardware
soak testing.
5. Preserve observable behavior unless a behavior change is explicitly named,
reviewed, and tested.
## Change-Control Discipline (Stop-and-Ask)
This roadmap is executed incrementally, often by an agent working one phase at a
time. Each unit of work is scoped to its assigned phase. When work uncovers
something outside that scope, the correct action is to **stop and ask, not to fix
forward.** Silent scope expansion is the primary way a targeted stability change
turns into an unreviewed refactor or a needless merge-conflict surface. This
section is binding on anyone -- human or agent -- executing the plan.
### Stop and ask before writing code when any of these is true
- A refactor larger than the current phase authorizes appears necessary -- for
example touching task lifecycle, client lifetime, cross-core ownership, or the
queue backends when the phase did not name them.
- A bug is discovered that is not described in the plan, especially one
affecting persistence, teardown, OTA, or heap behavior.
- A phase premise turns out to be false or already implemented (for example, a
described defect that is already fixed). Report the discrepancy and get the
plan re-baselined before writing code against a stale assumption.
- A change would alter observable behavior the phase did not explicitly name,
review, and test (Constraint 5).
- Work would touch upstream-heavy files (`MQTTBridge.cpp`, `CommonCLI`, the
role-specific `MyMesh` files) beyond a small, additive adapter (Constraint 2).
- A change touches fleet-critical persistence layouts, or could overwrite or
discard an unknown or newer `/mqtt_prefs` file (Constraint 1).
- Two phases would be combined in one change, or a "Explicitly Deferred Debt"
item would be pulled forward.
- A fix spans multiple files, changes a public seam, or would surprise a
reviewer expecting only the phase's stated work.
### What "stop and ask" means in practice
- Do not implement the out-of-scope change in the same pass. Record it.
- Report it with evidence: file and line references, a concrete reproduction or
failure scenario, and the specific invariant or constraint at risk.
- Present options with tradeoffs and a recommendation, then wait for an explicit
decision on scope before proceeding.
- File a newly found bug as its own item. Do not fold an opportunistic fix into
an unrelated phase's commit -- single-purpose commits are required, and an
unexpected fix deserves its own review.
### What may proceed without asking
- Work squarely inside the assigned phase and its acceptance criteria.
- Small, behavior-preserving fixes fully contained in fork-owned helper files
with matching host tests, where a reviewer would expect them as part of the
phase.
When uncertain whether a change is in scope, treat it as out of scope and ask.
The cost of a question is a round trip; the cost of an unreviewed lifecycle or
persistence change across a fleet of thousands of devices is not.
## Sequenced Work
### Phase 0: Record the pre-change lifecycle characterization
**Status: Not started -- the next actionable step, and prerequisite for Phases 4
and 5.**
Capture current behavior before changing shutdown mechanics. This provides a
reference for the lifecycle fakes and lets the later state-machine refactor
prove equivalence rather than relying on memory or a multi-hour soak.
Characterize at least:
- Normal `begin() -> connect -> end()` ordering.
- `end()` while disconnected, connecting, connected, publishing, retrying, and
applying a slot reconfiguration.
- Which callbacks can arrive during and after `end()`.
- Queue disposition and connection/status counters across stop and restart.
- Heap, largest allocatable internal block, task stack high-water mark, and
client/task counts before start, after start, after stop, and after restart.
- Partial initialization failures: queue allocation, task creation, client
allocation, and PSRAM-buffer allocation.
Use instrumented hardware logging where necessary, but encode every behavior
that can be represented deterministically into the lifecycle fake tests in
Phase 4. Store representative logs as CI or test artifacts rather than enabling
permanent high-volume production logging.
### Phase 1: Put MQTT/WebConfig firmware smoke builds in PR CI
**Status: Complete on this branch.** PR CI already compiles both smoke builds and
runs the native suite and the ArduinoJson pin check. Still pending against the
acceptance criteria: build-size artifact/threshold reporting (criterion 4) and
the optional ASan/UBSan native job. The description below is retained as the
record of intent.
The PR build matrix historically did not compile an MQTT observer target; it now
compiles two required smoke builds for changes touching firmware, variants,
PlatformIO configuration, MQTT/WebConfig helpers, or their tests:
- `Heltec_v3_repeater_observer_mqtt` for the constrained non-PSRAM path.
- `T_Beam_S3_Supreme_SX1262_repeater_observer_mqtt` for the PSRAM path.
Keep the native suite required. Add ASan/UBSan to a separate native job if the
PlatformIO native toolchain supports it reliably. Record static RAM and flash
usage and fail only on reviewed limits with enough headroom to avoid noisy
one-byte regressions.
Acceptance criteria:
- Pull requests cannot merge when native tests or either representative MQTT
build fails.
- The build log shows ArduinoJson 7.4.3 for both firmware profiles.
- CI checks that every ArduinoJson declaration remains pinned to 7.4.3.
- Build-size output is retained as an artifact or job summary.
### Phase 2: Fix PSRAM restart resource symmetry
**Status: Complete on this branch.** The asymmetry described in the original plan
has been fixed; this section now documents the intended design and the tests that
guard it, not outstanding work.
PSRAM-backed raw-data and JSON buffers are allocated in `begin()` through
`allocateRuntimeBuffers()` (idempotent, allocate-if-missing), freed and nulled by
`end()` through `releaseRuntimeBuffers()`, and reallocated by a later `begin()`.
Raw-data caching and the PSRAM JSON buffers therefore survive a restart; the
task-stack buffer is used only when a live allocation actually fails. The pure
helpers live in `MQTTRuntimeBufferLifecycle.h`, covered by
`test_mqtt_runtime_buffer_lifecycle`.
The design keeps symmetric runtime-resource operations:
- `allocateRuntimeBuffers()` called by `begin()` or a shared initialization
path.
- `releaseRuntimeBuffers()` called by `end()` and initialization rollback.
The operations must be idempotent, handle partial allocation, and preserve the
existing graceful fallback when PSRAM allocation fails. Avoid changing client
or task shutdown behavior in this phase.
Tests and validation:
- Allocation success, partial failure, repeated allocation, and repeated
release using a narrow allocator fake.
- Multiple `begin()/end()` cycles do not lose the raw-data buffer or permanently
move JSON serialization onto the task stack.
- Initialization failure releases only resources owned by that attempt.
- Representative PSRAM and non-PSRAM firmware builds pass.
- A short hardware restart loop shows stable free heap and largest free block.
### Phase 3: Add binary MQTT preference migration fixtures
**Status: Complete on this branch.** The versioned loader, the frozen-layout
`static_assert`s, and the migration/fixture coverage described below are
implemented in `MQTTPrefsCodec.h`, `MQTTPrefsStorage.h`, and
`MQTTPrefsAtomicStore.h`, and host-tested by `test_mqtt_prefs_codec` and
`test_mqtt_prefs_atomic_store`. Unknown-newer files are preserved and corrupt
input is rejected without overwrite. Residual: the concrete filesystem adapter
and the `load/saveMQTTPrefs()` orchestration still live in `CommonCLI.cpp`
(platform-coupled by design); keep that adapter small. The description below is
retained as the record of intent.
Build deterministic tests around the versioned `/mqtt_prefs` loader before
upstream merges change `CommonCLI`, filesystem behavior, or preference structs.
Use checked-in, non-secret binary fixtures or byte arrays representing each
deployed layout.
Required cases:
- Pre-slot layout.
- Three-slot layout.
- Legacy headerless six-slot layout.
- Shorter version-1 payload with newer fields defaulted.
- Current version round trip.
- Truncated header and truncated payload.
- Invalid magic and implausible payload length.
- Unsupported newer version: run with safe defaults and preserve the original
file without overwriting it.
- Migration failure or interrupted save leaves a recoverable source file.
- Existing credentials, slot ordering, and publish flags survive migration.
Prefer extracting a fork-owned serializer/decoder seam over host-compiling all
of `CommonCLI`. Keep the production adapter small and retain the frozen-layout
`static_assert`s.
Acceptance criteria:
- All known deployed layouts have a fixture and field-by-field expected result.
- Corrupt input cannot cause an out-of-bounds read or silent overwrite.
- A future `MQTTPrefs` layout change fails tests until its migration and fixture
are added deliberately.
### Phase 4: Establish ownership and teardown test seams
**Status: Seams, ownership doc, and teardown tests landed; production rewiring
deferred to Phase 5 by explicit decision (scope: "seams + tests only").** The
fork-owned pure lifecycle state machine and narrow dependency seam
(`src/helpers/MQTTLifecycle.h`), the teardown-focused test matrix
(`test/test_mqtt_lifecycle/`), and the ownership model (`MQTT_OWNERSHIP.md`) are
in place. `MQTTBridge.cpp` was intentionally left untouched to keep the
merge-sensitive file free of churn until the Phase 5 change lands as one
reviewable unit. The invasive production work -- publishing the plain-data
snapshot and repointing consumers at it, replacing the `volatile` handshakes
with a command queue / task notifications, and the cooperative-shutdown `end()`
rewrite with a `begin()` double-call guard -- is carried into Phase 5.
Verified premise (with Phase 4 refinements): the loop task, WebConfig, CLI, and
`AlertReporter` currently read the MQTT task's live, mutable slot objects and
client counters cross-core without a lock or a published snapshot.
`getSlotStatusSnapshot()` is built on demand from live state despite its name --
the refactor must actually publish a plain-data snapshot, not assume one exists.
Refinements found while mapping the code (see `MQTT_OWNERSHIP.md` for
file:line references):
- The snapshot's `name`/`state` `const char*`s point at static rodata, so they
are not dangling; the real hazards are reading the mutable `slot.preset`
pointer value (Core 0 can null/reassign it) and `slot.client->getPublishOk()`
(a client pointer Core 0 can `delete` during teardown).
- The live-bridge web reads are in the `MyMesh.cpp` app layer (`buildStatsJson`),
not `WebConfigServer.cpp`, which reads only a compile-time constant.
- `end()` clears only the `s_mqtt_bridge_instance` singleton; the app's `bridge`
pointer and `AlertReporter::_bridge` are not cleared, so instance reads can
touch a torn-down bridge.
This phase is the safety net for cooperative shutdown. It must land before the
shutdown state machine.
#### Ownership model
Document one owner for each mutable runtime domain:
- MQTT task: clients, slot connection state, NTP client operations, publish
counters, and the packet drain path.
- Loop task: CLI execution, preference persistence, WebConfig batch draining,
and bridge lifecycle requests.
- Producer/radio context: packet staging before queue handoff.
- Async TCP context: request parsing and immutable response handoff only.
Replace cross-core `volatile` handshakes with an appropriate primitive:
- Task notifications or a command queue for one-way lifecycle/reconfigure/NTP
requests.
- Atomics only for truly independent scalar state.
- Immutable published snapshots for WebConfig, CLI diagnostics, and alerting.
- A mutex only where ownership transfer or snapshot publication cannot express
the operation cleanly.
In particular, loop/WebConfig code must not directly inspect mutable MQTT slot
objects or client counters. The MQTT task should publish a plain-data status
snapshot.
#### Narrow lifecycle fakes
Introduce interfaces or callbacks for only the dependencies needed to drive the
lifecycle deterministically:
- Clock/timer.
- Task start, stop request, acknowledgment, and timeout.
- MQTT client connect/disconnect and delayed callbacks.
- Queue depth/pop/requeue behavior.
- Runtime allocator and heap measurements.
- OTA coordinator/barrier.
Required teardown-focused tests:
- Stop while connecting, connected, publishing, retrying, renewing a token,
running NTP, and applying a slot change.
- Callback delivered before stop, during stop, after disconnect, and after the
stop acknowledgment.
- Duplicate stop, stop before full initialization, and restart after stop.
- Timeout/fallback behavior when the MQTT task or client does not acknowledge.
- No client, queue, buffer, or task access after its owner releases it.
- Current queue and diagnostic behavior is preserved unless a change is
explicitly approved.
### OTA Teardown Barrier: release-critical scenario
**Status: Not started. This is the fix for a known shipping crash, not a
hypothetical hardening target.** With a broker down over `wss`, the abrupt
`vTaskDelete` in `end()` can kill the MQTT task inside mbedTLS; `destroySlotClients()`
then frees client buffers on a possibly-corrupted heap and OTA begins flashing
with no barrier -- the observed teardown heap panic. There is no coordination
today between MQTT shutdown and flash writing beyond straight-line ordering on
the loop task.
Treat OTA teardown as a first-class test target throughout Phases 0, 2, 4, 5,
and 7. It is not merely another restart case.
The required invariant is: firmware erase/write must not begin until MQTT
shutdown has reached a safe acknowledgment point, and the bridge must not be
restarted while flash writing is active.
Required scenarios:
- OTA requested while MQTT is connecting, publishing, draining retries, or
handling a callback.
- MQTT stop succeeds and OTA begins only after the teardown barrier.
- MQTT stop times out: OTA aborts safely rather than writing under uncertain
ownership.
- OTA preflight or download aborts before flashing: the bridge restarts once and
returns to the prior configured behavior.
- Successful OTA: no bridge restart is attempted before the device reboots.
- Power loss/reset at the platform-supported OTA boundaries retains a bootable
partition; this portion requires hardware/platform validation.
- Repeated failed OTA attempts do not leak heap, duplicate WiFi callbacks, or
leave the bridge permanently stopped.
Retain the prior teardown/heap-panic reproduction as a regression artifact if
available. OTA-related lifecycle tests are release gates for any future change
to bridge teardown, OTA sequencing, MQTT client lifetime, or task ownership.
### Phase 5: Implement cooperative MQTT shutdown
**Status: Minimal cooperative shutdown implemented on branch
`phase5/cooperative-mqtt-shutdown` (scope: "the smallest change that fixes the
OTA teardown panic as one reviewable unit"). Native suite green; the non-PSRAM
observer firmware smoke build compiles. NOT yet hardware-validated -- that is the
Phase 7 gate -- and the stop timeout is a Phase-0 placeholder (see below).**
What landed (wiring the Phase 4 `MQTTLifecycle` state machine into the bridge):
- `MQTTBridge` owns a `MQTTLifecycle::Coordinator` driven **only** by the loop
task (Core 1) from `begin()`/`end()`. A nested `LifecycleOps` binds the pure,
host-tested `Ops` spec to FreeRTOS/PsychicMqttClient.
- `end()` no longer blind-`vTaskDelete`s. It requests a cooperative stop; the
MQTT task (Core 0) sees a new `volatile _stop_requested`, tears down its own
clients **on Core 0 where the mbedTLS contexts live**, sets `_stop_acked`
last, and self-terminates. `end()` waits (bounded) for the ack, then frees the
queue/buffers. This removes the "kill the task mid-mbedTLS, then free client
buffers on a corrupted heap" teardown path.
- Bounded stop timeout -> reviewed fallback: on timeout the task is force-killed
and torn down on Core 1 (the old behavior), but a **dirty latch** is set so
`canFlashAfterStop()` is false and OTA flashing is withheld.
- `begin()` has a double-call guard and syncs the Coordinator to `Running`.
- OTA teardown barrier: `simple_repeater`'s deferred-OTA fire site aborts and
resumes the bridge unless the preceding `end()` reported a clean stop.
Deferred (kept out of this reviewable unit; carried to a Phase 5b / Phase 6):
replacing the `volatile` NTP/reconfigure handshakes with a command channel, and
publishing a plain-data status snapshot to repoint the Section 1/Section 2 consumers in
`MQTT_OWNERSHIP.md` (the `AlertReporter`/`buildStatsJson` instance-pointer reads
that can still touch a torn-down bridge). Those are not required to fix the OTA
panic and would enlarge the merge-sensitive diff.
**Phase 0 dependency -- CHARACTERIZED + FIXED 2026-07-19:** the flat
`MQTT_STOP_TIMEOUT_MS` = 8 s placeholder was **too small** (measured per-wss-slot
teardown ~5-6 s sequential -> ~11-12 s at 2 slots, ~16 s at 3, ~27-30 s at 5; at
8 s healthy multi-slot stops tripped the dirty fallback and the OTA barrier
withheld flashing). Replaced with a **slot-scaled timeout** set per stop in
`end()`: `5 s + 8 s x enabled_slots` (`MQTT_STOP_TIMEOUT_BASE_MS` /
`MQTT_STOP_TIMEOUT_PER_SLOT_MS`, applied via `Coordinator::setStopTimeoutMs()`).
Hardware-verified: a 2-slot stop that force-timed-out pre-fix now acks clean in
~11.7 s within the 21 s budget. See "Hardware Characterization Results (Phase 0 &
Phase 7)".
The original plan of record follows. Replace direct task deletion with an
explicit lifecycle such as:
`Stopped -> Starting -> Running -> StopRequested -> Stopping -> Stopped`
The exact representation can differ, but it must provide:
- Idempotent start and stop requests.
- A stop request delivered through the established ownership channel.
- Cessation of new connects, publishes, retries, and reconfigurations.
- Ordered client/service shutdown on the MQTT task.
- A completion acknowledgment before the loop task releases queues, buffers, or
other shared resources.
- A bounded timeout with clear diagnostics and a deliberately reviewed fallback.
- Safe restart after a completed stop.
- An OTA barrier that consumes the same completion acknowledgment.
Do not combine this phase with queue-loop deduplication, broad bridge cleanup,
or unrelated feature changes.
Acceptance criteria:
- All Phase 4 lifecycle tests pass, including delayed/stale callbacks.
- Characterized behavior from Phase 0 is preserved or differences are explicitly
documented and approved.
- OTA teardown-barrier tests pass.
- Repeated hardware start/stop cycles show no downward heap or largest-block
trend and no task/client-count growth.
### Phase 6: Expand request, queue, connection, and publication integration tests
**Status: Partial -- branch `phase6/integration-tests` (draft PR, base `phase5`).**
The remaining *inline* decision points that were host-testable have been extracted
into the pure policy seams and covered:
- WiFi STA reconnect backoff moved out of `handleWiFiConnection()` into
`MQTTConnectionPolicy::{wifiReconnectBackoffMs,wifiReconnectDue,
nextWifiBackoffAttempt}` (behavior-preserving; adversarially reviewed for
rollover/boundary equivalence) with `test_mqtt_connection_policy` cases.
- The (packet, raw) publication-outcome pairing named as
`MQTTPacketQueuePolicy::queuedPacketPublished()` and wired at both queue-drain
sites, with `test_mqtt_packet_queue_policy` cases (partial success = completed).
- `MQTTPublicationType` values frozen in `test_mqtt_topic_router`; the
bridge-side `MQTTMessageType` alignment was already a compile-time `static_assert`.
The **WebConfig POST/result/reboot/stop state machine** (the largest gap) has a
pure, host-tested spec -- `src/helpers/WebConfigBatch.h` +
`test/test_webconfig_batch/` -- and as of 2026-07-19 it is **wired**:
`WebConfigServer.cpp` calls it at every decision point, so the spec is
load-bearing and cannot drift from production. `MAX_BATCH` and `STOP_WARN_MS`
alias `kMaxBatch`/`kStopWarnMs` for the same reason.
Two asymmetries between spec and caller are deliberate, and are documented in
the header so a future reader does not "simplify" them back:
1. `finishRebootAt()` returns 0 for "no reboot scheduled", but the caller only
ASSIGNS `_reboot_at` when the result is non-zero. `_reboot_at` is not solely
batch-owned -- the manual `/api/reboot` route arms it from the async_tcp task,
possibly while a batch is still draining -- so an unconditional assign would
silently cancel a manual reboot. This was caught during the wiring, not by a
test; the host suite does not model the two owners of `_reboot_at`, which is a
coverage gap worth closing.
2. `classifyPost()` is consulted in two phases, because the change count is only
known after the `set` map is parsed, and parsing must not precede the
Replay/Busy answer (a replayed POST carrying a bad key must still get its 202).
**Verification status of the wiring:** native suite and both smoke builds green;
V3 hardware shows a clean WebConfig AP start/stop (the rewired `stopStep` path
takes the Finalize branch with no handler-wait warning). **Not verified: the
POST -> drain -> result -> reboot sequence over real HTTP.** LAN mode needs an
admin-password login and the setup AP needs a client associated to the device's
SoftAP; neither was driven. Given that this server was originally tuned against
real iOS captive-portal behavior, HTTP caching, and route ordering, treat an
end-to-end portal save as a required check before this ships.
Still open (each a good follow-up PR): the end-to-end HTTP exercise above, and
the **queue-orchestration** behaviors (FIFO ordering, evict/requeue-failure
interplay, the two adapters' drop-vs-keep-head divergence), which need a
fake-queue harness. The original scope list follows.
After lifecycle ownership is stable, broaden deterministic integration coverage:
- WebConfig POST/result/reboot/stop behavior, including lost responses,
duplicate request IDs, concurrent clients, partial command failure, and stop
with an active handler.
- MQTT queue behavior for FreeRTOS and circular-buffer adapters: overflow,
delayed retry, requeue failure, stale flush, queue ordering, and
`millis()` rollover.
- Packet succeeds/raw fails and raw succeeds/packet fails.
- Connection backoff, stable reset, breaker probe, WiFi recovery, token renewal,
and slot reconfiguration callback ordering.
- Topic and payload contracts at the bridge boundary, so enum/cast/adaptation
mistakes are covered in addition to the pure helper tests.
Once both queue backends are covered by the same behavioral contract, consider
extracting a shared `processQueuedPacket()` that returns an outcome while each
backend retains pop/requeue/dequeue ownership. Do not make queue deduplication a
prerequisite for the stability work.
### Phase 7: Establish uptime, memory, and fault-injection gates
**Status: Not started.** The final validation gate; runs after the lifecycle and
OTA-barrier work is in place.
Use hardware soak tests to validate the already-tested design, not to discover
basic lifecycle errors for the first time.
Run at least one constrained non-PSRAM board and one PSRAM board through:
- Stable broker operation.
- Broker unavailable, rejecting authentication, and flapping.
- WiFi loss/recovery and credential changes.
- TLS handshake failures and repeated reconnect backoff.
- Queue saturation and slow broker behavior.
- Repeated slot reconfiguration and full bridge stop/start.
- WebConfig start/save/stop cycles.
- JWT renewal and NTP failure/recovery.
- OTA success, preflight abort, download failure, teardown timeout, and resume.
- At least one test crossing the 32-bit `millis()` rollover boundary, accelerated
where hardware time injection is available.
Record and threshold:
- Minimum free internal heap.
- Largest free internal block.
- MQTT task stack high-water mark.
- PSRAM free/largest block where available.
- Queue and outbox high-water marks.
- Connect/disconnect/retry/publish counters.
- Watchdog and reset reason.
- Task/client counts across restart cycles.
Recommended gates are a 72-hour fault-injection run followed by a seven-day
stable run. Store machine-readable time series and a concise summary artifact.
Avoid enabling high-volume diagnostic logging in production builds.
## Branch and Release Channels
`observer-firmware-dev` is the standing development line, not a one-off merge
branch. It is where upstream merges land and where the dev/beta firmware channel
is built from; it is promoted into `webconfig` (and onward to the flex mainline)
when its contents are ready to ship. Future upstream merges land ON this branch
rather than creating a new dated branch each time.
Two firmware channels are published, fully separated so a node cannot cross
between them by accident:
| | Production | Dev/Beta |
|---|---|---|
| Source branch | flex mainline | `observer-firmware-dev` |
| Workflow | `build-observer-firmwares.yml` (push-triggered) | `build-observer-firmwares-beta.yml` (manual dispatch) |
| Release tag | `observer-mqtt-latest` | `observer-mqtt-beta-latest` |
| OTA manifest base | `observer.gessaman.com/v` | `observer.gessaman.com/beta/v` |
| Download host | `observer-fw.gessaman.com` | `observer-fw-beta.gessaman.com` |
| Flasher config | `config.json` (default) | `config-beta.json` (`?config=config-beta`) |
| Embedded version | `v1.16.0.N-observer-<hash>` | `v1.16.0.N-observer-beta-dev-<hash>` |
The channel is baked into each binary as `OTA_MANIFEST_BASE` (injected by
`build.sh`, overridable via `OTA_MANIFEST_BASE_URL`), so a node only ever
receives OTA updates from the channel it was flashed from. Both channels
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-07-19 (`observer-firmware-dev`)
First `upstream/dev` merge since the v1.16.0 base (`8c0d5c5b`, 2026-06-06):
**191 upstream commits, 14 conflicted files, ~18 hunks.** The fork was 349
commits ahead. Recorded here because the next merge starts from these
resolutions (all captured in `rerere`).
### The finding that matters most: `/com_prefs` is safe to reorder
Upstream moved `rx_boosted_gain` and `path_hash_mode` to the tail of
`struct NodePrefs`, while the fork holds them mid-struct. This *looks* like a
fleet-critical layout divergence (Constraint 1) and was analyzed as one before
resolving. It is not:
**`/com_prefs` is serialized field-by-field at explicit byte offsets, not as a
struct dump.** `NodePrefs` member order is in-memory only and has no effect on
the file. The fork's extracted `writeCommonPrefsImage()` was verified
byte-identical to upstream's inline writer at every offset -- 79 (pad), 121, 122,
and 290-294 -- so either struct order produces the same image. No migration was
needed, and none should be invented for this in future merges.
Corrected while here: a comment claiming `rx_boosted_gain` lives at
`/com_prefs` offset 79. Offset 79 is a pad; the field is written at 290. The
comment would have misled exactly the analysis above.
Note the asymmetry this creates: `/mqtt_prefs` (fork-owned) IS layout-critical
and versioned; `/com_prefs` is offset-addressed and tolerant of struct
reordering. Do not generalize one file's rules to the other.
### Resolutions
- `CommonCLI.h` -- kept the fork's `NodePrefs` (a superset) and adopted upstream's
**`setRxBoostedGain(bool)` -> `bool`** signature change, which upstream's
`CommonCLI.cpp` now uses to report "unsupported". A real semantic API change,
exactly the kind the discipline list warns can hide behind a clean merge.
- `CommonCLI.cpp` -- kept the fork's legacy `/com_prefs` migration block and the
`writeCommonPrefsImage()` call.
- `UITask.cpp` -- genuine three-way merge: upstream's `drawTextCentered` and
powering-off screen, plus the fork's `WITH_WEBCONFIG` portal/reboot screens.
- `ESP32Board.cpp`, `MeshCore.h`, `platformio.ini` -- keep-both (fork OTA additions
alongside upstream `powerOff`/`enterDeepSleep` and `Packet.cpp`).
- `MicroNMEALocationProvider.h` -- took upstream's `claim()`/`release()` and added
the `_claims` member they depend on.
- `MyMesh.cpp`/`.h` (repeater + room server) -- kept the fork's superset defaults.
- Removed duplicate declarations that auto-merge produced without conflicting:
`RadioLibWrapper::_cad_enabled` and `MyMesh::getCADEnabled()`. **These compiled
only after being caught by the build, not by Git** -- a reminder that a
conflict-free merge is not a correct merge.
### Verification
Native 15/15 (including upstream's new `test_mesh_tables`), both MQTT smoke
builds, ArduinoJson pin check. On V3 hardware (non-PSRAM, 1 wss slot): clean
boot with `/com_prefs` values intact across the flash (the end-to-end proof of
the `NodePrefs` resolution), WiFi -> NTP -> MQTT1 connect -> status published,
`Free=137612 Max=124916` matching the pre-merge healthy baseline, and a clean
cooperative teardown -- `1 enabled slot(s), timeout 13000 ms` -> `cooperative
stop` -> `stop acknowledged (clean)` -> `Bridge stopped (clean)`. Phase 5 and the
slot-scaled timeout both survive the merge.
Not verified: the V4/PSRAM path, and the WebConfig HTTP batch machine (see
Phase 6).
### Cost signal for scheduling the next merge
Upstream's deltas to the hot files were small (`CommonCLI.cpp` 165 lines,
`MyMesh.cpp` 70, `CommonCLI.h` 26) against 107 and 61 fork commits on those same
files. **The fork is the churn source, not upstream** -- so merge cost scales
with how much fork work accumulates between merges, not with upstream velocity.
Six weeks of drift cost roughly a half-day. Merge after each phase lands rather
than batching.
## Continuous Upstream-Merge Discipline
Apply these practices throughout every phase:
- Keep fork-owned logic in additive helper files and keep adapters in
upstream-heavy files small.
- Avoid drive-by formatting, renames, and unrelated cleanup in
`MQTTBridge.cpp`, `CommonCLI`, and the role-specific `MyMesh` files.
- Use single-purpose commits with messages that describe their full scope.
- Merge upstream frequently enough that conflicts remain attributable.
- Before each upstream merge, record the native-test, representative-build,
firmware-size, and persistence-fixture baseline.
- After the merge, run native tests, both MQTT smoke builds, preference fixtures,
and the relevant lifecycle/OTA tests before resolving the merge as complete.
- Review semantic behavior at adapters even when Git reports no textual
conflict; upstream signature, lifetime, and task-context changes can invalidate
fork assumptions silently.
- Reuse resolutions only after revalidating them against the new upstream code.
- A conflict-free merge is not a correct merge: the 2026-07-19 merge produced two
duplicate declarations that Git resolved silently and only the compiler caught.
Always build both smoke targets before treating a merge as done.
- Extract repeater/room-server WebConfig ownership into a shared fork helper when
that integration next requires material change; do not perform a standalone
broad move solely for aesthetics.
## Explicitly Deferred Debt
These items are recognized but are not prerequisites for the sequenced
stability work:
- Shared packet-drain processing between FreeRTOS and circular-buffer backends.
- Repeater/room-server WebConfig integration duplication.
- Moving the WebConfig HTML generator out of the common ESP32 build path.
- The secret-placeholder edge case for a credential exactly equal to the UI
sentinel.
- Secure setup-AP authentication beyond the currently documented open/optional
PSK threat model.
Revisit an item when its code is already being changed, when operational data
raises its priority, or when tests make the refactor substantially safer.
## Completion Definition
This roadmap is complete when:
- PR CI protects native logic and both representative firmware memory paths.
- Every deployed MQTT preference layout has a passing migration fixture.
- Runtime resources survive repeated start/stop cycles symmetrically.
- Cross-core ownership is explicit and diagnostics consume immutable snapshots.
- Cooperative shutdown passes deterministic lifecycle and OTA-barrier tests.
- Hardware fault-injection and stable soaks meet reviewed memory, stack, task,
and reliability thresholds.
- Upstream merges use the same automated gate and do not require broad rewrites
of fork-owned behavior.
+516
View File
@@ -0,0 +1,516 @@
# WebConfig Branch Review
## Scope
This review covers the fork-owned WebConfig and Heltec Tracker additions on the `webconfig` branch, principally commits `d7a7e1b6`, `dfee21a0`, and `639c07a4`, plus the fork-owned MQTT/CLI paths they invoke. Issues inherited unchanged from `meshcore-dev/MeshCore` are intentionally excluded.
No implementation changes are included in this document.
## Executive Summary
The portal builds successfully and has a sound high-level design: HTTP handlers avoid directly running CLI/radio operations, configuration writes are marshalled to the loop task, secrets are represented by placeholders, and the UI is self-contained for offline provisioning.
Before deployment, the most important work is:
1. Secure setup/forced-AP reachability.
2. Correlate each save with its own result.
3. Prevent reboot after partially failed wizard saves.
4. Make persisted Wi-Fi and MQTT settings match live runtime behavior.
5. Remove cross-task preference/statistics races and long loop-task blocking.
## Priority 1: Security and Data Integrity
### 1. Forced AP mode exposes the unauthenticated setup API on the existing LAN
**Severity:** High
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:125-134`
- `src/helpers/esp32/WebConfigServer.cpp:346-349`
- `src/helpers/bridges/MQTTBridge.cpp:801-866`
- `src/helpers/bridges/MQTTBridge.cpp:919-926`
- `examples/simple_repeater/MyMesh.cpp:1329-1339`
- `examples/simple_room_server/MyMesh.cpp:974-984`
**Problem:**
`MQTTBridge::end()` deliberately leaves the STA association connected. Forced setup then selects `WIFI_AP_STA`, starts the server on port 80, and disables authentication for all requests while `MODE_SETUP` is active. The server is therefore reachable through both the setup AP and the existing LAN connection.
The comment that setup mode implies physical proximity is not valid in forced-AP mode. Any host on the existing LAN can read or change configuration and reboot the node without the admin password.
**Suggested fix:**
Choose one of these approaches:
- Disconnect and disable STA before entering unauthenticated setup mode, leaving only the SoftAP interface active. If scanning requires STA mode, keep the interface enabled but explicitly disconnect it and prevent auto-reconnect.
- Bind the setup listener only to the SoftAP interface if the ESPAsyncWebServer/network stack supports reliable interface binding.
- Keep authentication enabled for setup-mode requests arriving through STA, while allowing unauthenticated requests only through the SoftAP interface.
Add a hardware test that starts from an associated STA connection, stops the bridge, enters forced AP mode, and confirms that another LAN host cannot access `/api/config` or `/api/reboot` without authentication.
### 2. Default provisioning sends credentials over an open HTTP network
**Severity:** High
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:130-134`
- `src/helpers/esp32/WebConfigServer.cpp:346-349`
- `src/helpers/esp32/WebConfigServer.cpp:530-598`
**Problem:**
No reviewed observer environment defines `WEBCONFIG_AP_PASSWORD`, so setup creates an open AP. Setup requests are unauthenticated and use plain HTTP. Wi-Fi passwords, MQTT passwords, and access tokens can be captured by another nearby station. A nearby party can also provision the device before the intended operator.
**Suggested fix:**
- Generate a unique per-device setup password from secure random data during first boot and show it on the display or a physical label.
- Alternatively require a short-lived setup PIN displayed on-device and validated by the API before secrets can be submitted.
- Add an explicit provisioning-session expiry and invalidate the setup credential after successful setup.
- If an intentionally open AP remains supported, document the threat model prominently and avoid describing proximity as authentication.
### 3. Save results are not correlated with the submitted batch
**Severity:** High
**Locations:**
- `webui/index.html:630-680`
- `src/helpers/esp32/WebConfigServer.cpp:547-550`
- `src/helpers/esp32/WebConfigServer.cpp:601-641`
**Problem:**
The server keeps the last completed result readable. The frontend polls `/api/config/result` after nearly every POST failure because the POST may have reached the device even if its response was lost. There is no request or batch identity.
A rejected, lost, or concurrent save can therefore consume a previous or different tab's result, report success, clear `st.dirty`, and overwrite unsaved values with the device configuration.
**Suggested fix:**
- Have the browser generate a random request ID and include it in the POST body.
- Store that ID with the batch and include it in the 202 response and every result response.
- Require the frontend to accept `pending` or `done` only when the ID matches.
- Treat definite HTTP responses such as 400, 409, and 413 as final rejection; only poll after an ambiguous network failure.
- Return the active request ID with 409 so the browser can distinguish its own retry from another client's batch.
Add regression tests for stale `DONE`, two tabs, 409, lost 202, lost result response, and a POST that never reached the server.
### 4. Failed wizard commands still cause partial application and reboot
**Severity:** High
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:265-297`
- `src/helpers/esp32/WebConfigServer.cpp:624-637`
- `webui/index.html:950-957`
**Problem:**
Commands are persisted independently. Error replies are recorded, but `_batch_reboot` still schedules a fallback reboot, and reading the result arms the three-second reboot without checking aggregate success. The wizard can display rejected settings while the device reboots with a partially applied configuration.
**Suggested fix:**
- Track aggregate batch success while draining commands.
- Arm automatic reboot only if every required command succeeded.
- If partial persistence cannot be rolled back, return an explicit `partial` state listing applied and rejected keys.
- Keep the portal active after partial failure and present deliberate choices: correct and retry, or reboot with the partial configuration.
- Longer term, validate all values before executing any setter, or stage a complete preference snapshot and commit it atomically.
## Priority 2: Runtime Correctness
### 5. LAN Wi-Fi edits do not perform the reconnect promised by the UI
**Severity:** High
**Locations:**
- `webui/index.html:339-350`
- `webui/index.html:630-641`
- `src/helpers/CommonCLI_Observer.cpp:278-285`
**Problem:**
The Wi-Fi tab says the connection will restart and the page will drop. Normal editor saves do not request reboot, and the underlying setters only persist the SSID/password. The active connection remains on the old network, while subsequent config reads show the new persisted values.
**Suggested fix:**
Use an explicit `Save and reconnect` or `Save and reboot` flow for SSID/password changes. Deliver the result first, then schedule the reconnect/reboot. Do not replace the displayed active network with the persisted network until the transition has begun successfully.
### 6. MQTT publishing controls report success without changing live behavior
**Severity:** High
**Locations:**
- `webui/index.html:303-319`
- `src/helpers/CommonCLI_Observer.cpp:215-238`
- `src/helpers/bridges/MQTTBridge.cpp:637-645`
**Problem:**
Status, packet, raw, RX, and TX options are copied into cached bridge fields during initialization. Their CLI setters persist preferences but do not refresh those fields or restart the bridge. The UI reports success although the running bridge continues using old values.
**Suggested fix:**
- Add one task-safe bridge method that reloads the publishing flags from preferences on the MQTT task, or
- coalesce these changes into one full bridge restart after the batch.
If live application is not desirable, label these controls as restart-required and provide a reboot action instead of claiming immediate success.
### 7. Custom MQTT endpoint edits do not reliably refresh the live slot
**Severity:** High
**Locations:**
- `src/helpers/CommonCLI_Observer.cpp:385-425`
- `examples/simple_repeater/MyMesh.cpp:1354-1367`
- `examples/simple_room_server/MyMesh.cpp:999-1012`
- `src/helpers/bridges/MQTTBridge.cpp:1066-1072`
- `src/helpers/bridges/MQTTBridge.cpp:2076-2095`
**Problem:**
Custom server and port setters persist without requesting slot reconfiguration. Credential changes request reconfiguration, but the custom branch of `applySlotPreset()` reuses the existing slot fields instead of copying the current host, port, username, and password from preferences.
**Suggested fix:**
Create a single `reloadSlotFromPrefs(slot)` operation executed on the MQTT task. It should tear down the slot, copy every custom field and preset-dependent credential from `MQTTPrefs`, validate the complete endpoint, and then reconnect. Every slot-affecting CLI setter should queue that same operation rather than implementing partial restart behavior.
### 8. Wizard review and validation mishandle intentionally cleared values
**Severity:** Medium
**Locations:**
- `webui/index.html:907-929`
- `webui/index.html:931-933`
**Problem:**
Review calculations use `dirtyValue || originalValue`. An intentional empty string is treated as absent, so Review shows the original SSID/password/name/identity/slot while the submitted batch clears it. Final SSID validation can pass using the old SSID even though the effective new value is empty.
**Suggested fix:**
Resolve values by key presence, not truthiness. Add a helper such as `effectiveValue(key)` that returns `st.dirty[key]` when the key exists in `st.dirty`, including `""`, and otherwise returns `st.orig[key]`. Use it for review, validation, and reboot messaging.
### 9. A credential equal to `********` cannot be configured
**Severity:** Low
**Locations:**
- `webui/index.html:422`
- `webui/index.html:527-538`
- `src/helpers/esp32/WebConfigServer.cpp:564`
**Problem:**
The secret sentinel is also a valid possible password/token. The UI either considers it unchanged or the backend silently drops it.
**Suggested fix:**
Track secret-field edit state separately from the displayed placeholder. Prefer an empty password control with an adjacent "stored credential unchanged" indicator. If retaining the sentinel, reject that exact value with a clear validation message rather than silently ignoring it.
## Priority 3: Concurrency, Responsiveness, and Lifecycle
### 10. The preference mutex does not synchronize reads with writes
**Severity:** Medium
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:454-523`
- `src/helpers/esp32/WebConfigServer.cpp:265-280`
- `src/helpers/CommonCLI.cpp:1074-1216`
- `src/helpers/CommonCLI_Observer.cpp:198-486`
**Problem:**
`handleConfigGet()` takes `_mux` while reading preferences, but `drainBatch()` invokes CLI setters outside `_mux`. Those setters mutate the same strings and scalar fields. The lock therefore does not protect the data from concurrent async HTTP reads.
**Suggested fix:**
Prefer loop-task ownership: have the HTTP handler request a configuration snapshot, let `tick()` build it on the loop task, and return the immutable snapshot. If retaining direct reads, every writer must take the same mutex, with careful review to avoid holding it through flash writes or callbacks.
### 11. Changing `mqtt.ntp` can block the main loop for up to 30 seconds
**Severity:** Medium
**Locations:**
- `src/helpers/CommonCLI_Observer.cpp:249-272`
- `src/helpers/bridges/MQTTBridge.cpp:3230-3247`
- `src/helpers/esp32/WebConfigServer.cpp:275-280`
**Problem:**
Web batches run from `tick()` on the Arduino loop task. The NTP setter waits for the MQTT task in a polling loop that can last 30 seconds. During that period mesh/radio processing, portal DNS, further batch work, stats, and reboot timers stop progressing.
**Suggested fix:**
Persist and validate hostname syntax synchronously, then queue NTP synchronization without waiting. Represent NTP validation as a separate asynchronous operation/status result. The config batch should finish immediately and the UI can poll NTP validation independently.
### 12. MQTT status snapshots read mutable cross-core state without synchronization
**Severity:** Medium
**Locations:**
- `src/helpers/bridges/MQTTBridge.cpp:295-320`
- `examples/simple_repeater/MyMesh.cpp:1403-1408`
- `examples/simple_room_server/MyMesh.cpp:1048-1053`
**Problem:**
The loop task reads slot state and client counters while the MQTT task and callbacks mutate them. This can yield inconsistent snapshots and formal C++ data races.
**Suggested fix:**
Build a plain-data `SlotStatusSnapshot` array on the MQTT task and publish it atomically or under a shared lock. The web/loop side should read only the copied snapshot and should not call client methods cross-task.
### 13. Fixed-delay server deletion does not prove connections have ended
**Severity:** Medium, hardware/library stress-test required
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:188-204`
**Problem:**
Stopping the listener and waiting two seconds does not establish that accepted slow or stalled clients have completed. Deleting the server and route lambdas while a request remains active risks use-after-free or crashes.
**Suggested fix:**
Use connection/request reference counting and finalize deletion only after all accepted requests disconnect, with an upper-bound recovery policy. If the library cannot expose lifecycle state safely, consider retaining one server instance for the firmware lifetime and enabling/disabling routes/listening without deleting captured handler state.
### 14. Repeated starts permanently grow the global default-header list
**Severity:** Medium
**Location:**
- `src/helpers/esp32/WebConfigServer.cpp:177-185`
**Problem:**
`DefaultHeaders::Instance().addHeader()` appends to a process-lifetime list on every server creation. Repeated start/stop cycles consume heap and add duplicate `Cache-Control` headers to every response.
**Suggested fix:**
Register the global header once through a static one-time guard, or avoid global headers and add `Cache-Control: no-store` to each WebConfig response.
### 15. Stats polling can accumulate overlapping requests
**Severity:** Low
**Locations:**
- `webui/index.html:717-747`
**Problem:**
A three-second `setInterval` starts a new request even if the previous fetch is still pending. Degraded Wi-Fi can accumulate requests and pressure both browser and ESP32 memory.
**Suggested fix:**
Schedule the next poll with `setTimeout` only after the current request settles. Add an in-flight guard and use the existing API timeout support.
## Priority 4: Validation, Build, and Maintainability
### 16. Malformed keys can cause out-of-bounds reads and invalid JSON errors
**Severity:** Low
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:41-59`
- `src/helpers/esp32/WebConfigServer.cpp:554-562`
**Problem:**
Short attacker-supplied keys are indexed at positions 4 and 5 without first establishing their length. Rejected keys are interpolated into hand-built JSON without escaping quotes or backslashes.
**Suggested fix:**
Check the key length before `memcmp` and indexed access. Construct error responses with ArduinoJson rather than string interpolation.
### 17. Input lengths do not match fixed firmware buffers
**Severity:** Low
**Locations:**
- `webui/index.html` configuration controls
- `src/helpers/CommonCLI.h:106-159`
**Problem:**
Several controls accept more text than the fixed `MQTTPrefs` fields retain. The CLI truncates values while the UI reports success.
**Suggested fix:**
Add `maxlength` values matching each destination buffer minus the NUL terminator, and validate lengths in the backend because client-side constraints are bypassable. Return a clear error instead of silently truncating.
### 18. Generated HTML freshness relies only on timestamps
**Severity:** Low
**Location:**
- `scripts/generate_webconfig_html.py:30-38`
**Problem:**
A generated header with a future timestamp can survive later source edits and embed stale UI code.
**Suggested fix:**
Use an explicit SCons source/target dependency or store the source hash in the generated header and regenerate whenever it differs. Deterministic gzip output can remain unchanged.
### 19. Tracker v1.1 reports itself as Tracker V2
**Severity:** Medium
**Locations:**
- `boards/heltec_tracker_v1_1.json:19,28`
- `variants/heltec_tracker_v2/platformio.ini:61-109`
- `variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp:82-84`
**Problem:**
The v1.1 environment reuses the V2 board implementation, whose manufacturer name is hardcoded as `Heltec Tracker V2`. WebConfig and MQTT metadata therefore report the wrong board.
**Suggested fix:**
Return `Heltec Tracker V1.1` when `HELTEC_TRACKER_V1_1` is defined, and retain the V2 value otherwise. Add a build-time or host-side assertion for both target identities.
### 20. WebConfig operation and security behavior are undocumented
**Severity:** Documentation gap
**Locations:**
- Commands added in `src/helpers/CommonCLI_Observer.cpp:982-994`
- `MQTT_IMPLEMENTATION.md`
**Problem:**
The build targets are documented, but operators cannot discover `start webconfig`, `start webconfig ap`, `stop webconfig`, first-boot AP behavior, authentication, timeout, or the security implications of setup mode.
**Suggested fix:**
Add an operator section to `MQTT_IMPLEMENTATION.md` covering:
- first-boot setup behavior;
- AP name and setup credential;
- LAN versus AP modes;
- exact CLI commands;
- authentication requirements;
- idle and absolute timeout behavior;
- how Wi-Fi changes are applied;
- how to recover through serial if provisioning fails.
### 21. Repeater and room-server integration is duplicated
**Severity:** Optimization
**Locations:**
- `examples/simple_repeater/MyMesh.*`
- `examples/simple_room_server/MyMesh.*`
- Corresponding `UITask.cpp` files
**Problem:**
Lifecycle, restart coalescing, stats construction, and display behavior are implemented twice. Fixes can easily land in only one role.
**Suggested fix:**
Extract shared WebConfig callbacks, lifecycle ownership, and JSON snapshot helpers into a common observer helper. Keep role-specific radio/stat sources as injected callbacks.
### 22. The HTML generator runs for every ESP32 build
**Severity:** Optimization
**Locations:**
- `platformio.ini:57-72`
**Problem:**
The generator runs from `esp32_base` even when WebConfig is not compiled into the target.
**Suggested fix:**
Move the pre-script to MQTT observer environments or have the script inspect build flags and return immediately unless `WITH_MQTT_BRIDGE`/WebConfig is enabled.
### 23. New observer targets enable MQTT debug logging
**Severity:** Optimization
**Locations:**
- `variants/heltec_tracker_v2/platformio.ini:190,226,262,298`
**Problem:**
`MQTT_DEBUG=1` increases serial activity and code/logging overhead in targets that otherwise appear intended for deployment.
**Suggested fix:**
Remove it from production environments or create explicit debug variants. Confirm that no diagnostic output includes credentials or tokens.
## Test Recommendations
No WebConfig-specific automated tests were found. Add focused tests for:
1. Save request/result correlation, including stale and concurrent batches.
2. Partial command failures and reboot gating.
3. Effective-value handling when fields are intentionally cleared.
4. Secret placeholder/edit semantics.
5. CLI parsing and backend length validation.
6. Runtime application of Wi-Fi and cached MQTT settings.
7. Complete custom-slot reconfiguration.
8. NTP updates without loop-task blocking.
9. Config and MQTT status snapshots under concurrent updates.
10. Repeated server start/stop heap behavior and duplicate headers.
11. Forced AP reachability from both SoftAP and STA networks.
12. Setup AP absolute expiry with an idle associated station.
13. Correct board identity for Tracker v1.1 and V2 targets.
## Verification Already Performed
The review ran the following checks successfully:
- `git diff --check`
- Extracted JavaScript with `node --check`
- PlatformIO builds:
- `heltec_tracker_v1_1_repeater_observer_mqtt`
- `heltec_tracker_v2_repeater_observer_mqtt`
- `heltec_tracker_v1_1_room_server_observer_mqtt`
- `heltec_tracker_v2_room_server_observer_mqtt`
Observed static usage:
- Repeater: 76,960 bytes RAM (23.5%), approximately 47.4% flash.
- Room server: 80,656 bytes RAM (24.6%), approximately 47.3% flash.
Successful compilation does not validate the AP/STA security boundary, async teardown, concurrency, reconnection, or partial-save behavior; those require the targeted host/hardware tests above.
## Suggested Implementation Order
1. Fix forced-AP LAN exposure and define secure provisioning authentication.
2. Introduce request IDs for save/result correlation.
3. Gate reboot on aggregate success and represent partial application explicitly.
4. Correct effective empty-value handling in the wizard.
5. Align Wi-Fi, MQTT flags, and custom-slot runtime behavior with the UI.
6. Remove NTP blocking and marshal config/status snapshots to their owning tasks.
7. Fix server lifecycle/global-header accumulation.
8. Add input-length/backend validation and polling safeguards.
9. Fix Tracker v1.1 identity and add operator documentation.
10. Add automated tests, then run the full hardware matrix.
+40
View File
@@ -0,0 +1,40 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"partitions": "default_8MB.csv"
},
"core": "esp32",
"extra_flags": [
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "heltec_tracker_v2"
},
"connectivity": ["wifi", "bluetooth", "lora"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino", "espidf"],
"name": "Heltec Wireless Tracker v1.1",
"upload": {
"flash_size": "8MB",
"maximum_ram_size": 327680,
"maximum_size": 8388608,
"use_1200bps_touch": true,
"wait_for_upload_port": true,
"require_upload_port": true,
"speed": 921600
},
"url": "https://heltec.org/project/wireless-tracker/",
"vendor": "Heltec"
}
+18
View File
@@ -0,0 +1,18 @@
{
"version": 1,
"author": "MeshCore",
"editor": "wokwi",
"parts": [
{ "type": "board-esp32-s3-devkitc-1", "id": "esp", "top": 0, "left": 0, "attrs": {} },
{ "type": "board-ssd1306", "id": "oled", "top": -110, "left": 90,
"attrs": { "i2cAddress": "0x3c" } }
],
"connections": [
[ "esp:3V3", "oled:VCC", "red", [] ],
[ "esp:GND.1", "oled:GND", "black", [] ],
[ "esp:17", "oled:SDA", "green", [] ],
[ "esp:18", "oled:SCL", "yellow", [] ],
[ "esp:TX", "$serialMonitor:RX", "", [] ],
[ "esp:RX", "$serialMonitor:TX", "", [] ]
]
}
+52
View File
@@ -102,8 +102,13 @@ arguments such as node names, passwords, and keys is left unchanged.
### Start or stop an Over-The-Air (OTA) firmware update
**Usage:**
- `start ota`
- `start ota ap`
- `stop ota`
`start ota` serves the web upload page on the station IP when connected to WiFi;
otherwise it raises the `MeshCore-OTA` access point. `start ota ap` always raises
the access point, which is useful when the normal network uses client isolation.
On an ESP32 build with WebConfig, the manual OTA uploader and WebConfig both use HTTP
port 80 and cannot run together. Stop WebConfig before `start ota`, or stop OTA
before `start webconfig`.
@@ -186,6 +191,20 @@ remain available.
---
### Discover neighbor scopes (MQTT observer, PSRAM only)
Refreshes the zero-hop neighbor table, then queries each neighbor for its region
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.
---
## Statistics
### Clear Stats
@@ -2408,6 +2427,39 @@ sleep, this command schedules a sync and wakes it; after `gps off`, it reports
---
#### View or change periodic neighbors publishing (MQTT observer, PSRAM only)
**Usage:**
- `get mqtt.neighbors`
- `set mqtt.neighbors <on|off>`
**Parameters:**
- `on`: periodically discover neighbor scopes and publish the neighbor table to the `neighbors` topic
- `off`: disable periodic neighbors publishing
**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.
> While enabled, `get mqtt.status` gains a trailing `nbr: <next>/<last>` field
> (time to next publish, and how the last publish went).
---
#### View or change the neighbors publish interval (MQTT observer, PSRAM only)
**Usage:**
- `get mqtt.neighbors.interval`
- `set mqtt.neighbors.interval <hours>`
**Parameters:**
- `hours`: how often to publish the neighbor table (12-336, default 24)
**Default:** `24` (hours)
> **Note:** Out-of-range values are rejected (not clamped). Requires a PSRAM board.
---
#### View or change the NTP server (MQTT observer only)
**Usage:**
- `get mqtt.ntp`
+445 -2
View File
@@ -87,6 +87,12 @@
#define CLI_REPLY_DELAY_MILLIS 600
// Max time to flush the outbound queue (START alert + CLI reply) before the OTA
// teardown blocks the loop until reboot. Best-effort: exits early once the queue
// drains (immediate on a healthy node), caps the wait on a jammed/duty-limited
// channel so an update is never stalled indefinitely.
#define OTA_TX_DRAIN_TIMEOUT_MS 5000
#define LAZY_CONTACTS_WRITE_DELAY 5000
#define FLOOD_CHANNEL_BLOCK_FILE "/flood_ch_block"
@@ -2218,7 +2224,22 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m
int MyMesh::searchPeersByHash(const uint8_t *hash) {
int n = 0;
for (int i = 0; i < acl.getNumClients(); i++) {
#if defined(WITH_MQTT_NEIGHBORS)
// While a neighbor-scope discovery is active, overlay the heard neighbours
// that are NOT already ACL clients so their RESPONSE packets can be decoded.
// Overlay indices are offset by NEIGHBOR_DISCOVER_PEER_BASE to keep them
// 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)) {
matching_peer_indexes[n++] = NEIGHBOR_DISCOVER_PEER_BASE + i;
}
}
}
#endif
for (int i = 0; i < acl.getNumClients() && n < MAX_CLIENTS; i++) {
if (acl.getClientByIdx(i)->id.isHashMatch(hash)) {
matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods)
}
@@ -2228,6 +2249,16 @@ int MyMesh::searchPeersByHash(const uint8_t *hash) {
void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) {
int i = matching_peer_indexes[peer_idx];
#if defined(WITH_MQTT_NEIGHBORS)
// Overlay entries have no precomputed shared secret; derive it on the fly.
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);
return;
}
}
#endif
if (i >= 0 && i < acl.getNumClients()) {
// lookup pre-calculated shared_secret
memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE);
@@ -2279,11 +2310,34 @@ void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret,
uint8_t *data, size_t len) {
int i = matching_peer_indexes[sender_idx];
#if defined(WITH_MQTT_NEIGHBORS)
// Overlay response: a heard neighbour (not an ACL client) answering our
// anon-regions scope query. Consume it and stop -- it is not a client packet.
if (neighbor_discover_active && i >= NEIGHBOR_DISCOVER_PEER_BASE) {
int oi = i - NEIGHBOR_DISCOVER_PEER_BASE;
if (type == PAYLOAD_TYPE_RESPONSE && oi >= 0 && oi < neighbor_discover_count) {
handleNeighborDiscoverResponse(oi, data, len);
}
return;
}
#endif
if (i < 0 || i >= acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context)
MESH_DEBUG_PRINTLN("onPeerDataRecv: invalid peer idx: %d", i);
return;
}
ClientInfo* client = acl.getClientByIdx(i);
#if defined(WITH_MQTT_NEIGHBORS)
// A neighbour that IS an ACL client resolves to a normal index above, so a
// 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)) {
return;
}
}
}
#endif
if (type == PAYLOAD_TYPE_REQ) { // request (from a Known admin client!)
uint32_t timestamp;
@@ -2683,6 +2737,16 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
pending_discover_tag = 0;
pending_discover_until = 0;
#if defined(WITH_MQTT_NEIGHBORS)
neighbor_discover_count = 0;
neighbor_discover_active = false;
neighbor_table_refresh_active = false;
neighbor_table_refresh_periodic = false;
neighbor_discover_until = 0;
next_neighbors_publish = 0;
self_scopes_buf[0] = 0;
#endif
memset(default_scope.key, 0, sizeof(default_scope.key));
}
@@ -2694,6 +2758,25 @@ void MyMesh::begin(FILESYSTEM *fs) {
// load persisted prefs
_cli.loadPrefs(_fs);
#ifdef SIM_WIFI_SSID
// Emulator builds (Wokwi) boot with fresh NVS every run. Seed WiFi so the
// observer auto-joins the simulator's network and brings the MQTT bridge up
// (WiFi is driven by the bridge task), instead of raising the setup AP that
// the emulator can't model. No-op for real firmware (flag never defined).
{
MQTTPrefs* obs = _cli.getObserverPrefs();
if (obs->wifi_ssid[0] == 0) {
strncpy(obs->wifi_ssid, SIM_WIFI_SSID, sizeof(obs->wifi_ssid) - 1);
obs->wifi_ssid[sizeof(obs->wifi_ssid) - 1] = 0;
#ifdef SIM_WIFI_PWD
strncpy(obs->wifi_password, SIM_WIFI_PWD, sizeof(obs->wifi_password) - 1);
obs->wifi_password[sizeof(obs->wifi_password) - 1] = 0;
#endif
_prefs.bridge_enabled = 1; // WiFi comes up via the MQTT bridge task
}
}
#endif
acl.load(_fs, self_id);
// TODO: key_store.begin();
region_map.load(_fs);
@@ -7174,6 +7257,36 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
sendNodeDiscoverReq();
strcpy(reply, "OK - Discover sent");
}
#if defined(WITH_MQTT_NEIGHBORS)
} else if (memcmp(command, "discover.scopes", 15) == 0) {
const char* sub = command + 15;
while (*sub == ' ') sub++;
if (*sub != 0) {
strcpy(reply, "Err - discover.scopes has no options");
} else if (pending_discover_tag != 0 &&
!millisHasNowPassed(pending_discover_until) &&
!neighbor_discover_active) {
// A zero-hop table refresh is already collecting; queue the scope pass
// behind it (as a manual, non-periodic request) rather than starting a
// second refresh.
if (!neighborDiscoverReady(reply)) {
// reply already set by neighborDiscoverReady
} else {
neighbor_table_refresh_active = true;
neighbor_table_refresh_periodic = false;
long remaining_ms = (long)(pending_discover_until - futureMillis(0));
unsigned remaining_secs = remaining_ms > 0
? (unsigned)(((unsigned long)remaining_ms + 999UL) / 1000UL) : 0;
sprintf(reply, "OK - scopes queued (%us discovery remaining)", remaining_secs);
MESH_DEBUG_PRINTLN("Neighbor scopes queued behind active discovery (%us remaining)", remaining_secs);
}
} else if (!startNeighborDiscover(reply)) {
// reply already set by startNeighborDiscover
}
#elif defined(WITH_MQTT_BRIDGE)
} else if (memcmp(command, "discover.scopes", 15) == 0) {
strcpy(reply, "Err - not supported (requires PSRAM)");
#endif
} else{
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
}
@@ -7232,12 +7345,30 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() {
// (so this never returns); on any abort (already up to date, partition change,
// download error) it returns and we resume the bridge.
Serial.println("OTA: starting update");
// Flush the START alert (and CLI reply) out the radio BEFORE teardown blocks
// the loop until reboot - otherwise a packet still queued here (busy /
// duty-limited channel) is lost when the flash spins the loop and reboots.
drainOutbound(OTA_TX_DRAIN_TIMEOUT_MS);
setBridgeState(false);
char ota_reply[160];
if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) {
// OTA teardown barrier (Phase 5): only flash after a CLEAN MQTT shutdown.
// A timed-out/forced stop leaves mbedTLS/heap ownership uncertain - writing
// firmware then is the observed teardown heap-panic path - so abort and
// resume the bridge instead of flashing under uncertain ownership.
if (mqtt_bridge && !mqtt_bridge->canFlashAfterStop()) {
Serial.println("OTA: aborted, MQTT stop did not complete cleanly - resuming bridge");
otaAlert("OTA aborted: MQTT stop unclean, bridge resumed");
setBridgeState(true);
} else if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) {
Serial.print("OTA: aborted, resuming bridge - "); Serial.println(ota_reply);
char ota_alert_msg[160];
snprintf(ota_alert_msg, sizeof(ota_alert_msg), "OTA aborted: %s", ota_reply);
otaAlert(ota_alert_msg);
setBridgeState(true);
}
// Success path: otaFromManifest() flashes and reboots into the new image
// (never returns), so there is no in-boot "success" alert - the START alert
// plus the node returning on the new version is the success signal.
}
#endif
@@ -7275,6 +7406,66 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() {
_alerter.onLoop(now);
#endif
#if defined(WITH_MQTT_NEIGHBORS)
// Two-stage periodic neighbors publication:
// stage 1 - zero-hop node-discover refreshes the neighbour table (60s window)
// stage 2 - anon-regions scope query per neighbour (startNeighborDiscover)
// then the table JSON is published and the next cycle is rescheduled.
bool periodic_neighbors_enabled = _cli.getObserverPrefs()->mqtt_neighbors_enabled;
if (neighbor_discover_active) {
loopNeighborDiscover();
} else if (neighbor_table_refresh_active) {
if (neighbor_table_refresh_periodic && !periodic_neighbors_enabled) {
// periodic switched off mid-refresh -> cancel (leave pending_discover_tag alone)
neighbor_table_refresh_active = false;
neighbor_table_refresh_periodic = false;
next_neighbors_publish = 0;
} else if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) {
// 60s zero-hop window done -> begin the per-neighbour scope queries
bool was_periodic = neighbor_table_refresh_periodic;
pending_discover_tag = 0;
neighbor_table_refresh_active = false;
neighbor_table_refresh_periodic = false;
char tmp_reply[80];
const char* origin_str = was_periodic ? "periodic" : "manual";
if (startNeighborDiscover(tmp_reply)) {
MESH_DEBUG_PRINTLN("MQTT %s %s", origin_str, tmp_reply);
} else {
if (periodic_neighbors_enabled) {
next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval);
}
MESH_DEBUG_PRINTLN("MQTT %s neighbor scope discovery failed: %s", origin_str, tmp_reply);
}
}
} else if (periodic_neighbors_enabled && mqtt_bridge && mqtt_bridge->isRunning()) {
if (next_neighbors_publish == 0 ||
(next_neighbors_publish != 0 && millisHasNowPassed(next_neighbors_publish))) {
if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) {
pending_discover_tag = 0;
sendNodeDiscoverReq();
MESH_DEBUG_PRINTLN("MQTT periodic neighbor table refresh started");
} else {
MESH_DEBUG_PRINTLN("MQTT periodic refresh joined active neighbor discovery");
}
neighbor_table_refresh_active = true;
neighbor_table_refresh_periodic = true;
}
}
// Report the schedule state back to the bridge for `get mqtt.status`.
if (mqtt_bridge) {
if (neighbor_discover_active || neighbor_table_refresh_active) {
mqtt_bridge->setNeighborsSchedule(MQTTBridge::NBR_ACTIVE, 0);
} else if (next_neighbors_publish == 0 || millisHasNowPassed(next_neighbors_publish)) {
mqtt_bridge->setNeighborsSchedule(MQTTBridge::NBR_DUE, 0);
} else {
long remaining_ms = (long)(next_neighbors_publish - futureMillis(0));
uint32_t remaining_secs = remaining_ms > 0 ? (uint32_t)(remaining_ms / 1000) : 0;
mqtt_bridge->setNeighborsSchedule(MQTTBridge::NBR_SCHEDULED, remaining_secs);
}
}
#endif
#ifdef WITH_SNMP
// Push radio stats to SNMP agent every 2 seconds
if (_snmp_agent.isRunning()) {
@@ -7295,6 +7486,258 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() {
#endif
}
#if defined(WITH_MQTT_NEIGHBORS)
#include "helpers/MQTTMessageBuilder.h"
#if defined(ESP_PLATFORM)
#include <esp_heap_caps.h>
#endif
// This node's own non-flood scope names, same source the anon-regions server
// reply uses. Empty string when the node has no scoped regions.
void MyMesh::getLocalScopes(char* buf, size_t len) {
if (!buf || len == 0) return;
buf[0] = 0;
region_map.exportNamesTo(buf, (int)len, REGION_DENY_FLOOD);
}
// 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) {
uint8_t secret[PUB_KEY_SIZE];
self_id.calcSharedSecret(secret, target);
tag = getRTCClock()->getCurrentTimeUnique();
uint8_t inner[6];
memcpy(inner, &tag, 4);
inner[4] = ANON_REQ_TYPE_REGIONS;
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;
sendDirect(pkt, NULL, 0, 0);
return true;
}
// Match a RESPONSE against the pending overlay entry by tag; copy its scope
// string (payload after the 8-byte {tag}{clock} header) into the entry.
bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len) {
if (overlay_idx < 0 || overlay_idx >= neighbor_discover_count) return false;
NeighborDiscoverEntry& entry = neighbor_discover[overlay_idx];
if (entry.status != ND_PENDING || len < 8) return false;
uint32_t tag;
memcpy(&tag, data, 4);
if (tag != entry.tag) return false;
size_t scope_len = len - 8;
if (scope_len >= sizeof(entry.scopes)) {
scope_len = sizeof(entry.scopes) - 1;
}
memcpy(entry.scopes, &data[8], scope_len);
entry.scopes[scope_len] = 0;
entry.status = ND_RESPONDED;
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.
static bool neighborPublishEntryComesBefore(
const MQTTMessageBuilder::NeighborsMessageEntry& lhs,
const MQTTMessageBuilder::NeighborsMessageEntry& rhs) {
if (lhs.heard_secs_ago != rhs.heard_secs_ago) {
return lhs.heard_secs_ago < rhs.heard_secs_ago; // newer first
}
if (lhs.snr != rhs.snr) {
return lhs.snr > rhs.snr; // stronger first when equally recent
}
return strcmp(lhs.pubkey_hex, rhs.pubkey_hex) < 0;
}
#if defined(ESP_PLATFORM)
// 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;
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);
if (p) used += size;
return p;
}
void deallocate(void* ptr) override {
heap_caps_free(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);
if (p) used = next_used + new_size;
return p;
}
};
#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));
char pubkey_hex[MAX_NEIGHBOURS][65];
MQTTMessageBuilder::NeighborsMessageEntry entries[MAX_NEIGHBOURS];
uint32_t now_secs = getRTCClock()->getCurrentTime();
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;
}
}
// 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) {
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);
#else
free(json_buf);
#endif
neighbor_discover_active = false;
neighbor_discover_count = 0;
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).
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 (!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;
}
}
finishNeighborDiscover();
}
// Shared precondition for starting a discovery: PSRAM present + bridge running.
bool MyMesh::neighborDiscoverReady(char* reply) {
#if defined(ESP_PLATFORM)
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.
bool MyMesh::startNeighborDiscover(char* reply) {
if (neighbor_discover_active) {
strcpy(reply, "Err - neighbor discover already active");
return false;
}
if (!neighborDiscoverReady(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;
}
neighbor_discover_count++;
}
}
neighbor_discover_active = true;
neighbor_discover_until = futureMillis(NEIGHBOR_DISCOVER_TIMEOUT_MS);
if (neighbor_discover_count == 0) {
finishNeighborDiscover();
strcpy(reply, "OK - neighbor discover started (0 neighbors, self only)");
} else {
sprintf(reply, "OK - neighbor discover started (%u neighbors)", (unsigned)neighbor_discover_count);
}
return true;
}
#endif // WITH_MQTT_NEIGHBORS
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
if (deferred_cli_command.pending || pending_self_advert) return true;
+81 -5
View File
@@ -46,6 +46,7 @@
#ifdef WITH_MQTT_BRIDGE
#include "helpers/bridges/MQTTBridge.h"
#define WITH_BRIDGE
#include "helpers/esp32/WebConfigServer.h" // defines WITH_WEBCONFIG on ESP32
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
@@ -376,6 +377,44 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint8_t _wc_slot_restart_mask = 0;
#endif
#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.
enum NeighborDiscoverStatus : uint8_t {
ND_PENDING = 1,
ND_RESPONDED = 2,
ND_TIMEOUT = 3,
ND_SEND_FAILED = 4,
};
struct NeighborDiscoverEntry {
uint8_t neighbour_idx; // index into neighbours[]
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;
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 next_neighbors_publish; // periodic publish deadline (0 = fire ASAP)
char self_scopes_buf[96];
bool sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag);
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 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;
#endif
bool extractDirectRetryPrefix(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const;
int8_t getDirectRetryMinSNRX4() const;
uint8_t getDirectRetryCodingRateForSNR(int8_t snr_x4) const;
@@ -554,9 +593,6 @@ protected:
int getInterferenceThreshold() const override {
return _prefs.interference_threshold;
}
bool getCADEnabled() const override {
return _prefs.cad_enabled;
}
int getAGCResetInterval() const override {
return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds
}
@@ -720,7 +756,7 @@ public:
AbstractBridge* active_bridge = activeBridge();
if (!active_bridge || !active_bridge->isRunning()) return;
#ifdef WITH_WEBCONFIG
if (_wc_batch_active) {
if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd()
_wc_restart_pending = true;
return;
}
@@ -755,12 +791,52 @@ public:
#endif
}
#if defined(WITH_MQTT_BRIDGE)
// Broadcast a key OTA milestone (start/fail only) on the configured alert
// channel, in addition to the Serial log -- so an operator who triggered
// `ota update` via remote management still gets feedback that lands well after
// the command's reply window. Respects the `alert on/off` master switch and
// rides the configured alert scope (sendChannel -> resolveAlertScope); a no-op
// when alerts are off or no channel is set. Deliberately NOT wired to routine
// slot connect/disconnect -- those remain in AlertReporter's fault logic.
void otaAlert(const char* msg) {
auto* obs = _cli.getObserverPrefs();
if (obs && obs->alert_enabled) _alerter.sendText(msg);
}
// Best-effort flush of the outbound packet queue before an OTA teardown that
// blocks the loop until reboot. The START alert (otaAlert) and the CLI reply
// are queued fire-and-forget (delay 0 / CLI_REPLY_DELAY_MILLIS); once
// setBridgeState(false) + otaFromManifest() run they spin the loop task until
// the chip reboots, so anything still in the send queue at that point is
// silently lost -- the observed "OTA update starting never arrives" case on a
// busy / duty-limited channel where the packet can't win a TX slot inside the
// 2.5 s window. Pump the mesh loop so already-queued packets get their airtime,
// bounded by timeout_ms so a jammed or budget-exhausted channel can't stall the
// update. Respects duty cycle / CAD: it only drains what is queued, it does not
// force a transmit. Returns instantly on a healthy node (queue already empty).
void drainOutbound(uint32_t timeout_ms) {
unsigned long start = millis();
while (hasOutbound() || _mgr->getOutboundCount(millis()) > 0) {
if (millis() - start >= timeout_ms) break;
mesh::Mesh::loop(); // base dispatcher only -- drives RX + checkSend()/TX
delay(1); // yield to the radio ISR / other FreeRTOS tasks
}
}
#endif
// Schedule the pull-OTA flash to run from loop() in ~2.5 s, leaving time for the
// "Beginning update..." CLI reply (CLI_REPLY_DELAY_MILLIS = 600 ms) to transmit
// before the flash blocks the loop and reboots.
bool beginDeferredOtaUpdate() override {
_ota_update_at = millis() + 2500;
if (_ota_update_at == 0) _ota_update_at = 1; // 0 means "none"
#if defined(WITH_MQTT_BRIDGE)
// Broadcast START now, while the loop still runs (the 2.5 s reply window):
// the deferred flash blocks the loop and, on success, reboots -- so a start
// alert queued at fire time could never transmit. See otaAlert().
otaAlert("OTA update starting");
#endif
return true;
}
@@ -784,7 +860,7 @@ public:
#ifdef WITH_MQTT_BRIDGE
if (!mqtt_bridge || !mqtt_bridge->isRunning()) return false;
// Marshal onto the MQTT task (Core 0); this runs on the CLI thread (Core 1).
return mqtt_bridge->requestForcedNtpSync();
return mqtt_bridge->requestForcedNtpSync(0);
#else
return false;
#endif
+9
View File
@@ -191,6 +191,15 @@ void UITask::loop() {
}
#endif
#ifdef WITH_WEBCONFIG
// While the setup portal is up there's no user button to wake the screen
// reliably - keep it on so the join instructions stay visible.
if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) {
if (!_display->isOn()) _display->turnOn();
_auto_off = millis() + AUTO_OFF_MILLIS;
}
#endif
if (_display->isOn()) {
if (millis() >= _next_refresh) {
_display->startFrame();
+639 -1
View File
@@ -1,5 +1,6 @@
#include "MyMesh.h"
#include <helpers/CLICommandUtils.h>
#include <algorithm>
#include <helpers/RxReservePacketManager.h>
#ifdef WITH_WEBCONFIG
#include <WiFi.h>
@@ -12,6 +13,8 @@ static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
return delay_ms > 30000UL ? 30000UL : delay_ms;
}
#define ANON_REQ_TYPE_REGIONS 0x01 // client side of the anon-regions scope query (neighbors feature)
#define REPLY_DELAY_MILLIS 1500
#define PUSH_NOTIFY_DELAY_MILLIS 2000
#define SYNC_PUSH_INTERVAL 1200
@@ -31,6 +34,10 @@ static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
#define RESP_SERVER_LOGIN_OK 0 // response to ANON_REQ
// Best-effort bound for the queued CLI reply before OTA blocks the loop and
// reboots. Do not let a busy or duty-limited channel stall the update forever.
#define OTA_TX_DRAIN_TIMEOUT_MS 5000
#define LAZY_CONTACTS_WRITE_DELAY 5000
struct ServerStats {
@@ -499,7 +506,21 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m
int MyMesh::searchPeersByHash(const uint8_t *hash) {
int n = 0;
for (int i = 0; i < acl.getNumClients(); i++) {
#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];
// 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)) {
matching_peer_indexes[n++] = NEIGHBOR_DISCOVER_PEER_BASE + i;
}
}
}
#endif
for (int i = 0; i < acl.getNumClients() && n < MAX_CLIENTS; i++) {
if (acl.getClientByIdx(i)->id.isHashMatch(hash)) {
matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods)
}
@@ -509,6 +530,15 @@ int MyMesh::searchPeersByHash(const uint8_t *hash) {
void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) {
int i = matching_peer_indexes[peer_idx];
#if defined(WITH_MQTT_NEIGHBORS)
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);
return;
}
}
#endif
if (i >= 0 && i < acl.getNumClients()) {
// lookup pre-calculated shared_secret
memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE);
@@ -520,11 +550,34 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) {
void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret,
uint8_t *data, size_t len) {
int i = matching_peer_indexes[sender_idx];
#if defined(WITH_MQTT_NEIGHBORS)
// Overlay response: a heard neighbour (not an ACL client) answering our
// anon-regions scope query. Consume it and stop -- it is not a client packet.
if (neighbor_discover_active && i >= NEIGHBOR_DISCOVER_PEER_BASE) {
int oi = i - NEIGHBOR_DISCOVER_PEER_BASE;
if (type == PAYLOAD_TYPE_RESPONSE && oi >= 0 && oi < neighbor_discover_count) {
handleNeighborDiscoverResponse(oi, data, len);
}
return;
}
#endif
if (i < 0 || i >= acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context)
MESH_DEBUG_PRINTLN("onPeerDataRecv: invalid peer idx: %d", i);
return;
}
auto client = acl.getClientByIdx(i);
#if defined(WITH_MQTT_NEIGHBORS)
// A neighbour that IS an ACL client resolves to a normal index above, so a
// 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)) {
return;
}
}
}
#endif
if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { // a CLI command or new Post
uint32_t sender_timestamp;
memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong)
@@ -714,6 +767,107 @@ void MyMesh::onAckRecv(mesh::Packet *packet, uint32_t ack_crc) {
}
}
#if defined(WITH_MQTT_NEIGHBORS)
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80
#define CTL_TYPE_NODE_DISCOVER_RESP 0x90
void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) {
// find existing neighbour, else use least recently updated
uint32_t oldest_timestamp = 0xFFFFFFFF;
NeighbourInfo *neighbour = &neighbours[0];
for (int i = 0; i < MAX_NEIGHBOURS; i++) {
// if neighbour already known, we should update it
if (id.matches(neighbours[i].id)) {
neighbour = &neighbours[i];
break;
}
// otherwise we should update the least recently updated neighbour
if (neighbours[i].heard_timestamp < oldest_timestamp) {
neighbour = &neighbours[i];
oldest_timestamp = neighbour->heard_timestamp;
}
}
// update neighbour info
neighbour->id = id;
neighbour->advert_timestamp = timestamp;
neighbour->heard_timestamp = getRTCClock()->getCurrentTime();
neighbour->snr = (int8_t)(snr * 4);
}
static bool isShare(const mesh::Packet *packet) {
if (packet->hasTransportCodes()) {
return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere'
}
return false;
}
void MyMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32_t timestamp,
const uint8_t *app_data, size_t app_data_len) {
mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); // chain to super impl
// if this a zero hop advert (and not via 'Share'), add it to neighbours
if (packet->getPathHashCount() == 0 && !isShare(packet)) {
AdvertDataParser parser(app_data, app_data_len);
if (parser.isValid() && parser.getType() == ADV_TYPE_REPEATER) { // just keep neigbouring Repeaters
putNeighbour(id, timestamp, packet->getSNR());
}
}
}
void MyMesh::onControlDataRecv(mesh::Packet* packet) {
uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits
// A room server is ADV_TYPE_ROOM, so it does NOT answer node-discover requests
// (those filter for repeaters). It only records repeater responses to its own
// discovery, to build the neighbour table.
if (type == CTL_TYPE_NODE_DISCOVER_RESP && packet->payload_len >= 6) {
uint8_t node_type = packet->payload[0] & 0x0F;
if (node_type != ADV_TYPE_REPEATER) {
return;
}
if (packet->payload_len < 6 + PUB_KEY_SIZE) {
MESH_DEBUG_PRINTLN("onControlDataRecv: DISCOVER_RESP pubkey too short: %d", (uint32_t)packet->payload_len);
return;
}
if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) {
pending_discover_tag = 0;
return;
}
uint32_t tag;
memcpy(&tag, &packet->payload[2], 4);
if (tag != pending_discover_tag) {
return;
}
mesh::Identity id(&packet->payload[6]);
if (id.matches(self_id)) {
return;
}
putNeighbour(id, getRTCClock()->getCurrentTime(), packet->getSNR());
}
}
void MyMesh::sendNodeDiscoverReq() {
uint8_t data[10];
data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // prefix_only=0
data[1] = (1 << ADV_TYPE_REPEATER);
getRNG()->random(&data[2], 4); // tag
memcpy(&pending_discover_tag, &data[2], 4);
pending_discover_until = futureMillis(60000);
uint32_t since = 0;
memcpy(&data[6], &since, 4);
auto pkt = createControlData(data, sizeof(data));
if (pkt) {
sendZeroHop(pkt);
}
}
#endif // WITH_MQTT_NEIGHBORS
MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng,
mesh::RTCClock &rtc, mesh::MeshTables &tables)
: mesh::Mesh(radio, ms, rng, rtc, *createObserverPacketManager(32), tables),
@@ -802,6 +956,19 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
memset(posts, 0, sizeof(posts));
_num_posted = _num_post_pushes = 0;
#if defined(WITH_MQTT_NEIGHBORS)
pending_discover_tag = 0;
pending_discover_until = 0;
neighbor_discover_count = 0;
neighbor_discover_active = false;
neighbor_table_refresh_active = false;
neighbor_table_refresh_periodic = false;
neighbor_discover_until = 0;
next_neighbors_publish = 0;
self_scopes_buf[0] = 0;
memset(neighbours, 0, sizeof(neighbours));
#endif
memset(default_scope.key, 0, sizeof(default_scope.key));
}
@@ -1301,6 +1468,65 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
}
#endif
void MyMesh::formatNeighborsReply(char *reply) {
#if defined(WITH_MQTT_NEIGHBORS)
char *dp = reply;
// create copy of neighbours list, skipping empty entries so we can sort it separately from main list
int16_t neighbours_count = 0;
NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS];
for (int i = 0; i < MAX_NEIGHBOURS; i++) {
auto neighbour = &neighbours[i];
if (neighbour->heard_timestamp > 0) {
sorted_neighbours[neighbours_count] = neighbour;
neighbours_count++;
}
}
// sort neighbours newest to oldest
std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) {
return a->heard_timestamp > b->heard_timestamp; // desc
});
for (int i = 0; i < neighbours_count && dp - reply < 134; i++) {
NeighbourInfo *neighbour = sorted_neighbours[i];
// add new line if not first item
if (i > 0) *dp++ = '\n';
char hex[10];
// get 4 bytes of neighbour id as hex
mesh::Utils::toHex(hex, neighbour->id.pub_key, 4);
// add next neighbour
uint32_t secs_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp;
sprintf(dp, "%s:%d:%d", hex, secs_ago, neighbour->snr);
while (*dp)
dp++; // find end of string
}
if (dp == reply) { // no neighbours, need empty response
strcpy(dp, "-none-");
dp += 6;
}
*dp = 0; // null terminator
#else
strcpy(reply, "not supported");
#endif
}
void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) {
#if defined(WITH_MQTT_NEIGHBORS)
for (int i = 0; i < MAX_NEIGHBOURS; i++) {
NeighbourInfo *neighbour = &neighbours[i];
if (memcmp(neighbour->id.pub_key, pubkey, key_len) == 0) {
neighbours[i] = NeighbourInfo(); // clear neighbour entry
}
}
#else
(void)pubkey; (void)key_len;
#endif
}
void MyMesh::formatStatsReply(char *reply) {
StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr);
}
@@ -1309,11 +1535,16 @@ void MyMesh::formatRadioStatsReply(char *reply) {
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime());
}
void MyMesh::formatRadioDiagReply(char *reply) {
StatsFormatHelper::formatRadioDiag(reply, _radio, radio_driver, *_ms, _err_flags, hasOutbound());
}
void MyMesh::formatPacketStatsReply(char *reply) {
StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(),
getNumRecvFlood(), getNumRecvDirect());
}
void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) {
if (region_load_active) {
if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation
@@ -1404,6 +1635,45 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
Serial.printf("\n");
}
reply[0] = 0;
#if defined(WITH_MQTT_NEIGHBORS)
} else if (memcmp(command, "discover.neighbors", 18) == 0) {
const char* sub = command + 18;
while (*sub == ' ') sub++;
if (*sub != 0) {
strcpy(reply, "Err - discover.neighbors has no options");
} else {
sendNodeDiscoverReq();
strcpy(reply, "OK - Discover sent");
}
} else if (memcmp(command, "discover.scopes", 15) == 0) {
const char* sub = command + 15;
while (*sub == ' ') sub++;
if (*sub != 0) {
strcpy(reply, "Err - discover.scopes has no options");
} else if (pending_discover_tag != 0 &&
!millisHasNowPassed(pending_discover_until) &&
!neighbor_discover_active) {
// A zero-hop table refresh is already collecting; queue the scope pass
// behind it (as a manual, non-periodic request) rather than starting a
// second refresh.
if (!neighborDiscoverReady(reply)) {
// reply already set by neighborDiscoverReady
} else {
neighbor_table_refresh_active = true;
neighbor_table_refresh_periodic = false;
long remaining_ms = (long)(pending_discover_until - futureMillis(0));
unsigned remaining_secs = remaining_ms > 0
? (unsigned)(((unsigned long)remaining_ms + 999UL) / 1000UL) : 0;
sprintf(reply, "OK - scopes queued (%us discovery remaining)", remaining_secs);
MESH_DEBUG_PRINTLN("Neighbor scopes queued behind active discovery (%us remaining)", remaining_secs);
}
} else if (!startNeighborDiscover(reply)) {
// reply already set by startNeighborDiscover
}
#elif defined(WITH_MQTT_BRIDGE)
} else if (memcmp(command, "discover.scopes", 15) == 0) {
strcpy(reply, "Err - not supported (requires PSRAM)");
#endif
} else{
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
}
@@ -1559,6 +1829,44 @@ void MyMesh::loop() {
}
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE)
if (_ota_update_at && millisHasNowPassed(_ota_update_at)) { // deferred `ota update`
_ota_update_at = 0; // clear timer
// The "Beginning update..." reply has now been queued. Flush it before OTA
// blocks the loop until reboot, then free a running bridge for heap headroom.
// Remember its state: an OTA request must not enable MQTT that an operator
// had deliberately stopped.
Serial.println("OTA: starting update");
const bool bridge_was_running = bridge && bridge->isRunning();
drainOutbound(OTA_TX_DRAIN_TIMEOUT_MS);
bool may_flash = true;
if (bridge_was_running) {
setBridgeState(false);
// OTA must not write after a forced/timed-out MQTT shutdown: its TLS/heap
// ownership is uncertain until a subsequent clean start/stop cycle.
may_flash = bridge && bridge->canFlashAfterStop();
if (!may_flash) {
Serial.println("OTA: aborted, MQTT stop did not complete cleanly");
}
}
char ota_reply[160];
if (may_flash && !_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) {
Serial.print("OTA: aborted - "); Serial.println(ota_reply);
may_flash = false;
}
// Successful otaFromManifest() reboots and never returns. Restore only a
// bridge that was running before this attempt; leave an intentionally
// stopped bridge stopped after any OTA refusal or download failure.
if (!may_flash && bridge_was_running) {
Serial.println("OTA: resuming bridge");
setBridgeState(true);
}
}
#endif
// is pending dirty contacts write needed?
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
acl.save(_fs, MyMesh::saveFilter);
@@ -1575,6 +1883,85 @@ void MyMesh::loop() {
#ifdef WITH_MQTT_BRIDGE
_alerter.onLoop(now);
#endif
#if defined(WITH_MQTT_NEIGHBORS)
// Two-stage periodic neighbors publication:
// stage 1 - zero-hop node-discover refreshes the neighbour table (60s window)
// stage 2 - anon-regions scope query per neighbour (startNeighborDiscover)
// then the table JSON is published and the next cycle is rescheduled.
bool periodic_neighbors_enabled = _cli.getObserverPrefs()->mqtt_neighbors_enabled;
if (neighbor_discover_active) {
loopNeighborDiscover();
} else if (neighbor_table_refresh_active) {
if (neighbor_table_refresh_periodic && !periodic_neighbors_enabled) {
// periodic switched off mid-refresh -> cancel (leave pending_discover_tag alone)
neighbor_table_refresh_active = false;
neighbor_table_refresh_periodic = false;
next_neighbors_publish = 0;
} else if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) {
// 60s zero-hop window done -> begin the per-neighbour scope queries
bool was_periodic = neighbor_table_refresh_periodic;
pending_discover_tag = 0;
neighbor_table_refresh_active = false;
neighbor_table_refresh_periodic = false;
char tmp_reply[80];
const char* origin_str = was_periodic ? "periodic" : "manual";
if (startNeighborDiscover(tmp_reply)) {
MESH_DEBUG_PRINTLN("MQTT %s %s", origin_str, tmp_reply);
} else {
if (periodic_neighbors_enabled) {
next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval);
}
MESH_DEBUG_PRINTLN("MQTT %s neighbor scope discovery failed: %s", origin_str, tmp_reply);
}
}
} else if (periodic_neighbors_enabled && bridge && bridge->isRunning()) {
if (next_neighbors_publish == 0 ||
(next_neighbors_publish != 0 && millisHasNowPassed(next_neighbors_publish))) {
if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) {
pending_discover_tag = 0;
sendNodeDiscoverReq();
MESH_DEBUG_PRINTLN("MQTT periodic neighbor table refresh started");
} else {
MESH_DEBUG_PRINTLN("MQTT periodic refresh joined active neighbor discovery");
}
neighbor_table_refresh_active = true;
neighbor_table_refresh_periodic = true;
}
}
// Report the schedule state back to the bridge for `get mqtt.status`.
if (bridge) {
if (neighbor_discover_active || neighbor_table_refresh_active) {
bridge->setNeighborsSchedule(MQTTBridge::NBR_ACTIVE, 0);
} else if (next_neighbors_publish == 0 || millisHasNowPassed(next_neighbors_publish)) {
bridge->setNeighborsSchedule(MQTTBridge::NBR_DUE, 0);
} else {
long remaining_ms = (long)(next_neighbors_publish - futureMillis(0));
uint32_t remaining_secs = remaining_ms > 0 ? (uint32_t)(remaining_ms / 1000) : 0;
bridge->setNeighborsSchedule(MQTTBridge::NBR_SCHEDULED, remaining_secs);
}
}
#endif
#ifdef WITH_SNMP
// Push radio stats to SNMP agent every 2 seconds
if (_snmp_agent.isRunning()) {
static unsigned long last_snmp_stats = 0;
if (now - last_snmp_stats >= 2000) {
last_snmp_stats = now;
_snmp_agent.updateRadioStats(
radio_driver.getPacketsRecv(), radio_driver.getPacketsSent(),
radio_driver.getPacketsRecvErrors(),
(int16_t)_radio->getNoiseFloor(),
(int16_t)radio_driver.getLastRSSI(),
(int16_t)(radio_driver.getLastSNR() * 4),
getNumSentFlood(), getNumSentDirect(),
getNumRecvFlood(), getNumRecvDirect(),
getTotalAirTime() / 1000, uptime_millis / 1000);
}
}
#endif
}
bool MyMesh::isMillisTimerDue(unsigned long timestamp) const {
@@ -1642,3 +2029,254 @@ bool MyMesh::hasPendingWork() const {
|| (saved_radio_apply_pending && !temp_radio_applied))) return true;
return isMillisTimerDue(dirty_contacts_expiry);
}
#if defined(WITH_MQTT_NEIGHBORS)
#include "helpers/MQTTMessageBuilder.h"
#if defined(ESP_PLATFORM)
#include <esp_heap_caps.h>
#endif
// This node's own non-flood scope names, same source the anon-regions server
// reply uses. Empty string when the node has no scoped regions.
void MyMesh::getLocalScopes(char* buf, size_t len) {
if (!buf || len == 0) return;
buf[0] = 0;
region_map.exportNamesTo(buf, (int)len, REGION_DENY_FLOOD);
}
// 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) {
uint8_t secret[PUB_KEY_SIZE];
self_id.calcSharedSecret(secret, target);
tag = getRTCClock()->getCurrentTimeUnique();
uint8_t inner[6];
memcpy(inner, &tag, 4);
inner[4] = ANON_REQ_TYPE_REGIONS;
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;
sendDirect(pkt, NULL, 0, 0);
return true;
}
// Match a RESPONSE against the pending overlay entry by tag; copy its scope
// string (payload after the 8-byte {tag}{clock} header) into the entry.
bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len) {
if (overlay_idx < 0 || overlay_idx >= neighbor_discover_count) return false;
NeighborDiscoverEntry& entry = neighbor_discover[overlay_idx];
if (entry.status != ND_PENDING || len < 8) return false;
uint32_t tag;
memcpy(&tag, data, 4);
if (tag != entry.tag) return false;
size_t scope_len = len - 8;
if (scope_len >= sizeof(entry.scopes)) {
scope_len = sizeof(entry.scopes) - 1;
}
memcpy(entry.scopes, &data[8], scope_len);
entry.scopes[scope_len] = 0;
entry.status = ND_RESPONDED;
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.
static bool neighborPublishEntryComesBefore(
const MQTTMessageBuilder::NeighborsMessageEntry& lhs,
const MQTTMessageBuilder::NeighborsMessageEntry& rhs) {
if (lhs.heard_secs_ago != rhs.heard_secs_ago) {
return lhs.heard_secs_ago < rhs.heard_secs_ago; // newer first
}
if (lhs.snr != rhs.snr) {
return lhs.snr > rhs.snr; // stronger first when equally recent
}
return strcmp(lhs.pubkey_hex, rhs.pubkey_hex) < 0;
}
#if defined(ESP_PLATFORM)
// 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;
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);
if (p) used += size;
return p;
}
void deallocate(void* ptr) override {
heap_caps_free(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);
if (p) used = next_used + new_size;
return p;
}
};
#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));
char pubkey_hex[MAX_NEIGHBOURS][65];
MQTTMessageBuilder::NeighborsMessageEntry entries[MAX_NEIGHBOURS];
uint32_t now_secs = getRTCClock()->getCurrentTime();
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;
}
}
// 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) {
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);
#else
free(json_buf);
#endif
neighbor_discover_active = false;
neighbor_discover_count = 0;
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).
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 (!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;
}
}
finishNeighborDiscover();
}
// Shared precondition for starting a discovery: PSRAM present + bridge running.
bool MyMesh::neighborDiscoverReady(char* reply) {
#if defined(ESP_PLATFORM)
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.
bool MyMesh::startNeighborDiscover(char* reply) {
if (neighbor_discover_active) {
strcpy(reply, "Err - neighbor discover already active");
return false;
}
if (!neighborDiscoverReady(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;
}
neighbor_discover_count++;
}
}
neighbor_discover_active = true;
neighbor_discover_until = futureMillis(NEIGHBOR_DISCOVER_TIMEOUT_MS);
if (neighbor_discover_count == 0) {
finishNeighborDiscover();
strcpy(reply, "OK - neighbor discover started (0 neighbors, self only)");
} else {
sprintf(reply, "OK - neighbor discover started (%u neighbors)", (unsigned)neighbor_discover_count);
}
return true;
}
#endif // WITH_MQTT_NEIGHBORS
+96 -9
View File
@@ -28,6 +28,11 @@
#ifdef WITH_MQTT_BRIDGE
#include "helpers/bridges/MQTTBridge.h"
#define WITH_BRIDGE
#include "helpers/esp32/WebConfigServer.h" // defines WITH_WEBCONFIG on ESP32
#endif
#ifdef WITH_SNMP
#include "helpers/SNMPAgent.h"
#endif
@@ -99,6 +104,13 @@ struct PostInfo {
char text[MAX_POST_TEXT_LEN+1];
};
struct NeighbourInfo {
mesh::Identity id;
uint32_t advert_timestamp;
uint32_t heard_timestamp;
int8_t snr; // multiplied by 4, user should divide to get float value
};
class MyMesh : public mesh::Mesh, public CommonCLICallbacks
#ifdef WITH_WEBCONFIG
, public WebConfigServer::Callbacks
@@ -127,6 +139,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
RegionEntry* recv_pkt_region;
TransportKey default_scope;
unsigned long set_radio_at, revert_radio_at;
unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled)
float pending_freq;
float pending_bw;
uint8_t pending_sf;
@@ -137,9 +150,49 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
unsigned long radio_apply_retry_at;
uint8_t radio_apply_failures;
int matching_peer_indexes[MAX_CLIENTS];
#if defined(WITH_MQTT_NEIGHBORS)
NeighbourInfo neighbours[MAX_NEIGHBOURS];
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,
};
struct NeighborDiscoverEntry {
uint8_t neighbour_idx;
uint32_t tag;
char scopes[96];
uint8_t status;
};
NeighborDiscoverEntry neighbor_discover[MAX_NEIGHBOURS];
uint8_t neighbor_discover_count;
bool neighbor_discover_active;
bool neighbor_table_refresh_active;
bool neighbor_table_refresh_periodic;
unsigned long neighbor_discover_until;
unsigned long next_neighbors_publish;
char self_scopes_buf[96];
void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr);
void sendNodeDiscoverReq();
bool sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag);
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 getLocalScopes(char* buf, size_t len);
static const int NEIGHBOR_DISCOVER_PEER_BASE = 1000;
static const unsigned long NEIGHBOR_DISCOVER_TIMEOUT_MS = 30000;
#endif
#ifdef WITH_MQTT_BRIDGE
MQTTBridge* bridge;
#endif
#ifdef WITH_SNMP
MeshSNMPAgent _snmp_agent;
#endif
#ifdef WITH_MQTT_BRIDGE
AlertReporter _alerter;
#endif
@@ -193,9 +246,6 @@ protected:
int getInterferenceThreshold() const override {
return _prefs.interference_threshold;
}
bool getCADEnabled() const override {
return _prefs.cad_enabled;
}
int getAGCResetInterval() const override {
return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds
}
@@ -215,6 +265,10 @@ protected:
void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override;
bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override;
#if defined(WITH_MQTT_NEIGHBORS)
void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override;
void onControlDataRecv(mesh::Packet* packet) override;
#endif
#if ENV_INCLUDE_GPS == 1
void applyGpsPrefs() {
@@ -269,11 +323,11 @@ public:
void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override;
bool setRxBoostedGain(bool enable) override;
void formatNeighborsReply(char *reply) override {
strcpy(reply, "not supported");
}
void formatNeighborsReply(char *reply) override;
void removeNeighbor(const uint8_t* pubkey, int key_len) override;
void formatStatsReply(char *reply) override;
void formatRadioStatsReply(char *reply) override;
void formatRadioDiagReply(char *reply) override;
void formatPacketStatsReply(char *reply) override;
void startRegionsLoad() override;
bool saveRegions() override;
@@ -317,6 +371,13 @@ public:
bridge->setBuildDate(getBuildDate());
#ifdef WITH_MQTT_BRIDGE
bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms);
#ifdef WITH_SNMP
if (_cli.getObserverPrefs()->snmp_enabled) {
_snmp_agent.setNodeName(_prefs.node_name);
_snmp_agent.setFirmwareVersion(getFirmwareVer());
bridge->setSNMPAgent(&_snmp_agent);
}
#endif
#endif
bridge->begin();
#ifdef WITH_MQTT_BRIDGE
@@ -335,7 +396,7 @@ public:
void restartBridge() override {
if (!bridge || !bridge->isRunning()) return;
#ifdef WITH_WEBCONFIG
if (_wc_batch_active) {
if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd()
_wc_restart_pending = true;
return;
}
@@ -369,6 +430,29 @@ public:
#endif
}
#if defined(WITH_MQTT_BRIDGE)
// Pump already-queued mesh traffic before OTA blocks the loop and reboots.
// This is deliberately bounded: a jammed or duty-limited channel must not
// prevent an update, and Mesh::loop() continues to respect TX constraints.
void drainOutbound(uint32_t timeout_ms) {
unsigned long start = millis();
while (hasOutbound() || _mgr->getOutboundCount(millis()) > 0) {
if (millis() - start >= timeout_ms) break;
mesh::Mesh::loop();
delay(1);
}
}
#endif
// Schedule the pull-OTA flash to run from loop() in ~2.5 s, leaving time for the
// "Beginning update..." CLI reply (CLI_REPLY_DELAY_MILLIS = 600 ms) to transmit
// before the flash blocks the loop and reboots.
bool beginDeferredOtaUpdate() override {
_ota_update_at = millis() + 2500;
if (_ota_update_at == 0) _ota_update_at = 1; // 0 means "none"
return true;
}
int getQueueSize() override {
return bridge ? bridge->getQueueSize() : 0;
}
@@ -379,8 +463,11 @@ public:
bool syncMqttNtp() override {
if (!bridge || !bridge->isRunning()) return false;
// Marshal onto the MQTT task (Core 0); this runs on the CLI thread (Core 1).
return bridge->requestForcedNtpSync();
// Queue the sync onto the MQTT task (Core 0) without blocking: this runs on
// the Arduino loop task (serial CLI and the web config batch both drain
// here), and blocking up to 30 s would stall mesh/radio forwarding. Returns
// true once queued; verify with `get mqtt.ntp.diag`.
return bridge->requestForcedNtpSync(0);
}
bool runMqttNtpDiag(char* reply, size_t reply_size, bool verbose) override {
+1 -1
View File
@@ -1 +1 @@
<b>MQTT Observer v1.16.0 (experimental)</b><ul><li>Up to 6 MQTT broker slots with built-in presets</li><li>Presets: Analyzer, MeshMapper, MeshRank, Waev, Meshomatic, CascadiaMesh, TennMesh, NashMesh, and more</li><li>JWT (Ed25519) and username/password authentication</li><li>Automatic reconnection with exponential backoff</li><li>After flashing, configure via serial console (115200 baud)</li></ul><p>See <a target='_blank' href='https://github.com/agessaman/MeshCore/blob/mqtt-bridge-implementation-flex/MQTT_IMPLEMENTATION.md'>setup guide</a> for configuration instructions.</p>
<b>MQTT Observer v1.16.0 (experimental)</b><ul><li>Up to 6 MQTT broker slots with built-in presets</li><li>Presets: Analyzer, MeshMapper, MeshRank, Waev, Meshomatic, CascadiaMesh, TennMesh, NashMesh, and more</li><li>JWT (Ed25519) and username/password authentication</li><li>Automatic reconnection with exponential backoff</li><li>After flashing, configure via serial console (115200 baud)</li></ul><p>See <a target='_blank' href='https://github.com/agessaman/MeshCore/blob/observer-firmware/MQTT_IMPLEMENTATION.md'>setup guide</a> for configuration instructions.</p>
@@ -50,6 +50,17 @@ PsychicMqttClient &PsychicMqttClient::setKeepAlive(int keepAlive)
return *this;
}
PsychicMqttClient &PsychicMqttClient::setNetworkTimeout(int timeoutMs)
{
#if ESP_IDF_VERSION_MAJOR == 5
_mqtt_cfg.network.timeout_ms = timeoutMs;
#else
_mqtt_cfg.network_timeout_ms = timeoutMs;
#endif
_config_dirty = true;
return *this;
}
PsychicMqttClient &PsychicMqttClient::setAutoReconnect(bool reconnect)
{
#if ESP_IDF_VERSION_MAJOR == 5
@@ -61,6 +72,17 @@ PsychicMqttClient &PsychicMqttClient::setAutoReconnect(bool reconnect)
return *this;
}
PsychicMqttClient &PsychicMqttClient::setMessageRetransmitTimeout(int timeoutMs)
{
#if ESP_IDF_VERSION_MAJOR == 5
_mqtt_cfg.session.message_retransmit_timeout = timeoutMs;
#else
_mqtt_cfg.message_retransmit_timeout = timeoutMs;
#endif
_config_dirty = true;
return *this;
}
PsychicMqttClient &PsychicMqttClient::setClientId(const char *clientId)
{
#if ESP_IDF_VERSION_MAJOR == 5
@@ -520,17 +542,44 @@ int PsychicMqttClient::publish(const char *topic, int qos, bool retain, const ch
if (async)
{
// QoS0 async publishes are stored in the esp-mqtt outbox (store=true) so that
// packet topics keep flowing -- enqueue(store=false) produced false-failure
// semantics that stalled the packet path. The outbox has no size bound of its
// own (esp-mqtt frees entries only on send-ack or time-based expiry), so on a
// stalled uplink QoS0 frames pile up on internal heap without limit. Cap it
// here: if the outbox is already at/over _outbox_limit, drop this QoS0 message
// and report -2 ("outbox full") so the caller can retry/drop. QoS1/2 (durable,
// low-rate) are never gated.
if (qos == 0 && _outbox_limit > 0 && _client != nullptr &&
esp_mqtt_client_get_outbox_size(_client) >= _outbox_limit)
{
_outbox_drops++;
static unsigned long last_full_log = 0;
unsigned long now = millis();
if (now - last_full_log > 5000)
{
ESP_LOGW(TAG, "Outbox at cap (%u bytes); dropping QoS0 message to topic %s",
(unsigned)_outbox_limit, topic);
last_full_log = now;
}
return -2;
}
ESP_LOGV(TAG, "Enqueuing message to topic %s with QoS %d", topic, qos);
// Hotfix: restore legacy outbox behavior for QoS0 async publishes.
// This avoids false-failure semantics from enqueue(store=false) on some
// connected paths where packet topics stop flowing.
bool store_in_outbox = true;
return esp_mqtt_client_enqueue(_client, topic, payload, length, qos, retain, store_in_outbox);
int result = esp_mqtt_client_enqueue(_client, topic, payload, length, qos, retain, store_in_outbox);
if (result < 0) _publish_err++; else _publish_ok++;
return result;
}
else
{
ESP_LOGV(TAG, "Publishing message to topic %s with QoS %d", topic, qos);
return esp_mqtt_client_publish(_client, topic, payload, length, qos, retain);
// Synchronous write (used for QoS0 packet publishes). A negative result is a real
// send failure (socket error / network_timeout on a stalled link), tracked here so
// callers can surface delivery health without per-message logging.
int result = esp_mqtt_client_publish(_client, topic, payload, length, qos, retain);
if (result < 0) _publish_err++; else _publish_ok++;
return result;
}
}
@@ -548,6 +597,40 @@ esp_mqtt_client_config_t *PsychicMqttClient::getMqttConfig()
return &_mqtt_cfg;
}
PsychicMqttClient &PsychicMqttClient::setOutboxLimit(size_t bytes)
{
_outbox_limit = bytes;
return *this;
}
size_t PsychicMqttClient::getOutboxSize()
{
if (_client == nullptr)
return 0;
int size = esp_mqtt_client_get_outbox_size(_client);
return size > 0 ? (size_t)size : 0;
}
size_t PsychicMqttClient::getOutboxLimit()
{
return _outbox_limit;
}
unsigned long PsychicMqttClient::getOutboxDrops()
{
return _outbox_drops;
}
unsigned long PsychicMqttClient::getPublishOk()
{
return _publish_ok;
}
unsigned long PsychicMqttClient::getPublishErr()
{
return _publish_err;
}
void PsychicMqttClient::_onMqttEventStatic(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data)
{
// Since this is a static function, we need to cast the first argument (void*) back to the class instance type
@@ -126,6 +126,29 @@ public:
*/
PsychicMqttClient &setAutoReconnect(bool reconnect = true);
/**
* @brief Sets the network operation timeout in milliseconds. esp-mqtt aborts a
* network read/write (including a synchronous publish's socket write) if it does
* not complete within this window. A lower value bounds how long a synchronous
* QoS0 publish can block on a stalled/half-open socket before failing and letting
* the slot flip to disconnected. esp-mqtt's default is 10000.
*
* @param timeoutMs Network timeout in milliseconds.
* @return A reference to the PsychicMqttClient instance.
*/
PsychicMqttClient &setNetworkTimeout(int timeoutMs);
/**
* @brief Sets the retransmit timeout for unacknowledged QoS 1/2 messages.
* esp-mqtt resends an unacked PUBLISH (DUP flag set) every time this
* timeout elapses, so a value shorter than the broker's ack latency
* produces byte-identical duplicates on the wire.
*
* @param timeoutMs Retransmit timeout in milliseconds. esp-mqtt's default is 1000.
* @return A reference to the PsychicMqttClient instance.
*/
PsychicMqttClient &setMessageRetransmitTimeout(int timeoutMs);
/**
* @brief Sets the client ID for the MQTT connection.
*
@@ -399,6 +422,55 @@ public:
*/
esp_mqtt_client_config_t *getMqttConfig();
/**
* @brief Caps the size of the esp-mqtt outbox for async QoS 0 publishes.
*
* QoS 0 async publishes are forced into the esp-mqtt outbox (store=true) so
* packet topics keep flowing, but the outbox has no size bound of its own --
* it only frees entries on send-ack or time-based expiry. On a stalled uplink
* the entries accumulate on internal heap without limit. When the current
* outbox size is at/over this cap, publish() drops the new QoS 0 message
* (returns -2) instead of enqueuing it, applying backpressure. 0 disables the
* cap. QoS 1/2 publishes are never gated by this.
*
* @param bytes Maximum outbox size in bytes, or 0 to disable.
* @return A reference to the PsychicMqttClient instance.
*/
PsychicMqttClient &setOutboxLimit(size_t bytes);
/**
* @brief Returns the current esp-mqtt outbox size in bytes (0 if the client
* is not initialized). Useful for diagnostics/backpressure monitoring.
*
* @return Current outbox size in bytes.
*/
size_t getOutboxSize();
/**
* @brief Returns the configured outbox cap in bytes (0 = disabled). Lets
* callers confirm the cap is actually applied to this client.
*/
size_t getOutboxLimit();
/**
* @brief Returns the cumulative count of QoS0 messages dropped because the
* outbox was at/over the cap. Monotonic; useful for backpressure diagnostics.
*/
unsigned long getOutboxDrops();
/**
* @brief Cumulative count of publishes that were accepted (enqueued or written
* successfully). Monotonic. Pair with getPublishErr() for delivery-health stats.
*/
unsigned long getPublishOk();
/**
* @brief Cumulative count of publishes that failed (negative return from the
* synchronous write or async enqueue -- socket error / network timeout). Monotonic.
* A rising value indicates the uplink is dropping publishes.
*/
unsigned long getPublishErr();
private:
esp_mqtt_client_handle_t _client = nullptr;
esp_mqtt_client_config_t _mqtt_cfg;
@@ -407,6 +479,15 @@ private:
bool _stopMqttClient = false;
bool _config_dirty = true;
// Runtime cap on the esp-mqtt outbox for QoS 0 async publishes (bytes).
// 0 = disabled. Enforced in publish(); not an esp-mqtt config field.
size_t _outbox_limit = 0;
// Cumulative count of QoS0 publishes dropped because the outbox hit the cap.
unsigned long _outbox_drops = 0;
// Cumulative publish accept/fail counts (any QoS, sync or async path).
unsigned long _publish_ok = 0;
unsigned long _publish_err = 0;
// Multipart message reassembly. _buffer is lazily allocated at connect() time
// to match the configured buffer size, then reused for the client's lifetime.
// _topic is inline storage, never heap-allocated.
+12 -2
View File
@@ -49,6 +49,9 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
build_src_filter =
+<*.cpp>
+<helpers/*.cpp>
; MQTT-only sources (need Timezone/NTPClient); observer envs add them back.
-<helpers/MQTTMessageBuilder.cpp>
-<helpers/JWTHelper.cpp>
+<helpers/radiolib/*.cpp>
+<helpers/bridges/BridgeBase.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -76,10 +79,13 @@ build_flags = ${arduino_base.build_flags}
-D ENABLE_OTA=1
-D OTA_FLASH_STORE=1
-D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW)
; Route esp_transport_ws_init through src/helpers/ESP32WsTransportFix.cpp to
; fix a heap-overflow in the precompiled IDF 4.4 WS transport (see that file).
-Wl,--wrap=esp_transport_ws_init
; -D ESP32_CPU_FREQ=80 ; change it to your need
lib_deps = ${arduino_base.lib_deps}
ESP32Async/ESPAsyncWebServer @ 3.10.3
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
build_src_filter = ${arduino_base.build_src_filter}
+<helpers/esp32/WebConfigServer.cpp>
+<helpers/esp32/PortableErrorStrings.cpp>
@@ -236,6 +242,8 @@ build_flags =
-UENV_INCLUDE_GPS
; ----------------- TESTING ---------------------
; Host GoogleTest suites for the fork's pure logic. See test/README.md.
; Run: `pio test -e native` (all) or `pio test -e native -f <suite>` (one).
[env:native]
platform = native
@@ -244,10 +252,11 @@ build_flags = -std=c++17
-I src
-I test/mocks
test_build_src = yes
test_filter = test_utils
test_ignore = test_kiss_modem
build_src_filter =
-<*>
+<../src/Utils.cpp>
+<../src/helpers/MQTTPayloadBuilder.cpp>
+<../src/Packet.cpp>
+<../src/Dispatcher.cpp>
+<../src/Identity.cpp>
@@ -261,6 +270,7 @@ build_src_filter =
+<../src/helpers/ota/detools/detools.c>
lib_deps =
google/googletest @ 1.17.0
bblanchon/ArduinoJson @ 7.4.3
[env:native_kiss_modem]
platform = native
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Require ArduinoJson 7.4.3 in checked-in PlatformIO configurations.
The checker scans the root ``platformio.ini`` plus every
``variants/**/platformio.ini``. It checks every explicit ArduinoJson
declaration and, without a fragile fixed count, requires one in the native
test environment and each MQTT bridge environment.
Run ``python3 scripts/check_arduinojson_pin.py`` for the repository and add
``--self-test`` to exercise accepted, unpinned, wrong, and missing pins in a
temporary fixture.
"""
import argparse
from pathlib import Path
import re
import sys
from tempfile import TemporaryDirectory
VERSION = "7.4.3"
PACKAGE = "bblanchon/ArduinoJson"
PACKAGE_PATTERN = re.compile(r"\bbblanchon\s*/\s*ArduinoJson\b", re.IGNORECASE)
PIN_PATTERN = re.compile(r"@\s*7\.4\.3\Z")
SECTION_PATTERN = re.compile(r"^\s*\[([^]]+)\]\s*$")
def active(line):
"""Remove PlatformIO whole-line and inline comments."""
if line.lstrip().startswith((";", "#")):
return ""
comments = [index for index in (line.find(";"), line.find("#")) if index >= 0]
return line[: min(comments)] if comments else line
def configuration_paths(root):
root_config = root / "platformio.ini"
if not root_config.is_file():
raise FileNotFoundError(f"root PlatformIO configuration is missing: {root_config}")
variants = root / "variants"
return [root_config] + sorted(variants.rglob("platformio.ini") if variants.is_dir() else [])
def sections(lines):
"""Yield ``(name, header_line, lines)`` while preserving source locations."""
name, header_line, content = "<global>", 1, []
for number, line in enumerate(lines, 1):
match = SECTION_PATTERN.fullmatch(active(line).strip())
if match:
yield name, header_line, content
name, header_line, content = match.group(1), number, []
else:
content.append((number, line))
yield name, header_line, content
def expects_pin(config, root, section, lines):
if config == root / "platformio.ini":
return section == "env:native"
if not section.startswith("env:"):
return False
text = "\n".join(active(line) for _, line in lines)
return "WITH_MQTT_BRIDGE" in text or "elims/PsychicMqttClient" in text
def check(root):
"""Return (configs, declarations, required environments, error messages)."""
root = root.resolve()
configs, declarations, required, errors = configuration_paths(root), 0, 0, []
for config in configs:
relative = config.relative_to(root).as_posix()
for section, header_line, lines in sections(config.read_text(encoding="utf-8").splitlines()):
found = False
for number, raw_line in lines:
declaration = active(raw_line)
match = PACKAGE_PATTERN.search(declaration)
if not match:
continue
found, declarations = True, declarations + 1
version = declaration[match.end() :].strip()
if not version:
errors.append(f"{relative}:{number}: {PACKAGE} is unpinned; use '@ {VERSION}'")
elif not PIN_PATTERN.fullmatch(version):
errors.append(
f"{relative}:{number}: {PACKAGE} must be pinned exactly to {VERSION}; "
f"found '{version}'"
)
if expects_pin(config, root, section, lines):
required += 1
if not found:
errors.append(
f"{relative}:{header_line}: missing required {PACKAGE} @ {VERSION} "
f"in [{section}]"
)
return configs, declarations, required, errors
def self_test():
"""Exercise discovery and the valid, unpinned, wrong, and missing cases."""
with TemporaryDirectory() as directory:
root = Path(directory)
root_config = root / "platformio.ini"
variant = root / "variants" / "nested" / "mqtt" / "platformio.ini"
variant.parent.mkdir(parents=True)
root_config.write_text("[env:native]\nbblanchon/ArduinoJson\n", encoding="utf-8")
variant.write_text(
"[env:wrong]\n-D WITH_MQTT_BRIDGE=1\nbblanchon/ArduinoJson @ ^7.4.3\n"
"[env:missing]\nelims/PsychicMqttClient@^0.2.4\n",
encoding="utf-8",
)
configs, _, required, errors = check(root)
joined = "\n".join(errors)
if len(configs) != 2 or required != 3 or len(errors) != 3 or not all(
phrase in joined for phrase in ("unpinned", "must be pinned exactly", "missing required")
):
print("ArduinoJson pin checker self-test failed: invalid cases were not reported.", file=sys.stderr)
return 1
root_config.write_text("[env:native]\nbblanchon/ArduinoJson @ 7.4.3\n", encoding="utf-8")
variant.write_text(
"[env:one]\n-D WITH_MQTT_BRIDGE=1\nbblanchon/ArduinoJson @ 7.4.3\n"
"[env:two]\nelims/PsychicMqttClient@^0.2.4\nbblanchon/ArduinoJson@7.4.3\n",
encoding="utf-8",
)
if check(root)[3]:
print("ArduinoJson pin checker self-test failed: valid pins were rejected.", file=sys.stderr)
return 1
print("ArduinoJson pin checker self-test passed.")
return 0
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project-root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args(argv)
if args.self_test:
return self_test()
try:
configs, declarations, required, errors = check(args.project_root)
except FileNotFoundError as error:
print(f"ArduinoJson pin check could not run: {error}", file=sys.stderr)
return 2
if errors:
print("ArduinoJson pin check failed:", *[f" {error}" for error in errors], sep="\n", file=sys.stderr)
return 1
print(
f"ArduinoJson pin check passed: {declarations} declaration(s) across {len(configs)} "
f"root/variant configuration file(s); {required} required environment section(s) covered."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""Compare MQTT built-in preset *names* between two MQTTPresets.h files.
Only the first string field of each ``MQTT_PRESETS`` entry is compared (set
equality, case-sensitive). URL, auth, CA, keepalive, and credentials are
ignored so channel branches may diverge on config details without failing CI.
Usage::
python3 scripts/check_mqtt_preset_parity.py FILE_A FILE_B \\
--label-a observer-firmware --label-b observer-firmware-dev
python3 scripts/check_mqtt_preset_parity.py --self-test
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
COUNT_PATTERN = re.compile(
r"static\s+const\s+int\s+MQTT_PRESET_COUNT\s*=\s*(\d+)\s*;"
)
# Start of the preset table; allow optional whitespace/newlines.
TABLE_START_PATTERN = re.compile(
r"(?:static|extern)\s+const\s+MQTTPresetDef\s+MQTT_PRESETS\s*"
r"\[\s*[^\]]*\]\s*=\s*\{",
re.MULTILINE,
)
# First quoted string after an opening brace at entry start (after optional WS).
ENTRY_NAME_PATTERN = re.compile(r'\{\s*"([^"]+)"\s*,')
def _strip_line_comment(line: str) -> str:
"""Remove ``//`` comments; do not treat ``://`` in URLs as comments."""
in_string = False
i = 0
while i < len(line):
ch = line[i]
if ch == '"' and (i == 0 or line[i - 1] != "\\"):
in_string = not in_string
elif not in_string and ch == "/" and i + 1 < len(line) and line[i + 1] == "/":
# Prefer not to cut URL schemes: require that the previous char is not ':'.
if i == 0 or line[i - 1] != ":":
return line[:i]
i += 1
return line
def extract_preset_block(text: str) -> str:
"""Return the interior of the ``MQTT_PRESETS[...] = { ... };`` initializer."""
match = TABLE_START_PATTERN.search(text)
if not match:
raise ValueError("MQTT_PRESETS table not found")
depth = 1
i = match.end()
in_string = False
while i < len(text):
ch = text[i]
if ch == '"' and (i == 0 or text[i - 1] != "\\"):
in_string = not in_string
elif not in_string:
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return text[match.end() : i]
i += 1
raise ValueError("MQTT_PRESETS table is not closed")
def parse_presets(text: str, *, source: str = "<input>") -> tuple[list[str], int | None]:
"""Return (names in file order, MQTT_PRESET_COUNT or None if absent)."""
count_match = COUNT_PATTERN.search(text)
declared_count = int(count_match.group(1)) if count_match else None
block = extract_preset_block(text)
names: list[str] = []
for raw_line in block.splitlines():
line = _strip_line_comment(raw_line).strip()
if not line:
continue
entry = ENTRY_NAME_PATTERN.match(line)
if entry:
names.append(entry.group(1))
if not names:
raise ValueError(f"{source}: no preset entries found in MQTT_PRESETS")
seen: set[str] = set()
dupes: list[str] = []
for name in names:
if name in seen:
dupes.append(name)
seen.add(name)
if dupes:
raise ValueError(f"{source}: duplicate preset name(s): {', '.join(sorted(set(dupes)))}")
if declared_count is not None and declared_count != len(names):
raise ValueError(
f"{source}: MQTT_PRESET_COUNT is {declared_count} but found {len(names)} preset entries"
)
return names, declared_count
def format_missing(label: str, names: set[str]) -> str:
if not names:
return f"Missing from {label}: (none)"
return f"Missing from {label}: {', '.join(sorted(names))}"
def compare(
names_a: set[str],
names_b: set[str],
*,
label_a: str,
label_b: str,
) -> list[str]:
"""Return human-readable error lines if sets differ; empty list if equal."""
only_a = names_a - names_b
only_b = names_b - names_a
if not only_a and not only_b:
return []
return [
format_missing(label_b, only_a), # in A but not B -> missing from B
format_missing(label_a, only_b), # in B but not A -> missing from A
]
def load_names(path: Path) -> set[str]:
text = path.read_text(encoding="utf-8")
names, _ = parse_presets(text, source=str(path))
return set(names)
def self_test() -> int:
"""Exercise equal-with-different-URLs, missing-name, and duplicate cases."""
header = """
static const int MQTT_PRESET_COUNT = {count};
static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {{
{body}
}};
"""
with TemporaryDirectory() as directory:
root = Path(directory)
equal_a = root / "a.h"
equal_b = root / "b.h"
missing = root / "missing.h"
dupes = root / "dupes.h"
equal_a.write_text(
header.format(
count=2,
body=' { "alpha", "wss://a.example/mqtt", nullptr },\n'
' { "beta", "wss://b.example/mqtt", nullptr },',
),
encoding="utf-8",
)
# Same names, different URLs/config -- must pass.
equal_b.write_text(
header.format(
count=2,
body=' { "beta", "mqtt://other:1883", nullptr },\n'
' { "alpha", "wss://changed.example/mqtt", nullptr },',
),
encoding="utf-8",
)
if compare(load_names(equal_a), load_names(equal_b), label_a="a", label_b="b"):
print("self-test failed: equal name sets with different URLs were rejected.", file=sys.stderr)
return 1
missing.write_text(
header.format(
count=1,
body=' { "alpha", "wss://a.example/mqtt", nullptr },',
),
encoding="utf-8",
)
errs = compare(load_names(equal_a), load_names(missing), label_a="a", label_b="missing")
if len(errs) != 2 or "beta" not in errs[0] or "(none)" not in errs[1]:
print(f"self-test failed: missing-name case unexpected: {errs!r}", file=sys.stderr)
return 1
try:
parse_presets(
header.format(
count=2,
body=' { "alpha", "wss://a.example/mqtt", nullptr },\n'
' { "alpha", "wss://b.example/mqtt", nullptr },',
),
source="dupes",
)
print("self-test failed: duplicate names were accepted.", file=sys.stderr)
return 1
except ValueError as error:
if "duplicate" not in str(error):
print(f"self-test failed: unexpected duplicate error: {error}", file=sys.stderr)
return 1
try:
parse_presets(
header.format(
count=99,
body=' { "alpha", "wss://a.example/mqtt", nullptr },',
),
source="count-mismatch",
)
print("self-test failed: count mismatch was accepted.", file=sys.stderr)
return 1
except ValueError as error:
if "MQTT_PRESET_COUNT" not in str(error):
print(f"self-test failed: unexpected count error: {error}", file=sys.stderr)
return 1
# URL with :// must not be treated as a line comment.
url_comment = root / "url.h"
url_comment.write_text(
header.format(
count=1,
body=' { "alpha", "wss://mqtt.example:443/mqtt", nullptr }, // note',
),
encoding="utf-8",
)
if load_names(url_comment) != {"alpha"}:
print("self-test failed: URL scheme was treated as a comment.", file=sys.stderr)
return 1
# Silence unused path in fixture layout.
dupes.write_text("// unused\n", encoding="utf-8")
print("MQTT preset parity checker self-test passed.")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("file_a", type=Path, nargs="?", help="First MQTTPresets.h (e.g. production)")
parser.add_argument("file_b", type=Path, nargs="?", help="Second MQTTPresets.h (e.g. dev)")
parser.add_argument("--label-a", default="file_a", help="Label for file_a in reports")
parser.add_argument("--label-b", default="file_b", help="Label for file_b in reports")
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args(argv)
if args.self_test:
return self_test()
if args.file_a is None or args.file_b is None:
parser.error("FILE_A and FILE_B are required unless --self-test is set")
try:
names_a = load_names(args.file_a)
names_b = load_names(args.file_b)
except (OSError, ValueError) as error:
print(f"MQTT preset parity check could not run: {error}", file=sys.stderr)
return 2
errors = compare(names_a, names_b, label_a=args.label_a, label_b=args.label_b)
if errors:
print("MQTT preset name parity check failed:", *errors, sep="\n ", file=sys.stderr)
return 1
print(
f"MQTT preset name parity check passed: {len(names_a)} preset name(s) "
f"match between {args.label_a} and {args.label_b}."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+508
View File
@@ -0,0 +1,508 @@
#!/usr/bin/env python3
"""Local mock of the WebConfig portal backend, for iterating on webui/index.html
in a real browser with no firmware, no flashing, and no paid emulator account.
It serves the real webui/index.html and implements the same /api/* contract as
src/helpers/esp32/WebConfigServer.cpp -- including the 202+reqid handshake, the
pending -> done result polling, aggregate-success reboot gating, secret masking
(********), and the IATA / owner-key / length validation the firmware enforces.
So the browser drives the actual portal JS (wizard, save/poll/reqid, effective
value handling, reboot overlay, stats, scan) against realistic responses.
It does NOT run the C++ handlers (that's what test/ gtest covers) or the
AsyncTCP transport -- it's a frontend + contract harness.
Usage:
python3 scripts/webconfig_mock_server.py # LAN mode (login: password)
python3 scripts/webconfig_mock_server.py --setup # first-boot setup wizard
python3 scripts/webconfig_mock_server.py --port 9000 --active-slots 2
Then open http://localhost:8080/ (or the chosen port). Editing index.html and
refreshing shows changes immediately -- the page is re-read per request.
Stdlib only; no pip install.
"""
import argparse
import copy
import json
import os
import re
import secrets
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
HERE = os.path.dirname(os.path.abspath(__file__))
INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html")
SENTINEL = "********"
ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build flag
BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling
SCAN_SECS = 0.8
# Destination buffer sizes (chars, minus the NUL) -- mirrors the MQTTPrefs fields
# the firmware validates in CommonCLI_Observer.cpp.
LEN_LIMITS = {
"name": 31, "wifi.ssid": 31, "wifi.pwd": 63, "mqtt.origin": 31,
"mqtt.email": 63, "mqtt.ntp": 63, "timezone": 31, "snmp.community": 23,
}
SLOT_LEN_LIMITS = {"server": 63, "username": 31, "password": 63,
"token": 47, "topic": 95, "audience": 63}
# Preset names + what the UI must collect (mirrors handlePresets()).
PRESETS = (
[(n, "none") for n in (
"analyzer-us", "analyzer-eu", "nz-analyzer", "meshmapper", "waev",
"meshomatic", "cascadiamesh", "tennmesh", "nashmesh", "ctmesh", "chimesh",
"meshat.se", "eastidahomesh", "coloradomesh", "dutchmeshcore-1",
"dutchmeshcore-2", "meshcore-ca-1", "meshcore-ca-2", "meshcore-fi",
"bostonmesh", "rflab", "ipnt.uk", "flmesh", "corecomms")]
+ [("meshrank", "token"), ("inwmesh", "userpass")]
)
SCAN_NETWORKS = [
{"ssid": "Wokwi-GUEST", "rssi": -42, "enc": False},
{"ssid": "HomeNet", "rssi": -55, "enc": True},
{"ssid": "HomeNet-5G", "rssi": -61, "enc": True},
{"ssid": "Neighbor 2.4", "rssi": -78, "enc": True},
{"ssid": "OpenGuest", "rssi": -83, "enc": False},
]
def default_config(setup_mode):
return {
"radio": {
"freq": 910.525, "bw": 62.5, "sf": 7, "cr": 5, "tx": 22, "af": 1.0,
"rxdelay": 0.0, "txdelay": 0.5, "cad": False, "rxgain": True,
"repeat": True, "flood_max": 64, "flood_max_advert": 8,
"flood_max_unscoped": 8, "loop_detect": "moderate",
"name": "MockNode", "lat": 39.7392, "lon": -104.9903,
"advert_interval": 240, "flood_advert_interval": 6,
},
"wifi": {
# setup mode = unconfigured (empty ssid -> wizard); LAN mode = joined
"ssid": "" if setup_mode else "HomeNet",
"pwd": "" if setup_mode else "secretpw", # stored raw; masked on GET
"powersave": "min",
},
"mqtt": {
"origin": "" if setup_mode else "MockNode", "iata": "" if setup_mode else "DEN",
"status": True, "packets": True, "raw": False, "tx": "advert", "rx": True,
"interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7,
"ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False,
"snmp_community": "public",
"slots": [_slot() for _ in range(6)],
},
}
def _slot():
return {"preset": "none", "server": "", "port": 8883, "username": "",
"password": "", "token": "", "topic": "", "audience": ""}
class State:
def __init__(self, args):
self.lock = threading.Lock()
self.setup_mode = args.setup
self.active_slots = args.active_slots
self.cfg = default_config(args.setup)
# latched at AP start, like WebConfigServer::_initial_setup
self.initial_setup = args.setup and self.cfg["wifi"]["ssid"] == ""
self.start = time.time()
self.session = None # cookie token when logged in (LAN mode)
self.batch = {"state": "idle"}
self.scan_started = None
# ---- auth -------------------------------------------------------------
def is_authed(self, headers):
if self.setup_mode:
return True # setup mode: proximity trust, no auth
if not self.session:
return False
cookie = headers.get("Cookie", "")
m = re.search(r"wcs=([0-9a-f]+)", cookie)
return bool(m and m.group(1) == self.session)
# ---- config serialization (masks secrets, like handleConfigGet) -------
def config_json(self):
c = copy.deepcopy(self.cfg)
c["wifi"]["pwd"] = SENTINEL if self.cfg["wifi"]["pwd"] else ""
for s in c["mqtt"]["slots"]:
s["password"] = SENTINEL if s["password"] else ""
s["token"] = SENTINEL if s["token"] else ""
return c
def status_json(self, authed):
return {
"mode": "setup" if self.setup_mode else "lan",
"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)",
"uptime_s": int(time.time() - self.start),
"runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots,
}
# ---------------------------------------------------------------------------
# set-command application + validation (mirrors the firmware's setters enough
# to produce realistic per-field OK / Error replies for the UI chips).
# ---------------------------------------------------------------------------
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")}
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"),
"timezone.offset": ("mqtt", "timezone_offset")}
FLOAT_KEYS = {"lat": ("radio", "lat"), "lon": ("radio", "lon"),
"af": ("radio", "af"), "rxdelay": ("radio", "rxdelay"),
"txdelay": ("radio", "txdelay")}
STR_KEYS = {"name": ("radio", "name"), "wifi.ssid": ("wifi", "ssid"),
"wifi.powersave": ("wifi", "powersave"), "loop.detect": ("radio", "loop_detect"),
"mqtt.origin": ("mqtt", "origin"), "mqtt.ntp": ("mqtt", "ntp"),
"mqtt.email": ("mqtt", "email"), "timezone": ("mqtt", "timezone"),
"snmp.community": ("mqtt", "snmp_community"), "mqtt.tx": ("mqtt", "tx")}
SECRET_STR_KEYS = {"wifi.pwd": ("wifi", "pwd")}
def _hex64(v):
return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v)
def apply_set(cfg, key, val):
"""Return (ok, reply) and mutate cfg. Mirrors the firmware's validation for
the fields where it matters (length, IATA, owner key, port, radio combo)."""
# length guard for the plain string fields
if key in LEN_LIMITS and len(val) > LEN_LIMITS[key]:
return False, "Error: %s too long (max %d chars)" % (key, LEN_LIMITS[key])
if key == "password":
# Stored outside cfg: it must never appear in the /api/config GET. The
# firmware overwrites the CLI's "password now: <secret>" echo, so the
# reply carries no secret either.
global ADMIN_PASSWORD
ADMIN_PASSWORD = val
return True, "OK"
if key == "radio":
try:
f, bw, sf, cr = val.split(",")
f, bw, sf, cr = float(f), float(bw), int(sf), int(cr)
except ValueError:
return False, "Error, invalid radio params"
if not (150 <= f <= 2500 and 7 <= bw <= 500 and 5 <= sf <= 12 and 5 <= cr <= 8):
return False, "Error, invalid radio params"
cfg["radio"].update(freq=f, bw=bw, sf=sf, cr=cr)
return True, "OK - reboot to apply"
if key == "mqtt.iata":
if val == "":
cfg["mqtt"]["iata"] = ""
return True, "OK - IATA cleared"
if len(val) != 3 or not val.isalnum() or not val.isascii():
return False, "Error: IATA code must be exactly 3 letters/digits (e.g. DEN)"
cfg["mqtt"]["iata"] = val.upper()
return True, "OK"
if key == "mqtt.owner":
if val == "":
cfg["mqtt"]["owner"] = ""
return True, "OK - owner key cleared"
if not _hex64(val):
return False, "Error: public key must be 64 hex characters (32 bytes)"
cfg["mqtt"]["owner"] = val
return True, "OK"
m = re.match(r"^mqtt([1-6])\.(\w+)$", key)
if m:
return apply_slot_set(cfg, int(m.group(1)) - 1, m.group(2), val)
if key in BOOL_KEYS:
sec, f = BOOL_KEYS[key]
cfg[sec][f] = (val == "on")
return True, "OK"
if key in INT_KEYS:
sec, f = INT_KEYS[key]
try:
cfg[sec][f] = int(val)
except ValueError:
return False, "Error: expected a number"
return True, "OK"
if key in FLOAT_KEYS:
sec, f = FLOAT_KEYS[key]
try:
cfg[sec][f] = float(val)
except ValueError:
return False, "Error: expected a number"
return True, "OK"
if key in SECRET_STR_KEYS:
sec, f = SECRET_STR_KEYS[key]
cfg[sec][f] = val
return True, "OK"
if key in STR_KEYS:
sec, f = STR_KEYS[key]
cfg[sec][f] = val
return True, "OK"
return True, "OK" # unknown-but-allowlisted: accept (mock is lenient here)
def apply_slot_set(cfg, idx, field, val):
slot = cfg["mqtt"]["slots"][idx]
if field in SLOT_LEN_LIMITS and len(val) > SLOT_LEN_LIMITS[field]:
return False, "Error: %s too long (max %d chars)" % (field, SLOT_LEN_LIMITS[field])
if field == "port":
try:
p = int(val)
except ValueError:
return False, "Error: port must be between 1 and 65535"
if not (1 <= p <= 65535):
return False, "Error: port must be between 1 and 65535"
slot["port"] = p
return True, "OK"
if field in ("preset", "server", "username", "password", "token", "topic", "audience"):
slot[field] = val
if field == "token":
return True, "OK - slot %d token set" % (idx + 1)
return True, "OK"
return False, "Error: unknown slot field"
def is_secret_key(key):
return key == "wifi.pwd" or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key))
def valid_reqid(reqid):
return isinstance(reqid, str) and bool(re.fullmatch(r"[0-9A-Fa-f]{16}", reqid))
# ---------------------------------------------------------------------------
# HTTP handler
# ---------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args): # concise one-line log
print(" %s %s" % (self.command, self.path))
# -- helpers --
def _json(self, code, obj, extra_headers=None):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
for k, v in (extra_headers or {}):
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
def _read_body(self):
n = int(self.headers.get("Content-Length", 0))
return self.rfile.read(n) if n else b""
def _need_auth(self):
if not ST.is_authed(self.headers):
self._json(401, {"error": "auth"})
return True
return False
# -- GET --
def do_GET(self):
path = self.path.split("?", 1)[0]
if path == "/":
return self._serve_index()
if path == "/api/status":
return self._json(200, ST.status_json(ST.is_authed(self.headers)))
if path == "/api/presets":
return self._json(200, {"presets": [{"name": n, "needs": nd} for n, nd in PRESETS]})
if path == "/api/config":
if self._need_auth():
return
with ST.lock:
return self._json(200, ST.config_json())
if path == "/api/config/result":
if self._need_auth():
return
return self._config_result()
if path == "/api/stats":
if self._need_auth():
return
return self._json(200, self._stats())
if path == "/api/scan":
if self._need_auth():
return
return self._scan()
return self._json(404, {"error": "not found"})
# -- POST --
def do_POST(self):
path = self.path.split("?", 1)[0]
if path == "/api/login":
return self._login()
if path == "/api/logout":
ST.session = None
return self._json(200, {"ok": True}, [("Set-Cookie", "wcs=; Max-Age=0; Path=/")])
if path == "/api/config":
if self._need_auth():
return
return self._config_post()
if path == "/api/reboot":
if self._need_auth():
return
return self._json(200, {"ok": True})
if path == "/api/portal/exit":
return self._json(200, {"ok": True, "url": "http://localhost:%d/" % PORT})
return self._json(404, {"error": "not found"})
# -- endpoint impls --
def _serve_index(self):
try:
with open(INDEX_HTML, "rb") as f: # re-read each time -> live edits
html = f.read()
except OSError:
self.send_error(500, "webui/index.html not found")
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(html)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(html)
def _login(self):
if ST.setup_mode:
return self._json(200, {"ok": True})
try:
body = json.loads(self._read_body() or b"{}")
except ValueError:
return self._json(400, {"error": "bad request"})
if body.get("password") != ADMIN_PASSWORD:
return self._json(401, {"error": "wrong password"})
ST.session = secrets.token_hex(16)
return self._json(200, {"ok": True},
[("Set-Cookie", "wcs=%s; HttpOnly; SameSite=Lax; Path=/" % ST.session)])
def _config_post(self):
raw = self._read_body()
if len(raw) > 4096:
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"})
reboot = bool(body.get("reboot", False))
setmap = body.get("set", {}) or {}
# `password` maps to the top-level CLI command rather than a setter. It
# is accepted in both modes (LAN already required a login), but first
# onboarding cannot finish without it.
if "password" in setmap:
pwd = str(setmap["password"])
if not 0 < len(pwd) <= 15 or "\r" in pwd or "\n" in pwd:
return self._json(400, {"error": "admin password must be 1-15 characters with no line breaks"})
elif ST.setup_mode and ST.initial_setup and (reboot or "wifi.ssid" in setmap):
return self._json(400, {"error": "admin password required for initial setup"})
with ST.lock:
if ST.batch.get("state") != "idle" and ST.batch.get("reqid") == reqid:
return self._json(202, {
"state": ST.batch["state"], "count": len(ST.batch.get("results", [])),
"reqid": reqid,
})
if ST.batch.get("state") == "pending":
return self._json(409, {"error": "busy", "reqid": ST.batch.get("reqid", "")})
# 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)]
if not entries and not reboot:
return self._json(400, {"error": "no changes"})
# apply now, but expose as pending->done to exercise polling
results, all_ok = [], True
for k, v in entries:
ok, reply = apply_set(ST.cfg, k, str(v))
if not ok:
all_ok = False
results.append({"key": k, "reply": reply})
ST.batch = {"state": "pending", "reqid": reqid, "results": results,
"all_ok": all_ok, "reboot": reboot,
"done_at": time.time() + BATCH_PENDING_SECS}
return self._json(202, {"state": "pending", "count": len(entries), "reqid": reqid})
def _config_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"})
with ST.lock:
b = ST.batch
if b.get("state") == "idle":
return self._json(200, {"state": "idle", "reqid": reqid})
if b.get("reqid") != reqid:
return self._json(404, {"error": "unknown request"})
if b["state"] == "pending" and time.time() < b["done_at"]:
return self._json(200, {"state": "pending", "reqid": b["reqid"]})
b["state"] = "done" # stays readable until next POST
return self._json(200, {
"state": "done", "reqid": b["reqid"], "all_ok": b["all_ok"],
"reboot": b["reboot"] and b["all_ok"], "results": b["results"],
})
def _scan(self):
rescan = "rescan=1" in self.path
now = time.time()
if rescan or ST.scan_started is None:
ST.scan_started = now
return self._json(200, {"state": "scanning"})
if now - ST.scan_started < SCAN_SECS:
return self._json(200, {"state": "scanning"})
return self._json(200, {"state": "done", "networks": SCAN_NETWORKS})
def _stats(self):
up = int(time.time() - ST.start)
slots = []
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})
return {
"uptime_s": up, "batt_mv": 4020, "heap_free": 142000, "heap_min": 118000,
"heap_max_alloc": 96000, "noise": -98, "rssi": -71, "snr": 9.5,
"airtime_s": up // 20, "rx_airtime_s": up // 8, "recv": 512 + up,
"sent": 88 + up // 3, "rx_err": 3, "sent_flood": 40, "sent_direct": 48,
"recv_flood": 300, "recv_direct": 212, "tx_queue": 0, "mqtt_queue": 0,
"wifi_rssi": -58, "ip": "192.168.1.42", "slots": slots,
}
def main():
global ST, PORT
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)")
args = ap.parse_args()
ST, PORT = State(args), args.port
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(" open http://localhost:%d/ (Ctrl-C to stop)" % args.port)
try:
srv.serve_forever()
except KeyboardInterrupt:
print("\nstopped")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -76,7 +76,7 @@ public:
virtual void setGpio(uint32_t values) {}
virtual uint8_t getStartupReason() const = 0;
virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; }
virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported
virtual bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) { return false; } // not supported
virtual bool stopOTAUpdate(char reply[]) { return false; } // not supported
virtual bool isOTAUpdateRunning() const { return false; }
// Pull-based OTA: fetch the firmware build for this variant from a baked-in manifest and flash it.
+6
View File
@@ -218,6 +218,12 @@ void AlertReporter::onLoop(unsigned long now_ms) {
// already enforces this on set, but a stale prefs file or future field
// tweak shouldn't be able to drag the floor below 1 hour and let a
// flapping link spam the mesh.
//
// The rate limiter only applies between two real sends: fired_at_ms == 0
// means "never fired since boot/config change", and treating it as a send
// at millis()==0 would suppress every first alert until uptime reaches
// min_interval (observed as a 30-minute alert.mqtt threshold not reporting
// until 60 minutes after a reboot).
uint16_t cfg_min = _obs->alert_min_interval_min;
if (cfg_min < 60) cfg_min = 60;
unsigned long min_interval_ms = (unsigned long)cfg_min * 60000UL;
+653 -239
View File
File diff suppressed because it is too large. Load diff
+14 -113
View File
@@ -227,111 +227,8 @@ struct NodePrefs { // persisted to file
};
#ifdef WITH_MQTT_BRIDGE
// Old MQTT preferences layout (pre-slot firmware) - used only for migration detection
struct OldMQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
uint8_t wifi_power_save;
char timezone_string[32];
int8_t timezone_offset;
char mqtt_server[64];
uint16_t mqtt_port;
char mqtt_username[32];
char mqtt_password[64];
uint8_t mqtt_analyzer_us_enabled;
uint8_t mqtt_analyzer_eu_enabled;
char mqtt_owner_public_key[65];
char mqtt_email[64];
};
// 3-slot MQTTPrefs layout - used for migrating from 3-slot to 6-slot format.
// Changing array sizes from [3] to [6] shifts all field offsets, so raw file.read()
// into the new struct would corrupt data. This struct preserves the old binary layout.
struct ThreeSlotMQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
uint8_t wifi_power_save;
char timezone_string[32];
int8_t timezone_offset;
char mqtt_slot_preset[3][24];
char mqtt_slot_host[3][64];
uint16_t mqtt_slot_port[3];
char mqtt_slot_username[3][32];
char mqtt_slot_password[3][64];
char mqtt_owner_public_key[65];
char mqtt_email[64];
uint8_t _legacy_analyzer_us_enabled;
uint8_t _legacy_analyzer_eu_enabled;
char _legacy_mqtt_server[64];
uint16_t _legacy_mqtt_port;
char _legacy_mqtt_username[32];
char _legacy_mqtt_password[64];
char mqtt_slot_token[3][48];
char mqtt_slot_topic[3][96];
};
// Versionless 6-slot layout as shipped on mqtt-bridge-implementation-flex (the
// several-thousand-device deployed fleet). This is the current MQTTPrefs minus the
// observer tail, and it still carries the now-removed `_legacy_*` fields mid-struct.
// loadMQTTPrefs reads a headerless file of this size into this struct, then
// field-copies (dropping `_legacy_*`) into the compact versioned MQTTPrefs.
struct Legacy6SlotMQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
uint8_t wifi_power_save;
char timezone_string[32];
int8_t timezone_offset;
char mqtt_slot_preset[MAX_MQTT_SLOTS][24];
char mqtt_slot_host[MAX_MQTT_SLOTS][64];
uint16_t mqtt_slot_port[MAX_MQTT_SLOTS];
char mqtt_slot_username[MAX_MQTT_SLOTS][32];
char mqtt_slot_password[MAX_MQTT_SLOTS][64];
char mqtt_owner_public_key[65];
char mqtt_email[64];
uint8_t _legacy_analyzer_us_enabled;
uint8_t _legacy_analyzer_eu_enabled;
char _legacy_mqtt_server[64];
uint16_t _legacy_mqtt_port;
char _legacy_mqtt_username[32];
char _legacy_mqtt_password[64];
char mqtt_slot_token[MAX_MQTT_SLOTS][48];
char mqtt_slot_topic[MAX_MQTT_SLOTS][96];
char mqtt_slot_audience[MAX_MQTT_SLOTS][64];
uint8_t mqtt_rx_enabled;
char mqtt_ntp_server[64];
};
// The legacy layouts above describe files already written to the deployed fleet's
// flash, so their sizes are frozen forever - loadMQTTPrefs() tells the eras apart
// by file size and reads each file as a raw struct dump. These asserts pin the
// layouts on every target toolchain; if one fires, the compiler (or an edit to a
// legacy struct or MAX_MQTT_SLOTS) has changed a layout and fleet files would be
// read at wrong offsets.
static_assert(sizeof(MQTTPrefsHeader) == 8, "versioned /mqtt_prefs header must stay 8 bytes");
static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed");
static_assert(sizeof(ThreeSlotMQTTPrefs) == 1464, "frozen 3-slot /mqtt_prefs layout changed");
static_assert(sizeof(Legacy6SlotMQTTPrefs) == 2904, "frozen deployed-fleet /mqtt_prefs layout changed");
static_assert(MQTT_PREFS_SLOT_COUNT == MAX_MQTT_SLOTS,
"MQTT prefs layout and slot count must change together");
// Observer settings captured from the trailing block of an old-format /com_prefs
// (fork firmware that predates the NodePrefs -> MQTTPrefs split). loadPrefsInt()
@@ -476,7 +373,6 @@ public:
// Browser-based configuration portal. ESP32 infrastructure roles override
// these; force_ap=true asks for the captive SoftAP even when WiFi is set.
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
virtual bool startWebConfig(bool force_ap, char* reply) {
(void)force_ap;
(void)reply;
@@ -498,7 +394,6 @@ public:
(void)reply;
return false;
};
#endif
virtual int getQueueSize() {
return 0; // no op by default
@@ -553,6 +448,12 @@ public:
};
};
#ifdef WITH_MQTT_BRIDGE
namespace MQTTPrefsAtomicStore {
class LegacyUpgradeGate;
}
#endif
class CommonCLI {
mesh::RTCClock* _rtc;
NodePrefs* _prefs;
@@ -565,9 +466,8 @@ class CommonCLI {
#ifdef WITH_MQTT_BRIDGE
MQTTPrefs _mqtt_prefs;
LegacyObserverTail _legacy_tail;
// /mqtt_prefs carries a version newer than this firmware understands (a downgrade).
// The in-memory prefs run on defaults and saveMQTTPrefs() must not overwrite the
// file, or the first `set` command would destroy the newer config.
// /mqtt_prefs is newer, corrupt, or temporarily unreadable. The in-memory prefs
// 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
@@ -576,8 +476,9 @@ class CommonCLI {
void savePrefs();
void loadPrefsInt(FILESYSTEM* _fs, const char* filename);
#ifdef WITH_MQTT_BRIDGE
void loadMQTTPrefs(FILESYSTEM* fs);
void saveMQTTPrefs(FILESYSTEM* fs);
bool saveCommonPrefsImageAtomically(FILESYSTEM* fs);
void loadMQTTPrefs(FILESYSTEM* fs, MQTTPrefsAtomicStore::LegacyUpgradeGate* legacy_upgrade);
bool saveMQTTPrefs(FILESYSTEM* fs);
#endif
#if defined(ENABLE_OTA)
void syncOtaConfigFromPrefs(); // persisted OTA policy + signer allowlist -> running OtaContext
@@ -607,7 +508,7 @@ public:
: _board(&board), _rtc(&rtc), _sensors(&sensors), _region_map(&region_map), _acl(&acl), _prefs(prefs), _callbacks(callbacks) { }
void loadPrefs(FILESYSTEM* _fs);
void savePrefs(FILESYSTEM* _fs);
void savePrefs(FILESYSTEM* _fs, bool save_mqtt = true);
void handleCommand(uint32_t sender_timestamp, char* command, char* reply);
mesh::MainBoard* getBoard() { return _board; }
uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data);
+197 -53
View File
@@ -3,15 +3,18 @@
// only two small delegation hooks. These are CommonCLI member functions, so they
// retain full access to _prefs/_callbacks/_board/savePrefs() with no re-plumbing.
//
// Behavior is intentionally identical to the previously-inlined branches: MQTT
// commands keep their WITH_MQTT_BRIDGE guard; alert/SNMP commands remain unguarded.
// Each handler returns true if it recognized the command, false to fall through to
// the base get/set parser in CommonCLI.cpp.
// Behavior is intentionally identical to the previously-inlined branches. NOTE:
// the entire body of each set/get handler here is compiled under WITH_MQTT_BRIDGE
// (see the #ifdef at the top of each), so on an observer build without the bridge
// the WiFi/timezone/alert/SNMP commands compile out too -- they are not guarded
// independently of the MQTT commands. Each handler returns true if it recognized
// the command, false to fall through to the base get/set parser in CommonCLI.cpp.
#include <Arduino.h>
#include "CommonCLI.h"
#include "TxtDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]()
#include "MQTTObserverValidation.h" // pure input validators (host-testable)
#include <Utils.h>
#ifdef ESP_PLATFORM
#include <WiFi.h>
@@ -20,6 +23,7 @@
#endif
#ifdef WITH_MQTT_BRIDGE
#include "bridges/MQTTBridge.h"
#include "MQTTConnectionPolicy.h" // classifySlotActivation() -- "will this slot connect here?"
#include "MQTTDefaults.h"
#endif
@@ -92,19 +96,16 @@ static int getMQTTPresetNameCount() {
return MQTT_PRESET_COUNT + 2; // built-ins + custom + none
}
static bool isValidNtpHostname(const char* host) {
if (!host || host[0] == '\0') return false;
size_t len = strlen(host);
if (len > 63) return false;
if (host[0] == '.' || host[len - 1] == '.') return false;
for (size_t i = 0; i < len; i++) {
char c = host[i];
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '.' || c == '-')) {
return false;
}
// Reject a value that wouldn't fit its destination MQTTPrefs buffer (which must
// hold the string plus a NUL) so an over-long CLI/web submission fails loudly
// instead of being silently truncated. Fills reply and returns true when too
// long. reply is the caller's 160-byte command buffer.
static bool valueTooLong(const char* val, size_t bufsize, char* reply, const char* label) {
if (!mqttValueFits(val, bufsize)) {
snprintf(reply, 160, "Error: %s too long (max %u chars)", label, (unsigned)(bufsize - 1));
return true;
}
return true;
return false;
}
static const char* getMQTTPresetNameByIndex(int index) {
@@ -163,6 +164,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
#ifdef WITH_MQTT_BRIDGE
bool handled = true;
if (memcmp(config, "snmp.community ", 15) == 0) {
if (valueTooLong(&config[15], sizeof(_mqtt_prefs.snmp_community), reply, "snmp.community")) return true;
StrHelper::strncpy(_mqtt_prefs.snmp_community, &config[15], sizeof(_mqtt_prefs.snmp_community));
savePrefs();
strcpy(reply, "OK - restart to apply");
@@ -200,18 +202,36 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "mqtt.origin ", 12) == 0) {
if (valueTooLong(&config[12], sizeof(_mqtt_prefs.mqtt_origin), reply, "origin")) return true;
StrHelper::strncpy(_mqtt_prefs.mqtt_origin, &config[12], sizeof(_mqtt_prefs.mqtt_origin));
StrHelper::stripSurroundingQuotes(_mqtt_prefs.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin));
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "mqtt.iata ", 10) == 0) {
StrHelper::strncpy(_mqtt_prefs.mqtt_iata, &config[10], sizeof(_mqtt_prefs.mqtt_iata));
for (int i = 0; _mqtt_prefs.mqtt_iata[i]; i++) {
_mqtt_prefs.mqtt_iata[i] = toupper(_mqtt_prefs.mqtt_iata[i]);
const char* iata = &config[10];
size_t iata_len = strlen(iata);
if (iata_len == 0) {
// Empty clears the region code (meshcore-topic publishing stays disabled
// until one is set). This keeps the pre-existing "clear IATA" capability.
_mqtt_prefs.mqtt_iata[0] = '\0';
savePrefs();
_callbacks->restartBridge();
strcpy(reply, "OK - IATA cleared");
} else {
// A region code goes straight into MQTT topic paths, so require exactly
// three alphanumeric characters (real IATA codes are 3 letters, e.g. DEN).
if (!mqttIataValid(iata)) {
strcpy(reply, "Error: IATA code must be exactly 3 letters/digits (e.g. DEN)");
} else {
StrHelper::strncpy(_mqtt_prefs.mqtt_iata, iata, sizeof(_mqtt_prefs.mqtt_iata));
for (int i = 0; _mqtt_prefs.mqtt_iata[i]; i++) {
_mqtt_prefs.mqtt_iata[i] = toupper(_mqtt_prefs.mqtt_iata[i]);
}
savePrefs();
_callbacks->restartBridge();
strcpy(reply, "OK");
}
}
savePrefs();
_callbacks->restartBridge();
strcpy(reply, "OK");
} else if (memcmp(config, "mqtt.status ", 12) == 0) {
_mqtt_prefs.mqtt_status_enabled = memcmp(&config[12], "on", 2) == 0;
savePrefs();
@@ -246,11 +266,35 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
} else {
strcpy(reply, "Error: interval must be between 1-60 minutes");
}
#if defined(WITH_MQTT_NEIGHBORS)
} else if (memcmp(config, "mqtt.neighbors.interval ", 24) == 0) {
// Hours in, milliseconds stored. The 12-336h band keeps the interval under
// INT32_MAX so the mesh's wrap-safe signed-delta millis math stays valid.
uint32_t hours = _atoi(&config[24]);
if (hours >= MQTT_NEIGHBORS_MIN_INTERVAL_HOURS && hours <= MQTT_NEIGHBORS_MAX_INTERVAL_HOURS) {
_mqtt_prefs.mqtt_neighbors_interval = hours * 3600000UL;
savePrefs();
sprintf(reply, "OK - neighbors interval set to %u hours (%lu ms)", (unsigned)hours,
(unsigned long)_mqtt_prefs.mqtt_neighbors_interval);
} else {
strcpy(reply, "Error: neighbors interval must be between 12-336 hours");
}
} else if (memcmp(config, "mqtt.neighbors ", 15) == 0) {
// The mesh loop reads this live, so no bridge restart is needed; enabling it
// triggers a discovery on the next eligible loop pass.
_mqtt_prefs.mqtt_neighbors_enabled = memcmp(&config[15], "on", 2) == 0;
savePrefs();
strcpy(reply, "OK");
#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)");
#endif
} else if (memcmp(config, "mqtt.ntp ", 9) == 0) {
const char* host = &config[9];
while (*host == ' ') host++;
bool clearing = strcmp(host, "none") == 0;
if (!clearing && !isValidNtpHostname(host)) {
if (!clearing && !mqttNtpHostnameValid(host)) {
strcpy(reply, "Error: invalid NTP hostname");
} else {
if (clearing) {
@@ -260,26 +304,30 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
}
savePrefs();
#ifdef ESP_PLATFORM
// Validate by running an immediate sync. syncMqttNtp() marshals onto the MQTT
// task (Core 0) so no NTP I/O happens on this (Core 1) CLI thread.
// Queue a sync on the MQTT task (Core 0) but do NOT block: this handler
// runs on the Arduino loop task, shared with mesh/radio processing and the
// web config batch, so a synchronous wait of up to 30 s would stall the
// node. The sync runs in the background; verify with `get mqtt.ntp.diag`.
if (WiFi.status() != WL_CONNECTED) {
strcpy(reply, "OK - saved (WiFi not connected; NTP sync pending)");
} else if (!_callbacks->isMqttBridgeRunning()) {
strcpy(reply, "OK - saved (MQTT bridge not running)");
} else if (_callbacks->syncMqttNtp()) {
strcpy(reply, "OK - time synced");
strcpy(reply, "OK - saved (NTP sync started; check 'get mqtt.ntp.diag')");
} else {
strcpy(reply, "Error: NTP sync failed");
strcpy(reply, "OK - saved (NTP sync unavailable)");
}
#else
strcpy(reply, "OK - saved");
#endif
}
} else if (memcmp(config, "wifi.ssid ", 10) == 0) {
if (valueTooLong(&config[10], sizeof(_mqtt_prefs.wifi_ssid), reply, "wifi.ssid")) return true;
StrHelper::strncpy(_mqtt_prefs.wifi_ssid, &config[10], sizeof(_mqtt_prefs.wifi_ssid));
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "wifi.pwd ", 9) == 0) {
if (valueTooLong(&config[9], sizeof(_mqtt_prefs.wifi_password), reply, "wifi.pwd")) return true;
StrHelper::strncpy(_mqtt_prefs.wifi_password, &config[9], sizeof(_mqtt_prefs.wifi_password));
savePrefs();
strcpy(reply, "OK");
@@ -323,6 +371,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
#endif
}
} else if (memcmp(config, "timezone ", 9) == 0) {
if (valueTooLong(&config[9], sizeof(_mqtt_prefs.timezone_string), reply, "timezone")) return true;
StrHelper::strncpy(_mqtt_prefs.timezone_string, &config[9], sizeof(_mqtt_prefs.timezone_string));
savePrefs();
strcpy(reply, "OK");
@@ -369,6 +418,12 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
} else if (p && p->topic_style == MQTT_TOPIC_MESHCORE &&
(strlen(_mqtt_prefs.mqtt_iata) == 0 || strcmp(_mqtt_prefs.mqtt_iata, "XXX") == 0)) {
sprintf(reply, "OK - slot %d preset: %s (run 'set mqtt.iata <airport_code>' to publish)", slot + 1, preset_name);
} else if (p && mqttPresetNeedsSlotPassword(p) &&
_mqtt_prefs.mqtt_slot_password[slot][0] == '\0' &&
!mqttPresetNeedsSlotUsername(p)) {
sprintf(reply,
"OK - slot %d preset: %s (run 'set mqtt%d.password <pass>' to connect)",
slot + 1, preset_name, slot + 1);
} else if (p && mqttPresetNeedsSlotCredentials(p) &&
(_mqtt_prefs.mqtt_slot_username[slot][0] == '\0' ||
_mqtt_prefs.mqtt_slot_password[slot][0] == '\0')) {
@@ -378,13 +433,45 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
} else {
sprintf(reply, "OK - slot %d preset: %s", slot + 1, preset_name);
}
// Warn when this slot won't actually connect on this hardware. The set
// is never blocked -- prefs persist so the config carries over if the
// device is moved to a board with more slots -- but flag it, or the
// operator waits for a connection that never comes (A15). Two failure
// modes, keyed off the same rule the bridge's setup loop uses
// (classifySlotActivation): slots past the runtime array are never
// tried; slots within it are skipped once more than getMaxActiveSlots()
// are enabled (each WSS/TLS link costs ~40 KB heap).
if (strcmp(preset_name, MQTT_PRESET_NONE) != 0) {
bool slot_enabled[MAX_MQTT_SLOTS];
for (int s = 0; s < MAX_MQTT_SLOTS; s++) {
slot_enabled[s] = _mqtt_prefs.mqtt_slot_preset[s][0] != '\0' &&
strcmp(_mqtt_prefs.mqtt_slot_preset[s], MQTT_PRESET_NONE) != 0;
}
const int max_active = MQTTBridge::getMaxActiveSlots();
const MQTTConnectionPolicy::SlotActivation act =
MQTTConnectionPolicy::classifySlotActivation(slot, slot_enabled,
RUNTIME_MQTT_SLOTS, max_active);
size_t used = strlen(reply);
if (used < 158) {
if (act == MQTTConnectionPolicy::SlotActivation::BeyondArray) {
snprintf(reply + used, 160 - used, " (slot inactive on this hardware)");
} else if (act == MQTTConnectionPolicy::SlotActivation::OverActiveCap) {
snprintf(reply + used, 160 - used,
" (won't connect: %d-slot limit on this hardware)", max_active);
}
}
}
}
} else {
strcpy(reply, "Error: unknown preset. Use 'get mqtt.presets'");
}
} else if (memcmp(subcmd, "server ", 7) == 0) {
if (valueTooLong(&subcmd[7], sizeof(_mqtt_prefs.mqtt_slot_host[slot]), reply, "server")) return true;
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_host[slot], &subcmd[7], sizeof(_mqtt_prefs.mqtt_slot_host[slot]));
savePrefs();
// Reconfigure the slot so the new host reaches the live connection (other
// custom-slot setters do the same; without it the change only applies on
// the next reboot/bridge restart).
_callbacks->restartBridgeSlot(slot);
strcpy(reply, "OK");
} else if (memcmp(subcmd, "port ", 5) == 0) {
@@ -398,16 +485,19 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
strcpy(reply, "Error: port must be between 1 and 65535");
}
} else if (memcmp(subcmd, "username ", 9) == 0) {
if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_username[slot]), reply, "username")) return true;
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_username[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_username[slot]));
savePrefs();
_callbacks->restartBridgeSlot(slot);
strcpy(reply, "OK");
} else if (memcmp(subcmd, "password ", 9) == 0) {
if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_password[slot]), reply, "password")) return true;
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_password[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_password[slot]));
savePrefs();
_callbacks->restartBridgeSlot(slot);
strcpy(reply, "OK");
} else if (memcmp(subcmd, "token ", 6) == 0) {
if (valueTooLong(&subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_token[slot]), reply, "token")) return true;
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_token[slot], &subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_token[slot]));
savePrefs();
_callbacks->restartBridgeSlot(slot);
@@ -415,6 +505,8 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
} else if (memcmp(subcmd, "topic ", 6) == 0) {
if (strcmp(_mqtt_prefs.mqtt_slot_preset[slot], "custom") != 0) {
sprintf(reply, "Error: topic template only applies to custom preset slots");
} else if (valueTooLong(&subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_topic[slot]), reply, "topic")) {
return true;
} else {
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_topic[slot], &subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_topic[slot]));
savePrefs();
@@ -422,6 +514,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
sprintf(reply, "OK - slot %d topic: %s", slot + 1, _mqtt_prefs.mqtt_slot_topic[slot]);
}
} else if (memcmp(subcmd, "audience ", 9) == 0) {
if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_audience[slot]), reply, "audience")) return true;
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_audience[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_audience[slot]));
savePrefs();
_callbacks->restartBridgeSlot(slot);
@@ -465,28 +558,21 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
strcpy(reply, "OK - owner cleared");
} else if (memcmp(config, "mqtt.owner ", 11) == 0) {
const char* owner_key = &config[11];
int key_len = strlen(owner_key);
if (key_len == 64) {
bool valid_key = true;
for (int i = 0; i < key_len; i++) {
if (!((owner_key[i] >= '0' && owner_key[i] <= '9') ||
(owner_key[i] >= 'A' && owner_key[i] <= 'F') ||
(owner_key[i] >= 'a' && owner_key[i] <= 'f'))) {
valid_key = false;
break;
}
}
if (valid_key) {
StrHelper::strncpy(_mqtt_prefs.mqtt_owner_public_key, owner_key, sizeof(_mqtt_prefs.mqtt_owner_public_key));
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error: invalid hex characters in public key");
}
if (owner_key[0] == '\0') {
// Owner key is optional -- empty clears it (previously this errored, so a
// set key could never be removed via the portal/CLI).
_mqtt_prefs.mqtt_owner_public_key[0] = '\0';
savePrefs();
strcpy(reply, "OK - owner key cleared");
} else if (mqttOwnerKeyValid(owner_key)) {
StrHelper::strncpy(_mqtt_prefs.mqtt_owner_public_key, owner_key, sizeof(_mqtt_prefs.mqtt_owner_public_key));
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error: public key must be 64 hex characters (32 bytes)");
}
} else if (memcmp(config, "mqtt.email ", 11) == 0) {
if (valueTooLong(&config[11], sizeof(_mqtt_prefs.mqtt_email), reply, "email")) return true;
StrHelper::strncpy(_mqtt_prefs.mqtt_email, &config[11], sizeof(_mqtt_prefs.mqtt_email));
savePrefs();
strcpy(reply, "OK");
@@ -702,6 +788,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
start = (int)_atoi(start_arg);
}
formatMQTTPresetListReply(reply, 160, start);
} else if (memcmp(config, "mqtt.stats", 10) == 0) {
MQTTBridge::formatMqttStatsReply(reply, 160);
} else if (memcmp(config, "mqtt.status", 11) == 0) {
MQTTBridge::formatMqttStatusReply(reply, 160, &_mqtt_prefs);
} else if (memcmp(config, "mqtt.packets", 12) == 0) {
@@ -716,6 +804,19 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
} else if (memcmp(config, "mqtt.interval", 13) == 0) {
uint32_t minutes = (_mqtt_prefs.mqtt_status_interval + 29999) / 60000;
sprintf(reply, "> %u minutes (%lu ms)", minutes, (unsigned long)_mqtt_prefs.mqtt_status_interval);
#if defined(WITH_MQTT_NEIGHBORS)
// Longer token first: a bare "mqtt.neighbors" (14) would otherwise swallow
// "mqtt.neighbors.interval" since the GET tokens carry no trailing space.
} else if (memcmp(config, "mqtt.neighbors.interval", 23) == 0) {
uint32_t hours = (_mqtt_prefs.mqtt_neighbors_interval + 3599999) / 3600000;
sprintf(reply, "> %u hours (%lu ms)", (unsigned)hours, (unsigned long)_mqtt_prefs.mqtt_neighbors_interval);
} else if (memcmp(config, "mqtt.neighbors", 14) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_neighbors_enabled ? "on" : "off");
#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)");
#endif
} else if (memcmp(config, "mqtt.ntp.diag", 13) == 0 && (config[13] == '\0' || config[13] == ' ')) {
#if defined(PORTABLE_MQTT_OBSERVER)
strcpy(reply, "Error: NTP diagnostics omitted from portable build");
@@ -750,12 +851,20 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
} else if (memcmp(subcmd, "username", 8) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_username[slot]);
} else if (memcmp(subcmd, "password", 8) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_password[slot]);
// Serial only; remote sees set/unset.
if (sender_timestamp == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_password[slot]);
} else {
strcpy(reply, _mqtt_prefs.mqtt_slot_password[slot][0] ? "> ******** (serial only)" : "> (not set)");
}
} else if (memcmp(subcmd, "token", 5) == 0) {
if (_mqtt_prefs.mqtt_slot_token[slot][0] != '\0') {
// Serial only; remote sees set/unset.
if (_mqtt_prefs.mqtt_slot_token[slot][0] == '\0') {
strcpy(reply, "> (not set)");
} else if (sender_timestamp == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_token[slot]);
} else {
strcpy(reply, "> (not set)");
strcpy(reply, "> ******** (serial only)");
}
} else if (memcmp(subcmd, "topic", 5) == 0) {
if (_mqtt_prefs.mqtt_slot_topic[slot][0] != '\0') {
@@ -777,7 +886,12 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
} else if (memcmp(config, "wifi.ssid", 9) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.wifi_ssid);
} else if (memcmp(config, "wifi.pwd", 8) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.wifi_password);
// Serial only (WiFi creds grant LAN access); remote sees set/unset.
if (sender_timestamp == 0) {
sprintf(reply, "> %s", _mqtt_prefs.wifi_password);
} else {
strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (serial only)" : "> (not set)");
}
} else if (memcmp(config, "wifi.status", 11) == 0) {
wl_status_t status = WiFi.status();
const char* status_str;
@@ -801,8 +915,11 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
unsigned long h = (uptime_sec % 86400) / 3600;
unsigned long m = (uptime_sec % 3600) / 60;
unsigned long s = uptime_sec % 60;
// reply points at the caller's char[160] command buffer (see main.cpp);
// compute the actual remaining space instead of assuming 128.
const size_t kReplyBufSize = 160;
size_t len = strlen(reply);
const size_t reply_remaining = 128;
const size_t reply_remaining = (len < kReplyBufSize) ? (kReplyBufSize - len) : 0;
if (d > 0) {
snprintf(reply + len, reply_remaining, ", uptime: %lud %luh %lum %lus", d, h, m, s);
} else if (h > 0) {
@@ -835,10 +952,13 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
uint8_t ps = _mqtt_prefs.wifi_power_save;
const char* ps_name = (ps == 1) ? "none" : (ps == 2) ? "max" : "min";
sprintf(reply, "> %s", ps_name);
} else if (memcmp(config, "timezone.offset", 15) == 0) {
// Must precede the "timezone" (8-byte) check below -- that prefix-matches
// "timezone.offset" too, so the more-specific key has to come first or
// `get timezone.offset` returns the string and never the offset (A3).
sprintf(reply, "> %d", _mqtt_prefs.timezone_offset);
} else if (memcmp(config, "timezone", 8) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.timezone_string);
} else if (memcmp(config, "timezone.offset", 15) == 0) {
sprintf(reply, "> %d", _mqtt_prefs.timezone_offset);
} else if (memcmp(config, "mqtt.analyzer.us", 17) == 0) {
sprintf(reply, "> %s", strcmp(_mqtt_prefs.mqtt_slot_preset[0], "analyzer-us") == 0 ? "on" : "off");
} else if (memcmp(config, "mqtt.analyzer.eu", 17) == 0) {
@@ -985,6 +1105,21 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command,
strcpy(reply, "ERR: online OTA not supported on this build");
#endif
return true;
} else if (memcmp(command, "start webconfig", 15) == 0 && (command[15] == 0 || command[15] == ' ')) {
// Web config portal: `start webconfig` binds to the LAN IP (or raises the
// setup AP when WiFi is unconfigured); `start webconfig ap` forces the AP.
bool force_ap = (command[15] == ' ' && strcmp(&command[16], "ap") == 0);
if (command[15] == ' ' && !force_ap) {
strcpy(reply, "ERR: usage start webconfig [ap]");
} else if (!_callbacks->startWebConfig(force_ap, reply)) {
strcpy(reply, "ERR: webconfig not supported on this build");
}
return true;
} else if (strcmp(command, "stop webconfig") == 0) {
if (!_callbacks->stopWebConfig(reply)) {
strcpy(reply, "ERR: webconfig not supported on this build");
}
return true;
} else if (memcmp(command, "alert test", 10) == 0 && (command[10] == 0 || command[10] == ' ')) {
// Send a one-off test alert on the configured alert channel.
const char* extra = command[10] == ' ' ? &command[11] : "";
@@ -998,7 +1133,16 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command,
strcpy(reply, "Error: alert channel not configured (set alert.psk or set alert.hashtag)");
} else {
bool ok = _callbacks->sendAlertText(text);
strcpy(reply, ok ? "OK - alert sent" : "Error: alert send failed (bad PSK or PUBLIC key refused?)");
if (!ok) {
strcpy(reply, "Error: alert send failed (bad PSK or PUBLIC key refused?)");
} else if (!_mqtt_prefs.alert_enabled) {
// `alert test` deliberately bypasses the master switch, so a successful
// send here does NOT mean automatic WiFi/MQTT/OTA alerts will fire -- those
// gate on `alert on`. Flag it so a working test can't give false confidence.
strcpy(reply, "OK - test sent, but automatic alerts are OFF (run 'set alert on')");
} else {
strcpy(reply, "OK - alert sent");
}
}
return true;
}
+8 -5
View File
@@ -211,12 +211,12 @@ public:
static LightweightOTAServer lightweight_ota_server;
bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
(void)id;
inhibit_sleep = true;
IPAddress ip;
if (WiFi.status() == WL_CONNECTED) {
if (!force_ap && WiFi.status() == WL_CONNECTED) {
ip = WiFi.localIP();
} else {
if (!lightweight_ota_started_ap) {
@@ -273,7 +273,7 @@ bool ESP32Board::stopOTAUpdate(char reply[]) {
#include <SPIFFS.h>
bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
inhibit_sleep = true; // prevent sleep during OTA
if (ota_server != nullptr) { // already running (idempotent restart)
@@ -285,8 +285,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
// If the device is already on a WiFi network (e.g. an observer joined in STA
// mode), serve ElegantOTA on the station IP so it's reachable from the LAN
// without joining a separate AP. Otherwise raise the MeshCore-OTA SoftAP.
// force_ap ("start ota ap") always raises the SoftAP, so the OTA UI stays
// reachable even when the joined network applies client isolation and the
// station IP can't be reached.
IPAddress ip;
if (WiFi.status() == WL_CONNECTED) {
if (!force_ap && WiFi.status() == WL_CONNECTED) {
ip = WiFi.localIP();
} else {
const IPAddress ap_ip(192, 168, 4, 1);
@@ -343,7 +346,7 @@ bool ESP32Board::stopOTAUpdate(char reply[]) {
}
#else
bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
return false; // not supported
}
+1 -1
View File
@@ -180,7 +180,7 @@ public:
esp_restart();
}
bool startOTAUpdate(const char* id, char reply[]) override;
bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override;
bool stopOTAUpdate(char reply[]) override;
bool isOTAUpdateRunning() const override { return ota_server != nullptr; }
bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) override;
+130
View File
@@ -0,0 +1,130 @@
#ifdef ESP_PLATFORM
// Link-time workaround for an off-by-one heap overflow in ESP-IDF v4.4's
// WebSocket transport (components/tcp_transport/transport_ws.c), which ships
// PRECOMPILED in the Arduino-ESP32 2.x SDK (libtcp_transport.a) and cannot be
// patched at source level.
//
// The bug (transport_ws.c, ws_connect() response-read loop):
//
// header_len += len;
// ws->buffer[header_len] = '\0'; // header_len can reach WS_BUFFER_SIZE
// } while (... && header_len < WS_BUFFER_SIZE);
//
// ws->buffer is malloc(WS_BUFFER_SIZE) (1024). When a wss:// endpoint answers
// the WebSocket upgrade with >= 1024 bytes of HTTP response before the blank
// line terminator (typical for a down/misconfigured broker behind a proxy or
// CDN that serves a large HTML error page), the final iteration writes one
// '\0' one byte past the block. With heap poisoning enabled that zeroes the
// LSB of the tail canary (0xbaad5678 -> 0xbaad5600); the corruption then sits
// silent until the block is freed -- which happens in ws_destroy() during
// esp_mqtt_client_destroy(), i.e. MQTTBridge::end() -- and the free asserts:
//
// CORRUPT HEAP: Bad tail at 0x.... Expected 0xbaad5678 got 0xbaad5600
// assert failed: multi_heap_free multi_heap_poisoning.c:259
//
// On observer builds that teardown runs at the start of the deferred
// `ota update`, so a single down wss broker made every online OTA panic and
// reboot before the download began (backtrace decoded from a Heltec V3 on
// v1.16.0.11: free <- ws_destroy <- esp_transport_list_destroy <-
// esp_mqtt_client_destroy <- ~PsychicMqttClient <- destroySlotClients <-
// MQTTBridge::end <- MyMesh::setBridgeState <- MyMesh::loop).
//
// Fix: [esp32_base] adds `-Wl,--wrap=esp_transport_ws_init`, so every
// creation of a WS transport (esp-mqtt does one per wss slot) is routed
// through __wrap_esp_transport_ws_init below, which replaces the freshly
// allocated 1024-byte buffer with a (WS_BUFFER_SIZE + 1)-byte one. The
// out-of-bounds index WS_BUFFER_SIZE then lands on our extra byte and the
// handshake fails cleanly ("Upgrade" header not found) instead of corrupting
// the heap. Upstream fixed this in ESP-IDF 5.x, so this file compiles to a
// pass-through there and can be deleted (together with the --wrap flag) when
// the fork moves to Arduino core 3.x.
//
// transport_ws_t below is copied verbatim from ESP-IDF release/v4.4
// transport_ws.c (the struct is file-private, so it is not in any shipped
// header). Source fidelity was verified against the shipped binary: addr2line
// on the crash backtrace resolves to the exact line numbers of that file
// (e.g. free(ws->buffer) at transport_ws.c:546). Only the first two members
// (path, buffer) are dereferenced here.
#include "esp_idf_version.h"
#if ESP_IDF_VERSION_MAJOR == 4
#include <stdlib.h>
#include "sdkconfig.h"
#include "esp_transport.h"
#include "esp_transport_ws.h"
#ifndef CONFIG_WS_BUFFER_SIZE
#define CONFIG_WS_BUFFER_SIZE 1024
#endif
// --- copied from ESP-IDF release/v4.4 components/tcp_transport/transport_ws.c ---
typedef struct {
uint8_t opcode;
char mask_key[4];
int payload_len;
int bytes_remaining;
bool header_received;
} ws_transport_frame_state_t;
typedef struct {
char *path;
char *buffer;
char *sub_protocol;
char *user_agent;
char *headers;
bool propagate_control_frames;
ws_transport_frame_state_t frame_state;
esp_transport_handle_t parent;
} transport_ws_t;
// --------------------------------------------------------------------------------
extern "C" {
esp_transport_handle_t __real_esp_transport_ws_init(esp_transport_handle_t parent_handle);
esp_transport_handle_t __wrap_esp_transport_ws_init(esp_transport_handle_t parent_handle) {
esp_transport_handle_t t = __real_esp_transport_ws_init(parent_handle);
if (t != nullptr) {
transport_ws_t* ws = (transport_ws_t*)esp_transport_get_context_data(t);
if (ws != nullptr && ws->buffer != nullptr) {
// The buffer is untouched at this point (allocated moments ago inside
// __real_esp_transport_ws_init), so a swap is safe.
char* padded = (char*)malloc(CONFIG_WS_BUFFER_SIZE + 1);
if (padded != nullptr) {
free(ws->buffer);
ws->buffer = padded;
}
// On alloc failure keep the original buffer: same behavior as before
// this fix, which is still strictly better than failing init here.
}
}
return t;
}
} // extern "C"
#else // ESP_IDF_VERSION_MAJOR != 4
// IDF 5.x fixed the overflow upstream; keep a pass-through so the --wrap flag
// (set for all ESP32 envs in [esp32_base]) still links if anything references
// the symbol.
#include "esp_transport.h"
#include "esp_transport_ws.h"
extern "C" {
esp_transport_handle_t __real_esp_transport_ws_init(esp_transport_handle_t parent_handle);
esp_transport_handle_t __wrap_esp_transport_ws_init(esp_transport_handle_t parent_handle) {
return __real_esp_transport_ws_init(parent_handle);
}
} // extern "C"
#endif // ESP_IDF_VERSION_MAJOR
#endif // ESP_PLATFORM
+195
View File
@@ -0,0 +1,195 @@
#pragma once
#include <stdint.h>
// Pure timing and state-transition policy used by MQTTBridge's connection
// maintenance loop. Keeping these decisions independent of Arduino, WiFi, and
// the MQTT client lets host tests exercise the exact production policy with a
// deterministic clock.
namespace MQTTConnectionPolicy {
static const uint32_t kReconnectGuardMs = 15000UL;
static const uint32_t kStableResetMs = 120000UL;
static const uint32_t kCircuitBreakerProbeMs = 1800000UL;
static const uint32_t kRenewalThrottleMs = 60000UL;
static const uint32_t kSlotStaggerMs = 3000UL;
static const uint8_t kMaxFailuresAtMaxBackoff = 3;
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
// Unsigned subtraction is intentionally used: it is the standard millis()
// idiom and remains correct across a single 32-bit counter rollover.
static inline uint32_t elapsedMs(uint32_t now, uint32_t then) {
return now - then;
}
static inline bool reconnectGuardActive(uint32_t now, uint32_t last_reconnect) {
return elapsedMs(now, last_reconnect) < kReconnectGuardMs;
}
static inline bool stableConnection(uint32_t now, uint32_t connected_at) {
return connected_at != 0 && elapsedMs(now, connected_at) >= kStableResetMs;
}
static inline uint32_t reconnectBackoffMs(uint8_t reconnect_backoff) {
static const uint32_t kBackoffMs[] = {
10000UL, 30000UL, 60000UL, 120000UL, 300000UL
};
const uint8_t index = reconnect_backoff < 5 ? reconnect_backoff : 4;
return kBackoffMs[index];
}
static inline uint32_t reconnectDelayMs(uint8_t reconnect_backoff, uint8_t slot_index) {
return reconnectBackoffMs(reconnect_backoff) +
static_cast<uint32_t>(slot_index) * kSlotStaggerMs;
}
static inline bool reconnectDue(uint32_t now, uint32_t last_attempt,
uint8_t reconnect_backoff, uint8_t slot_index) {
return elapsedMs(now, last_attempt) >= reconnectDelayMs(reconnect_backoff, slot_index);
}
struct BackoffAdvance {
uint8_t reconnect_backoff;
uint8_t max_backoff_failures;
bool circuit_breaker_tripped;
bool should_reconnect;
};
// Advance the ladder immediately before a due reconnect. The first visit to
// the 300-second rung changes level 4 to the saturated marker 5. Three later
// failures at that rung trip the breaker; the third does not launch another
// connection attempt.
static inline BackoffAdvance advanceBackoff(uint8_t reconnect_backoff,
uint8_t max_backoff_failures) {
BackoffAdvance result = {
reconnect_backoff, max_backoff_failures, false, true
};
if (result.reconnect_backoff < 5) {
result.reconnect_backoff++;
return result;
}
if (result.max_backoff_failures < UINT8_MAX) {
result.max_backoff_failures++;
}
if (result.max_backoff_failures >= kMaxFailuresAtMaxBackoff) {
result.circuit_breaker_tripped = true;
result.should_reconnect = false;
}
return result;
}
static inline bool circuitBreakerProbeDue(uint32_t now, uint32_t last_attempt) {
return elapsedMs(now, last_attempt) >= kCircuitBreakerProbeMs;
}
// WiFi station reconnect backoff. The bridge drives its own STA reconnect loop
// separate from the per-slot MQTT reconnects, with a slightly longer first rung
// (15 s vs the slot ladder's 10 s). Extracted from handleWiFiConnection() so the
// ladder and its wrap-safe timing are exercised by host tests instead of a
// second inline copy of the backoff math.
static inline uint32_t wifiReconnectBackoffMs(uint8_t attempt) {
static const uint32_t kBackoffMs[] = {
15000UL, 30000UL, 60000UL, 120000UL, 300000UL
};
const uint8_t index = attempt < 5 ? attempt : 4;
return kBackoffMs[index];
}
// A reconnect is due only once the link has been down for the current rung AND
// no attempt has been made within that rung (both measured wrap-safely). This
// mirrors the two-part guard the bridge applied inline.
static inline bool wifiReconnectDue(uint32_t now, uint32_t disconnected_since,
uint32_t last_attempt, uint8_t attempt) {
const uint32_t delay = wifiReconnectBackoffMs(attempt);
return elapsedMs(now, disconnected_since) >= delay &&
elapsedMs(now, last_attempt) >= delay;
}
// The attempt counter climbs to 5 and then saturates; the index clamp in
// wifiReconnectBackoffMs() holds it at the 300 s rung.
static inline uint8_t nextWifiBackoffAttempt(uint8_t attempt) {
return attempt < 5 ? static_cast<uint8_t>(attempt + 1) : attempt;
}
// Each later slot expires up to five percent of the base lifetime earlier,
// capped at five minutes per slot. Runtime slot indexes are bounded by the
// persisted MQTT slot count; the final clamp also prevents underflow if this
// helper is used with unexpected input.
static inline uint32_t jwtLifetimeSecs(uint32_t base_lifetime, uint8_t slot_index) {
uint32_t per_slot_stagger = base_lifetime / 20UL;
if (per_slot_stagger > kMaxJwtStaggerSecs) {
per_slot_stagger = kMaxJwtStaggerSecs;
}
uint64_t stagger = static_cast<uint64_t>(slot_index) * per_slot_stagger;
if (stagger > base_lifetime) {
stagger = base_lifetime;
}
return base_lifetime - static_cast<uint32_t>(stagger);
}
static inline uint32_t renewalBufferSecs(uint32_t lifetime_secs) {
uint32_t buffer = lifetime_secs / 10UL;
if (buffer < 60UL) buffer = 60UL;
if (buffer > 300UL) buffer = 300UL;
return buffer;
}
static inline bool tokenNeedsRenewal(bool time_synced, uint32_t current_time,
uint32_t token_expires_at,
uint32_t renewal_buffer_secs) {
if (!time_synced) {
return token_expires_at == 0;
}
if (token_expires_at < kMinimumValidEpoch) {
return true;
}
if (current_time >= token_expires_at) {
return true;
}
return current_time >= token_expires_at - renewal_buffer_secs;
}
static inline bool renewalAttemptAllowed(uint32_t now, uint32_t last_attempt) {
return elapsedMs(now, last_attempt) >= kRenewalThrottleMs;
}
static inline bool jwtClockAvailable(bool ntp_synced, uint32_t current_time) {
return ntp_synced || current_time >= kJwtClockThreshold;
}
// How a given MQTT slot fares at bridge setup on this hardware. MQTTBridge's
// setup loop iterates runtime slots in index order and connects the first
// `max_active` *enabled* slots, skipping the rest (each WSS/TLS link needs
// ~40 KB internal heap, so non-PSRAM caps at 2 concurrent, PSRAM at 5). Slots at
// or beyond the runtime array size (`slot_count`, e.g. 3 on non-PSRAM) are never
// iterated at all. Extracted so the CLI can tell the operator, at
// `set mqttN.preset` time, whether a slot will actually come up -- and so the
// exact rule is host-tested rather than hand-reasoned (it is easy to conflate
// slot_count with max_active).
enum class SlotActivation : uint8_t {
Connects, // enabled and within the concurrent-connection budget
Disabled, // slot has no preset ("none") -- not attempted
BeyondArray, // index >= slot_count: outside the runtime slot array here
OverActiveCap, // enabled, but lower-numbered slots already fill the budget
};
// `enabled` must have at least `slot_count` entries; `slot` is 0-based. Mirrors
// the bridge's first-come-by-index activation order exactly.
static inline SlotActivation classifySlotActivation(int slot, const bool* enabled,
int slot_count, int max_active) {
if (slot < 0) return SlotActivation::Disabled;
if (slot >= slot_count) return SlotActivation::BeyondArray;
if (enabled == nullptr || !enabled[slot]) return SlotActivation::Disabled;
int rank = 0; // this slot's position among enabled slots, counting by index
for (int i = 0; i <= slot; i++) {
if (enabled[i]) rank++;
}
return (rank <= max_active) ? SlotActivation::Connects
: SlotActivation::OverActiveCap;
}
} // namespace MQTTConnectionPolicy
+5
View File
@@ -100,6 +100,11 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) {
prefs->alert_wifi_minutes = 30;
prefs->alert_mqtt_minutes = 240;
prefs->alert_min_interval_min = 60;
// Neighbors publishing defaults off; a defaulted tail is a valid 24h interval
// (not 0) so an in-lineage upgrade from a pre-neighbors payload is sane.
prefs->mqtt_neighbors_enabled = 0;
prefs->mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS;
}
#endif // WITH_MQTT_BRIDGE
+328
View File
@@ -0,0 +1,328 @@
#pragma once
#include <stdint.h>
// Fork-owned, dependency-free MQTT bridge lifecycle state machine and the
// narrow dependency seam used to drive it deterministically in host tests.
//
// This is the Phase 4 "ownership and teardown test seam" from
// STABILITY_TESTABILITY_HANDOFF.md. It is intentionally pure (no Arduino,
// FreeRTOS, WiFi, or PsychicMqttClient dependencies) so the exact
// start/stop/restart contract can be exercised with a fake clock and a
// recording Ops double, the same way MQTTConnectionPolicy.h and
// MQTTRuntimeBufferLifecycle.h are tested.
//
// Scope boundary (Phase 4 vs Phase 5): this header is the SPEC and the test
// seam. It is not yet wired into MQTTBridge. Phase 5 ("Implement cooperative
// MQTT shutdown") supplies FreeRTOS/PsychicMqttClient-backed Ops and replaces
// the abrupt vTaskDelete in MQTTBridge::end() with this cooperative lifecycle.
// See MQTT_OWNERSHIP.md for the ownership model and the migration plan.
//
// Behavior source (Phase 0 discipline): every transition and invariant encoded
// here is derived from the current MQTTBridge.cpp control flow (begin()/end(),
// mqttTaskLoop(), the volatile handshakes) -- not from a hardware soak. Values
// that require on-hardware characterization (the concrete stop timeout, exact
// mbedTLS teardown timing) are called out with "Phase 0 TODO" and left as
// injectable parameters rather than guessed constants.
namespace MQTTLifecycle {
// The lifecycle proposed by the handoff:
// Stopped -> Starting -> Running -> StopRequested -> Stopping -> Stopped
//
// StopRequested: a stop has been requested and delivered through the ownership
// channel, but the MQTT task has not yet begun its ordered shutdown.
// Stopping: the MQTT task is performing its ordered client/service
// shutdown. A StopBegan signal is optional; a task may ack directly from
// StopRequested if it does not report the intermediate step.
enum class State : uint8_t {
Stopped = 0,
Starting,
Running,
StopRequested,
Stopping,
};
// Events driven either by the owner (loop task) or by the MQTT task reporting
// its own progress. StopTimedOut is synthesized by the Coordinator when a stop
// is not acknowledged within the bounded timeout (the reviewed fallback).
enum class Event : uint8_t {
StartRequested = 0, // owner asked the bridge to start
StartCompleted, // MQTT task signalled init complete (StartAck)
StartFailed, // init failed on the task (partial-init rollback)
StopRequested, // owner (or OTA barrier) asked the bridge to stop
StopBegan, // MQTT task began its ordered shutdown (optional)
StopAcknowledged, // MQTT task signalled ordered shutdown complete (StopAck)
StopTimedOut, // bounded timeout expired with no StopAck (fallback)
};
// Side effects a transition asks the caller to perform. Naming WHO does WHAT
// keeps the Phase 5 production wiring and the host fakes on one contract:
// - create_task: owner creates/pins the MQTT task.
// - deliver_stop: owner delivers the stop request through the channel.
// - release_resources: owner may now free the queue, runtime buffers, and the
// task handle. Fires only after a completed/forced stop
// or an init-failure rollback -- never mid-run.
// - ota_release: the OTA barrier's completion acknowledgment is now
// available (a stop reached a terminal state).
struct Effects {
bool create_task = false;
bool deliver_stop = false;
bool release_resources = false;
bool ota_release = false;
};
struct Result {
State next;
Effects effects;
bool accepted; // false => the event was a no-op in this state (idempotency)
};
// Unsigned subtraction is the standard millis() idiom and stays correct across
// a single 32-bit rollover (mirrors MQTTConnectionPolicy::elapsedMs).
inline uint32_t elapsedMs(uint32_t now, uint32_t then) { return now - then; }
// Pure transition function. For a rejected/no-op event the result reports the
// unchanged state, no effects, and accepted == false.
inline Result apply(State s, Event e) {
Result r{s, Effects{}, false};
switch (s) {
case State::Stopped:
// Only a start is meaningful. A stop while already stopped is a no-op
// (idempotent stop). StartFailed/StopAck cannot occur here.
if (e == Event::StartRequested) {
r.next = State::Starting;
r.effects.create_task = true;
r.accepted = true;
}
break;
case State::Starting:
switch (e) {
case Event::StartCompleted:
r.next = State::Running;
r.accepted = true;
break;
case Event::StartFailed:
// Partial-init rollback: release only what the attempt acquired.
r.next = State::Stopped;
r.effects.release_resources = true;
r.accepted = true;
break;
case Event::StopRequested:
// Stop before full initialization: accept it and let the task ack.
r.next = State::StopRequested;
r.effects.deliver_stop = true;
r.accepted = true;
break;
default:
break;
}
break;
case State::Running:
if (e == Event::StopRequested) {
r.next = State::StopRequested;
r.effects.deliver_stop = true;
r.accepted = true;
}
// A duplicate StartRequested/StartCompleted while Running is a no-op.
break;
case State::StopRequested:
switch (e) {
case Event::StopBegan:
r.next = State::Stopping;
r.accepted = true;
break;
case Event::StopAcknowledged:
case Event::StopTimedOut:
r.next = State::Stopped;
r.effects.release_resources = true;
r.effects.ota_release = true;
r.accepted = true;
break;
default:
// Duplicate StopRequested is a no-op (idempotent stop).
break;
}
break;
case State::Stopping:
switch (e) {
case Event::StopAcknowledged:
case Event::StopTimedOut:
r.next = State::Stopped;
r.effects.release_resources = true;
r.effects.ota_release = true;
r.accepted = true;
break;
default:
break;
}
break;
}
return r;
}
// New connects/publishes/retries/reconfigurations are permitted only while the
// bridge is actively running (or still coming up). Once a stop is requested,
// all new work must cease (handoff: "Cessation of new connects, publishes,
// retries, and reconfigurations").
inline bool acceptsNewWork(State s) {
return s == State::Starting || s == State::Running;
}
// A late/stale client callback may touch owner-released resources ONLY before
// the owner has freed them. After the terminal Stopped state the queue,
// buffers, and clients may be gone, so a callback arriving then must be a
// no-op. (Callbacks that fire during StopRequested/Stopping run before
// release_resources and are still safe.)
inline bool mayTouchOwnedState(State s) { return s != State::Stopped; }
// A restart (begin()) is safe only from a completed stop.
inline bool mayRestart(State s) { return s == State::Stopped; }
inline bool isStopInProgress(State s) {
return s == State::StopRequested || s == State::Stopping;
}
inline const char* stateName(State s) {
switch (s) {
case State::Stopped: return "Stopped";
case State::Starting: return "Starting";
case State::Running: return "Running";
case State::StopRequested: return "StopRequested";
case State::Stopping: return "Stopping";
}
return "?";
}
inline const char* eventName(Event e) {
switch (e) {
case Event::StartRequested: return "StartRequested";
case Event::StartCompleted: return "StartCompleted";
case Event::StartFailed: return "StartFailed";
case Event::StopRequested: return "StopRequested";
case Event::StopBegan: return "StopBegan";
case Event::StopAcknowledged: return "StopAcknowledged";
case Event::StopTimedOut: return "StopTimedOut";
}
return "?";
}
// Narrow dependency seam. These are the only dependencies the lifecycle needs
// to be driven deterministically (handoff: "for only the dependencies needed").
// Phase 5 implements this over FreeRTOS + PsychicMqttClient; host tests
// implement it as a recording double with a settable clock.
//
// Dependencies enumerated by the handoff and where they land:
// - Clock/timer -> nowMs()
// - Task start/stop/ack/timeout -> startTask()/deliverStop() + the
// onTaskStarted()/onTaskStopped() callbacks
// into Coordinator, and tick() for timeout
// - Runtime allocator + queue -> folded into releaseResources() here; the
// allocate/free symmetry itself is already
// covered by MQTTRuntimeBufferLifecycle.h,
// and queue behavior by Phase 6
// - MQTT client connect/disconnect + delayed callbacks -> modeled by the
// mayTouchOwnedState() guard (a callback
// decides whether it may touch owned state)
// - OTA coordinator/barrier -> onStopComplete(clean) + mayBeginFlash()
struct Ops {
virtual ~Ops() = default;
virtual uint32_t nowMs() = 0; // monotonic ms (millis())
virtual void startTask() = 0; // create/pin the MQTT task
virtual void deliverStop() = 0; // signal stop through the channel
virtual void releaseResources() = 0; // free queue/buffers/task (post-stop)
// Unblock the OTA barrier's waiter. clean == true after a StopAcknowledged;
// clean == false after a StopTimedOut, so OTA aborts rather than flashing
// under uncertain ownership (handoff OTA barrier: "MQTT stop times out: OTA
// aborts safely rather than writing under uncertain ownership").
virtual void onStopComplete(bool clean) = 0;
};
// Drives the state machine against injected Ops and hosts the bounded stop
// timeout. Idempotent start/stop; safe restart after a completed stop.
class Coordinator {
public:
// stop_timeout_ms is the bound on how long a requested stop may run before the
// reviewed force-kill fallback fires. It is injected (not a constant here) and
// may be updated per stop via setStopTimeoutMs(): Phase 0 hardware
// characterization (2026-07-19) showed real mbedTLS/wss teardown scales with
// the number of connected slots (~5-6 s each, sequential), so the owner sizes
// it to the current slot count before each stop. See MQTTBridge::end().
Coordinator(Ops& ops, uint32_t stop_timeout_ms)
: _ops(ops), _stop_timeout_ms(stop_timeout_ms) {}
State state() const { return _state; }
bool stopTimedOut() const { return _stop_timed_out; }
bool acceptsNewWork() const { return MQTTLifecycle::acceptsNewWork(_state); }
bool mayTouchOwnedState() const {
return MQTTLifecycle::mayTouchOwnedState(_state);
}
bool isStopInProgress() const {
return MQTTLifecycle::isStopInProgress(_state);
}
// A restart is safe from a completed stop. A stop that reached Stopped via
// the timeout fallback still allows restart (the bridge is down); only OTA
// flashing is withheld after a dirty stop.
bool mayRestart() const { return MQTTLifecycle::mayRestart(_state); }
// OTA erase/write is permitted only after a CLEAN stop. A timed-out stop
// leaves ownership uncertain, so flashing stays blocked until a clean
// start/stop cycle clears the latch.
bool mayBeginFlash() const {
return _state == State::Stopped && !_stop_timed_out;
}
// Update the stop-timeout bound. Call before requestStop() to size the window
// to the current slot count (see MQTTBridge::end()); the value is read by
// tick() against _stop_request_ms, which requestStop() arms afterwards.
void setStopTimeoutMs(uint32_t ms) { _stop_timeout_ms = ms; }
uint32_t stopTimeoutMs() const { return _stop_timeout_ms; }
bool requestStart() { return dispatch(Event::StartRequested); }
bool requestStop() { return dispatch(Event::StopRequested); }
bool onTaskStarted() { return dispatch(Event::StartCompleted); }
bool onTaskStartFailed() { return dispatch(Event::StartFailed); }
bool onStopBegan() { return dispatch(Event::StopBegan); }
bool onTaskStopped() { return dispatch(Event::StopAcknowledged); }
// Call periodically from the owner. Fires the reviewed timeout fallback if a
// requested stop has not been acknowledged within stop_timeout_ms.
void tick() {
if (MQTTLifecycle::isStopInProgress(_state) &&
elapsedMs(_ops.nowMs(), _stop_request_ms) >= _stop_timeout_ms) {
dispatch(Event::StopTimedOut);
}
}
private:
bool dispatch(Event e) {
const Result r = apply(_state, e);
if (!r.accepted) return false;
_state = r.next;
if (e == Event::StartRequested) {
_stop_timed_out = false; // a fresh start clears the dirty-stop latch
} else if (e == Event::StopRequested) {
_stop_request_ms = _ops.nowMs(); // arm the timeout window
} else if (e == Event::StopTimedOut) {
_stop_timed_out = true;
}
if (r.effects.create_task) _ops.startTask();
if (r.effects.deliver_stop) _ops.deliverStop();
if (r.effects.release_resources) _ops.releaseResources();
if (r.effects.ota_release) _ops.onStopComplete(e == Event::StopAcknowledged);
return true;
}
Ops& _ops;
uint32_t _stop_timeout_ms;
State _state = State::Stopped;
uint32_t _stop_request_ms = 0;
bool _stop_timed_out = false;
};
} // namespace MQTTLifecycle
+38 -137
View File
@@ -2,6 +2,7 @@
#ifdef WITH_MQTT_BRIDGE
#include "MQTTPayloadBuilder.h"
#include <ArduinoJson.h>
#include <cstring>
#include <math.h>
@@ -59,66 +60,11 @@ int MQTTMessageBuilder::buildStatusMessage(
int packets_received,
const char* repeat
) {
// doc is provided by the caller (heap-allocated DynamicJsonDocument in MQTTBridge),
// keeping this 768-byte scratch space off the MQTT task stack.
doc.clear();
JsonObject root = doc.to<JsonObject>();
root["status"] = status;
root["timestamp"] = timestamp;
root["origin"] = origin;
root["origin_id"] = origin_id;
root["model"] = model;
root["firmware_version"] = firmware_version;
root["radio"] = radio;
root["client_version"] = client_version;
if (repeat != nullptr) {
root["repeat"] = repeat;
}
// Add stats object if any stats are provided
if (battery_mv >= 0 || uptime_secs >= 0 || errors >= 0 || queue_len >= 0 ||
noise_floor > -999 || tx_air_secs >= 0 || rx_air_secs >= 0 || recv_errors >= 0 ||
internal_heap >= 0 || packets_sent >= 0 || packets_received >= 0) {
JsonObject stats = root.createNestedObject("stats");
if (battery_mv >= 0) {
stats["battery_mv"] = battery_mv;
}
if (uptime_secs >= 0) {
stats["uptime_secs"] = uptime_secs;
}
if (packets_sent >= 0) {
stats["packets_sent"] = packets_sent;
}
if (packets_received >= 0) {
stats["packets_received"] = packets_received;
}
if (errors >= 0) {
stats["errors"] = errors;
}
if (queue_len >= 0) {
stats["queue_len"] = queue_len;
}
if (noise_floor > -999) {
stats["noise_floor"] = noise_floor;
}
if (tx_air_secs >= 0) {
stats["tx_air_secs"] = tx_air_secs;
}
if (rx_air_secs >= 0) {
stats["rx_air_secs"] = rx_air_secs;
}
if (recv_errors >= 0) {
stats["recv_errors"] = recv_errors;
}
if (internal_heap >= 0) {
stats["internal_heap"] = internal_heap;
}
}
size_t len = serializeJson(root, buffer, buffer_size);
return (len > 0 && len < buffer_size) ? len : 0;
return MQTTPayloadBuilder::buildStatusMessage(
doc, origin, origin_id, model, firmware_version, radio, client_version,
status, timestamp, buffer, buffer_size, battery_mv, uptime_secs, errors,
queue_len, noise_floor, tx_air_secs, rx_air_secs, recv_errors, internal_heap,
packets_sent, packets_received, repeat);
}
int MQTTMessageBuilder::buildPacketMessage(
@@ -144,71 +90,10 @@ int MQTTMessageBuilder::buildPacketMessage(
char* buffer,
size_t buffer_size
) {
// doc is provided by the caller (heap-allocated DynamicJsonDocument in MQTTBridge),
// keeping this 2048-byte scratch space off the MQTT task stack.
doc.clear();
JsonObject root = doc.to<JsonObject>();
// Format numeric values as strings to avoid String object allocations
char len_str[16];
char packet_type_str[16];
char payload_len_str[16];
char snr_str[16];
char rssi_str[16];
char score_str[16];
snprintf(len_str, sizeof(len_str), "%d", len);
snprintf(packet_type_str, sizeof(packet_type_str), "%d", packet_type);
snprintf(payload_len_str, sizeof(payload_len_str), "%d", payload_len);
snprintf(snr_str, sizeof(snr_str), "%.1f", snr);
snprintf(rssi_str, sizeof(rssi_str), "%d", rssi);
root["timestamp"] = timestamp;
root["hash"] = hash;
root["origin"] = origin;
root["type"] = "PACKET";
root["direction"] = direction;
root["time"] = time;
root["date"] = date;
root["len"] = len_str;
root["packet_type"] = packet_type_str;
root["route"] = route;
root["payload_len"] = payload_len_str;
root["raw"] = raw;
root["origin_id"] = origin_id;
// SNR and RSSI are only meaningful for RX packets (received from radio)
if (strcmp(direction, "rx") == 0) {
root["SNR"] = snr_str;
root["RSSI"] = rssi_str;
// Firmware's rebroadcast "score" for this RX packet, scaled x1000 to match the
// integer form printed in the serial RX log (see Dispatcher::checkRecv()).
if (!isnan(score)) {
snprintf(score_str, sizeof(score_str), "%d", (int)(score * 1000));
root["score"] = score_str;
}
}
// Routing path as an array of lowercase hex hop tokens, one element per hop
// (e.g. ["aa","bb","cc"], or ["aaaa","bbbb"] for multi-byte hashes). This matches
// meshcore-packet-capture's _split_path_hops() representation.
if (path_bytes && path_hop_count > 0 && path_hash_size > 0) {
JsonArray path_arr = root.createNestedArray("path");
char hop_hex[2 * 4 + 1]; // hop hash is 1-4 bytes -> up to 8 hex chars + null
for (int i = 0; i < path_hop_count; i++) {
size_t pos = 0;
for (int b = 0; b < path_hash_size && b < 4; b++) {
size_t idx = (size_t)i * path_hash_size + b;
if (idx >= MAX_PATH_SIZE) break;
snprintf(hop_hex + pos, 3, "%02x", path_bytes[idx]);
pos += 2;
}
hop_hex[pos] = '\0';
path_arr.add(hop_hex); // char[] (non-const) -> ArduinoJson copies the string
}
}
size_t json_len = serializeJson(root, buffer, buffer_size);
return (json_len > 0 && json_len < buffer_size) ? json_len : 0;
return MQTTPayloadBuilder::buildPacketMessage(
doc, origin, origin_id, timestamp, direction, time, date, len, packet_type,
route, payload_len, raw, snr, rssi, score, hash, path_bytes, path_hop_count,
path_hash_size, MAX_PATH_SIZE, buffer, buffer_size);
}
int MQTTMessageBuilder::buildRawMessage(
@@ -219,18 +104,24 @@ int MQTTMessageBuilder::buildRawMessage(
char* buffer,
size_t buffer_size
) {
// Use StaticJsonDocument to avoid heap fragmentation (fixed-size stack allocation)
StaticJsonDocument<512> doc;
JsonObject root = doc.to<JsonObject>();
root["origin"] = origin;
root["origin_id"] = origin_id;
root["timestamp"] = timestamp;
root["type"] = "RAW";
root["data"] = raw;
size_t len = serializeJson(root, buffer, buffer_size);
return (len > 0 && len < buffer_size) ? len : 0;
return MQTTPayloadBuilder::buildRawMessage(
origin, origin_id, timestamp, raw, buffer, buffer_size);
}
int MQTTMessageBuilder::buildNeighborsMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const NeighborsMessageEntry* neighbors,
int neighbor_count,
char* buffer,
size_t buffer_size
) {
return MQTTPayloadBuilder::buildNeighborsMessage(
doc, origin, origin_id, timestamp, self_scopes, neighbors, neighbor_count,
buffer, buffer_size);
}
int MQTTMessageBuilder::buildPacketJSON(
@@ -421,6 +312,11 @@ const char* MQTTMessageBuilder::getRouteTypeString(int route_type) {
}
void MQTTMessageBuilder::bytesToHex(const uint8_t* data, size_t len, char* hex, size_t hex_size) {
if (hex == nullptr || hex_size == 0) return;
// Guarantee a valid (empty) string even if we bail out below, so a caller's
// uninitialized stack buffer is never serialized into the JSON raw/hash fields
// when the buffer is too small (A6).
hex[0] = '\0';
if (hex_size < len * 2 + 1) return;
// Nibble lookup instead of a per-byte snprintf("%02X"): same uppercase hex
@@ -434,6 +330,11 @@ void MQTTMessageBuilder::bytesToHex(const uint8_t* data, size_t len, char* hex,
}
void MQTTMessageBuilder::packetToHex(mesh::Packet* packet, char* hex, size_t hex_size) {
if (hex == nullptr || hex_size == 0) return;
// Empty string on any early-out below (serialization returned nothing, or the
// hex buffer is too small) so an uninitialized raw_hex[] never reaches the
// published JSON (A6).
hex[0] = '\0';
// Serialize full on-air/wire format using Packet::writeTo()
// This includes header, transport codes (if present), path_len, path, and payload
uint8_t raw_buf[512];
+17
View File
@@ -4,6 +4,7 @@
#include "MeshCore.h"
#include <ArduinoJson.h>
#include "MQTTPayloadBuilder.h"
#include <Mesh.h>
#include <Timezone.h>
@@ -155,6 +156,22 @@ public:
size_t buffer_size
);
// Neighbors table entry + JSON builder. The layout logic lives in the pure,
// host-tested MQTTPayloadBuilder; this is the firmware-facing alias/delegate,
// matching the status/packet/raw builders.
using NeighborsMessageEntry = MQTTPayloadBuilder::NeighborsMessageEntry;
static int buildNeighborsMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const NeighborsMessageEntry* neighbors,
int neighbor_count,
char* buffer,
size_t buffer_size
);
/**
* Convert packet to JSON message
*
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <stddef.h>
#include <string.h>
// Pure, dependency-free validators for the observer's CLI/web configuration
// inputs. Factored out of CommonCLI_Observer.cpp so the exact logic the setters
// enforce can be unit-tested on the host (see test/test_observer_validation)
// rather than only through the full CLI object.
// IATA region code: exactly three ASCII alphanumerics. The value is placed
// directly into MQTT topic paths (meshcore/{iata}/...), so anything else (wrong
// length, spaces, topic separators) is rejected. Case is preserved here; the
// setter uppercases after validation.
static inline bool mqttIataValid(const char* s) {
if (!s || strlen(s) != 3) return false;
for (int i = 0; i < 3; i++) {
char c = s[i];
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))) {
return false;
}
}
return true;
}
// Owner public key: exactly 64 hex characters (a 32-byte Ed25519 key), any case.
static inline bool mqttOwnerKeyValid(const char* s) {
if (!s || strlen(s) != 64) return false;
for (int i = 0; i < 64; i++) {
char c = s[i];
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) {
return false;
}
}
return true;
}
// NTP hostname: non-empty, <= 63 chars, made of letters/digits/'.'/'-', with no
// leading or trailing dot. ("none" is handled as a clear by the caller.)
static inline bool mqttNtpHostnameValid(const char* host) {
if (!host || host[0] == '\0') return false;
size_t len = strlen(host);
if (len > 63) return false;
if (host[0] == '.' || host[len - 1] == '.') return false;
for (size_t i = 0; i < len; i++) {
char c = host[i];
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '.' || c == '-')) {
return false;
}
}
return true;
}
// A value fits its fixed destination buffer, which must hold the string plus a
// NUL terminator (so the usable length is bufsize - 1). Used to reject an
// over-long submission up front instead of silently truncating it.
static inline bool mqttValueFits(const char* s, size_t bufsize) {
return s != NULL && bufsize > 0 && strlen(s) < bufsize;
}
+116
View File
@@ -0,0 +1,116 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
// Pure queue/backpressure policy shared by the FreeRTOS and circular-buffer
// MQTT packet queues. Keeping the timing and retry decisions here makes the
// production behavior deterministic under host tests without mocking either
// queue implementation or the MQTT client.
namespace MQTTPacketQueuePolicy {
static const uint32_t kDisconnectedStaleMs = 300000UL;
static const size_t kBacklogThreshold = 5;
static const uint8_t kGentleDrainCount = 1;
static const uint8_t kBurstDrainCount = 5;
static const uint32_t kGentleDrainBudgetMs = 30UL;
static const uint32_t kBurstDrainBudgetMs = 100UL;
static const uint8_t kMaxQos0RetryAttempts = 3;
static const uint32_t kRetryDelayBaseMs = 300UL;
static const uint32_t kRetryDelayJitterMs = 200UL;
// Unsigned subtraction is the standard millis() idiom and remains correct
// across one 32-bit counter rollover.
static inline uint32_t elapsedMs(uint32_t now, uint32_t then) {
return now - then;
}
enum class EnqueueAction : uint8_t {
Enqueue,
EvictOldestThenEnqueue,
Reject
};
static inline EnqueueAction enqueueAction(size_t queue_count, size_t capacity) {
if (capacity == 0) return EnqueueAction::Reject;
return queue_count >= capacity
? EnqueueAction::EvictOldestThenEnqueue
: EnqueueAction::Enqueue;
}
// disconnected_since == 0 means tracking has not started. The bridge records
// the first disconnected observation and asks this helper on later cycles.
static inline bool shouldFlushDisconnected(uint32_t now,
uint32_t disconnected_since,
uint32_t stale_ms = kDisconnectedStaleMs) {
return disconnected_since != 0 && elapsedMs(now, disconnected_since) >= stale_ms;
}
struct DrainBudget {
uint8_t max_packets;
uint32_t max_time_ms;
};
static inline DrainBudget drainBudget(size_t queue_count) {
if (queue_count > kBacklogThreshold) {
return {kBurstDrainCount, kBurstDrainBudgetMs};
}
return {kGentleDrainCount, kGentleDrainBudgetMs};
}
static inline bool drainTimeAvailable(uint32_t now, uint32_t started_at,
uint32_t budget_ms) {
// Preserve the bridge's inclusive boundary: work may begin at exactly the
// configured limit, but not one millisecond later.
return elapsedMs(now, started_at) <= budget_ms;
}
// retry_attempts distinguishes an unscheduled packet from a scheduled retry
// whose deadline wrapped to exactly zero. Deadlines are always less than
// 500 ms away, so the half-range comparison is unambiguous.
static inline bool retryReady(uint32_t now, uint32_t next_retry_ms,
uint8_t retry_attempts) {
if (retry_attempts == 0) return true;
return elapsedMs(now, next_retry_ms) < 0x80000000UL;
}
enum class RetryAction : uint8_t {
Complete,
Schedule,
Drop
};
struct RetryDecision {
RetryAction action;
uint8_t retry_attempts;
uint32_t delay_ms;
uint32_t next_retry_ms;
};
// A queued packet counts as delivered if EITHER its structured-packet publish
// or its raw-frame publish reached at least one slot. Partial success (one
// succeeds while the other fails or was not attempted) is still success -- the
// packet completes and is not retried. This is the (packet, raw) outcome pairing
// fed to retryDecision(); naming it keeps the "partial publish = done" contract
// explicit and host-tested rather than inline in the bridge's queue drain.
static inline bool queuedPacketPublished(bool packet_published,
bool raw_published) {
return packet_published || raw_published;
}
static inline RetryDecision retryDecision(bool any_published,
uint8_t retry_attempts,
uint32_t now) {
if (any_published) {
return {RetryAction::Complete, retry_attempts, 0, 0};
}
if (retry_attempts >= kMaxQos0RetryAttempts) {
return {RetryAction::Drop, retry_attempts, 0, 0};
}
const uint32_t delay = kRetryDelayBaseMs + (now % kRetryDelayJitterMs);
return {RetryAction::Schedule, static_cast<uint8_t>(retry_attempts + 1),
delay, now + delay};
}
} // namespace MQTTPacketQueuePolicy
+231
View File
@@ -0,0 +1,231 @@
#include "MQTTPayloadBuilder.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
namespace {
static int serializeComplete(JsonObject root, char* buffer, size_t buffer_size) {
if (!buffer || buffer_size == 0) return 0;
size_t written = serializeJson(root, buffer, buffer_size);
// Preserve MQTTMessageBuilder's existing success criterion while clearing
// ArduinoJson's truncated prefix on failure. Callers publish only a positive
// return value, and now a failed buffer cannot be mistaken for complete JSON.
if (written == 0 || written >= buffer_size) {
buffer[0] = '\0';
return 0;
}
return static_cast<int>(written);
}
} // namespace
int MQTTPayloadBuilder::buildStatusMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* model,
const char* firmware_version,
const char* radio,
const char* client_version,
const char* status,
const char* timestamp,
char* buffer,
size_t buffer_size,
int battery_mv,
int uptime_secs,
int errors,
int queue_len,
int noise_floor,
int tx_air_secs,
int rx_air_secs,
int recv_errors,
int internal_heap,
int packets_sent,
int packets_received,
const char* repeat
) {
doc.clear();
JsonObject root = doc.to<JsonObject>();
root["status"] = status;
root["timestamp"] = timestamp;
root["origin"] = origin;
root["origin_id"] = origin_id;
root["model"] = model;
root["firmware_version"] = firmware_version;
root["radio"] = radio;
root["client_version"] = client_version;
if (repeat != nullptr) {
root["repeat"] = repeat;
}
if (battery_mv >= 0 || uptime_secs >= 0 || errors >= 0 || queue_len >= 0 ||
noise_floor > -999 || tx_air_secs >= 0 || rx_air_secs >= 0 || recv_errors >= 0 ||
internal_heap >= 0 || packets_sent >= 0 || packets_received >= 0) {
JsonObject stats = root["stats"].to<JsonObject>();
if (battery_mv >= 0) stats["battery_mv"] = battery_mv;
if (uptime_secs >= 0) stats["uptime_secs"] = uptime_secs;
if (packets_sent >= 0) stats["packets_sent"] = packets_sent;
if (packets_received >= 0) stats["packets_received"] = packets_received;
if (errors >= 0) stats["errors"] = errors;
if (queue_len >= 0) stats["queue_len"] = queue_len;
if (noise_floor > -999) stats["noise_floor"] = noise_floor;
if (tx_air_secs >= 0) stats["tx_air_secs"] = tx_air_secs;
if (rx_air_secs >= 0) stats["rx_air_secs"] = rx_air_secs;
if (recv_errors >= 0) stats["recv_errors"] = recv_errors;
if (internal_heap >= 0) stats["internal_heap"] = internal_heap;
}
return serializeComplete(root, buffer, buffer_size);
}
int MQTTPayloadBuilder::buildPacketMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* direction,
const char* time,
const char* date,
int len,
int packet_type,
const char* route,
int payload_len,
const char* raw,
float snr,
int rssi,
float score,
const char* hash,
const uint8_t* path_bytes,
int path_hop_count,
int path_hash_size,
size_t max_path_bytes,
char* buffer,
size_t buffer_size
) {
doc.clear();
JsonObject root = doc.to<JsonObject>();
char len_str[16];
char packet_type_str[16];
char payload_len_str[16];
char snr_str[16];
char rssi_str[16];
char score_str[16];
snprintf(len_str, sizeof(len_str), "%d", len);
snprintf(packet_type_str, sizeof(packet_type_str), "%d", packet_type);
snprintf(payload_len_str, sizeof(payload_len_str), "%d", payload_len);
snprintf(snr_str, sizeof(snr_str), "%.1f", snr);
snprintf(rssi_str, sizeof(rssi_str), "%d", rssi);
root["timestamp"] = timestamp;
root["hash"] = hash;
root["origin"] = origin;
root["type"] = "PACKET";
root["direction"] = direction;
root["time"] = time;
root["date"] = date;
root["len"] = len_str;
root["packet_type"] = packet_type_str;
root["route"] = route;
root["payload_len"] = payload_len_str;
root["raw"] = raw;
root["origin_id"] = origin_id;
if (direction && strcmp(direction, "rx") == 0) {
root["SNR"] = snr_str;
root["RSSI"] = rssi_str;
if (!isnan(score)) {
snprintf(score_str, sizeof(score_str), "%d", static_cast<int>(score * 1000));
root["score"] = score_str;
}
}
if (path_bytes && path_hop_count > 0 && path_hash_size > 0) {
JsonArray path_arr = root["path"].to<JsonArray>();
char hop_hex[2 * 4 + 1];
for (int i = 0; i < path_hop_count; i++) {
size_t pos = 0;
for (int b = 0; b < path_hash_size && b < 4; b++) {
size_t idx = static_cast<size_t>(i) * path_hash_size + b;
if (idx >= max_path_bytes) break;
snprintf(hop_hex + pos, 3, "%02x", path_bytes[idx]);
pos += 2;
}
hop_hex[pos] = '\0';
path_arr.add(hop_hex);
}
}
return serializeComplete(root, buffer, buffer_size);
}
int MQTTPayloadBuilder::buildRawMessage(
const char* origin,
const char* origin_id,
const char* timestamp,
const char* raw,
char* buffer,
size_t buffer_size
) {
JsonDocument doc;
JsonObject root = doc.to<JsonObject>();
root["origin"] = origin;
root["origin_id"] = origin_id;
root["timestamp"] = timestamp;
root["type"] = "RAW";
root["data"] = raw;
return serializeComplete(root, buffer, buffer_size);
}
int MQTTPayloadBuilder::buildNeighborsMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const NeighborsMessageEntry* neighbors,
int neighbor_count,
char* buffer,
size_t buffer_size
) {
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;
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;
// 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) {
arr.remove(arr.size() - 1);
break;
}
}
return serializeComplete(root, buffer, buffer_size);
}
+94
View File
@@ -0,0 +1,94 @@
#pragma once
#include <ArduinoJson.h>
#include <stddef.h>
#include <stdint.h>
// Mesh-independent JSON serialization core for MQTT publication payloads.
// MQTTMessageBuilder keeps the firmware-facing API and delegates these three
// deterministic contracts here so they can be exercised by native tests.
class MQTTPayloadBuilder {
public:
static int buildStatusMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* model,
const char* firmware_version,
const char* radio,
const char* client_version,
const char* status,
const char* timestamp,
char* buffer,
size_t buffer_size,
int battery_mv = -1,
int uptime_secs = -1,
int errors = -1,
int queue_len = -1,
int noise_floor = -999,
int tx_air_secs = -1,
int rx_air_secs = -1,
int recv_errors = -1,
int internal_heap = -1,
int packets_sent = -1,
int packets_received = -1,
const char* repeat = nullptr
);
static int buildPacketMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* direction,
const char* time,
const char* date,
int len,
int packet_type,
const char* route,
int payload_len,
const char* raw,
float snr,
int rssi,
float score,
const char* hash,
const uint8_t* path_bytes,
int path_hop_count,
int path_hash_size,
size_t max_path_bytes,
char* buffer,
size_t buffer_size
);
static int buildRawMessage(
const char* origin,
const char* origin_id,
const char* timestamp,
const char* raw,
char* buffer,
size_t buffer_size
);
struct NeighborsMessageEntry {
const char* pubkey_hex;
float snr;
uint32_t heard_secs_ago;
const char* scopes;
const char* status;
};
// Build neighbors-table JSON for the meshcore/{iata}/{device}/neighbors topic.
// Callers order entries most- to least-useful; document growth is bounded to
// buffer_size and the remaining tail is dropped once the next entry won't fit.
static int buildNeighborsMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const NeighborsMessageEntry* neighbors,
int neighbor_count,
char* buffer,
size_t buffer_size
);
};
+1 -61
View File
@@ -2,66 +2,6 @@
#ifdef WITH_MQTT_BRIDGE
#include <stdint.h>
#include <helpers/MQTTPresets.h>
// MQTT preferences are kept separate from role-specific NodePrefs. Companion
// and infrastructure roles intentionally use different NodePrefs layouts, but
// they can safely share this MQTT configuration structure.
struct MQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
uint8_t wifi_power_save;
char timezone_string[32];
int8_t timezone_offset;
char mqtt_slot_preset[MAX_MQTT_SLOTS][24];
char mqtt_slot_host[MAX_MQTT_SLOTS][64];
uint16_t mqtt_slot_port[MAX_MQTT_SLOTS];
char mqtt_slot_username[MAX_MQTT_SLOTS][32];
char mqtt_slot_password[MAX_MQTT_SLOTS][64];
char mqtt_owner_public_key[65];
char mqtt_email[64];
char mqtt_slot_token[MAX_MQTT_SLOTS][48];
char mqtt_slot_topic[MAX_MQTT_SLOTS][96];
char mqtt_slot_audience[MAX_MQTT_SLOTS][64];
uint8_t mqtt_rx_enabled;
char mqtt_ntp_server[64];
uint8_t snmp_enabled;
char snmp_community[24];
uint8_t radio_watchdog_minutes;
uint8_t alert_enabled;
char alert_psk_hex[33];
uint16_t alert_wifi_minutes;
uint16_t alert_mqtt_minutes;
uint16_t alert_min_interval_min;
char alert_hashtag[24];
char alert_region[31];
};
// /mqtt_prefs is written with an 8-byte header so the format is
// self-describing. This is also used by the companion NVS wrapper to reject
// incompatible payloads cleanly.
static const uint8_t MQTT_PREFS_MAGIC[4] = {0xF5, 'M', 'Q', 'P'};
static const uint16_t MQTT_PREFS_VERSION = 1;
struct MQTTPrefsHeader {
uint8_t magic[4];
uint16_t version;
uint16_t payload_len;
};
#include <helpers/MQTTPrefsStorage.h>
#endif // WITH_MQTT_BRIDGE
+127
View File
@@ -0,0 +1,127 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
// Transactional writer for /mqtt_prefs. The Store interface is intentionally
// narrow so host tests can exercise every failure boundary without an Arduino
// filesystem: begin(), write(), finish(), commit(), and abort(). The caller
// supplies header and payload separately, avoiding a second full-size buffer.
namespace MQTTPrefsAtomicStore {
enum class Result : uint8_t {
Committed,
BeginFailed,
HeaderWriteFailed,
PayloadWriteFailed,
FinishFailed,
CommitFailed,
};
inline bool committed(Result result) {
return result == Result::Committed;
}
// Generic streaming transaction for structured images such as /com_prefs.
// ImageWriter writes its fields directly to Store and returns false on any
// short write, so no contiguous staging allocation is required.
enum class ImageResult : uint8_t {
Committed,
BeginFailed,
WriteFailed,
FinishFailed,
CommitFailed,
};
inline bool imageCommitted(ImageResult result) {
return result == ImageResult::Committed;
}
template <typename Store, typename ImageWriter>
inline ImageResult writeImage(Store& store, ImageWriter write_image) {
if (!store.begin()) {
store.abort();
return ImageResult::BeginFailed;
}
if (!write_image(store)) {
store.abort();
return ImageResult::WriteFailed;
}
if (!store.finish()) {
store.abort();
return ImageResult::FinishFailed;
}
if (!store.commit()) {
store.abort();
return ImageResult::CommitFailed;
}
return ImageResult::Committed;
}
// Coordinates a two-file legacy upgrade. /com_prefs must not be compacted
// until the observer tail it carries has been published into /mqtt_prefs.
// Keeping this state in a tiny pure helper lets host tests cover power-cut
// boundaries without an Arduino filesystem.
class LegacyUpgradeGate {
public:
explicit LegacyUpgradeGate(bool com_prefs_rewrite_pending)
: _com_prefs_rewrite_pending(com_prefs_rewrite_pending) {}
void requireMqttRewrite() { _mqtt_rewrite_pending = true; }
void recordMqttSave(bool did_commit) {
if (did_commit) {
_mqtt_rewrite_pending = false;
_mqtt_source_held = false;
} else {
_mqtt_source_held = true;
}
}
void holdMqttSource() { _mqtt_source_held = true; }
bool mqttRewritePending() const { return _mqtt_rewrite_pending; }
bool blocksComPrefsRewrite() const {
return _com_prefs_rewrite_pending && (_mqtt_rewrite_pending || _mqtt_source_held);
}
bool mayRewriteComPrefs() const {
return _com_prefs_rewrite_pending && !blocksComPrefsRewrite();
}
void recordComPrefsRewrite() {
if (mayRewriteComPrefs()) _com_prefs_rewrite_pending = false;
}
private:
bool _com_prefs_rewrite_pending;
bool _mqtt_rewrite_pending = false;
bool _mqtt_source_held = false;
};
template <typename Store>
inline Result write(Store& store, const uint8_t* header, size_t header_size,
const uint8_t* payload, size_t payload_size) {
if (!store.begin()) {
store.abort();
return Result::BeginFailed;
}
if (store.write(header, header_size) != header_size) {
store.abort();
return Result::HeaderWriteFailed;
}
if (store.write(payload, payload_size) != payload_size) {
store.abort();
return Result::PayloadWriteFailed;
}
if (!store.finish()) {
store.abort();
return Result::FinishFailed;
}
if (!store.commit()) {
store.abort();
return Result::CommitFailed;
}
return Result::Committed;
}
} // namespace MQTTPrefsAtomicStore
+446
View File
@@ -0,0 +1,446 @@
#pragma once
#include <string.h>
#include "MQTTPrefsStorage.h"
#ifdef WITH_MQTT_BRIDGE
// Pure /mqtt_prefs format classification and field-copy migration. Production
// reads directly into the selected layout; this header never requires a second
// large staging buffer.
namespace MQTTPrefsCodec {
enum class Source : uint8_t {
Defaults,
Current,
LegacyPreSlot,
LegacyThreeSlotBase,
LegacyThreeSlot,
LegacySixSlotBase,
LegacySixSlotAudience,
LegacySixSlotAudienceRx,
LegacySixSlot,
UnsupportedVersion,
Corrupt,
};
struct DecodePlan {
Source source;
bool rewrite_legacy;
bool preserve_file;
// False means the decoded payload stops before snmp_enabled, so production
// may apply a captured observer tail from legacy /com_prefs.
bool observer_fields_present;
size_t payload_len;
};
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 kV1BaselinePayloadSize = MQTT_PREFS_V1_FULL_PAYLOAD_SIZE;
static const size_t kEncodedSize = sizeof(MQTTPrefsHeader) + kV1BaselinePayloadSize;
inline MQTTPrefsHeader makeHeader() {
MQTTPrefsHeader header;
memcpy(header.magic, MQTT_PREFS_MAGIC, sizeof(header.magic));
header.version = MQTT_PREFS_VERSION;
header.payload_len = static_cast<uint16_t>(kV1BaselinePayloadSize);
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();
memcpy(output, &header, sizeof(header));
memcpy(output + sizeof(header), &prefs, sizeof(prefs));
return kEncodedSize;
}
inline bool isMagicPrefix(const uint8_t* input, size_t available) {
if (input == nullptr || available == 0) return false;
const size_t compare_len = available < sizeof(MQTT_PREFS_MAGIC)
? available : sizeof(MQTT_PREFS_MAGIC);
return memcmp(input, MQTT_PREFS_MAGIC, compare_len) == 0;
}
inline DecodePlan corruptPlan() {
return {Source::Corrupt, false, true, false, 0};
}
// Classify from the first eight bytes and the filesystem-reported file size.
// Headerless layouts are an explicit, audited whitelist. Call
// isPlausibleLegacy() after reading the selected layout and before rewriting:
// size alone cannot distinguish a valid legacy payload from arbitrary bytes.
inline DecodePlan classify(const uint8_t* prefix, size_t prefix_read, size_t file_size) {
if (file_size == 0) return corruptPlan();
if (prefix == nullptr || prefix_read == 0) return corruptPlan();
const size_t expected_prefix = file_size < sizeof(MQTTPrefsHeader)
? file_size : sizeof(MQTTPrefsHeader);
if (prefix_read < expected_prefix) return corruptPlan();
if (file_size < sizeof(MQTTPrefsHeader) && isMagicPrefix(prefix, prefix_read)) {
return corruptPlan();
}
if (file_size >= sizeof(MQTTPrefsHeader)) {
MQTTPrefsHeader header;
memcpy(&header, prefix, sizeof(header));
if (memcmp(header.magic, MQTT_PREFS_MAGIC, sizeof(header.magic)) == 0) {
if (header.version != MQTT_PREFS_VERSION) {
return {Source::UnsupportedVersion, false, true, false, 0};
}
const size_t payload_available = file_size - sizeof(header);
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.
if (header.payload_len > kV1BaselinePayloadSize) {
return {Source::UnsupportedVersion, false, true, false, 0};
}
if (header.payload_len == kV1BaselinePayloadSize) {
return {Source::Current, false, false, true, kV1BaselinePayloadSize};
}
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
// missing, so it loads and keeps its defaults (off / 24h).
return {Source::Current, false, false, true, kV1PreNeighborsPayloadSize};
}
if (header.payload_len == kV1PreObserverPayloadSize) {
return {Source::Current, false, false, false, kV1PreObserverPayloadSize};
}
return corruptPlan();
}
}
switch (file_size) {
case sizeof(OldMQTTPrefs):
return {Source::LegacyPreSlot, true, false, false, file_size};
case sizeof(ThreeSlotBaseMQTTPrefs):
return {Source::LegacyThreeSlotBase, true, false, false, file_size};
case sizeof(ThreeSlotMQTTPrefs):
return {Source::LegacyThreeSlot, true, false, false, file_size};
case LEGACY6_BASE_SIZE:
return {Source::LegacySixSlotBase, true, false, false, file_size};
case LEGACY6_AUDIENCE_SIZE:
return {Source::LegacySixSlotAudience, true, false, false, file_size};
case LEGACY6_AUDIENCE_RX_SIZE:
return {Source::LegacySixSlotAudienceRx, true, false, false, file_size};
case sizeof(Legacy6SlotMQTTPrefs):
return {Source::LegacySixSlot, true, false, false, file_size};
default:
return corruptPlan();
}
}
inline bool looksLikePreWifiPower(const uint8_t* input, size_t size) {
if (input == nullptr || size != sizeof(OldMQTTPrefs)) return false;
// At byte 144 the newer layout has wifi_power_save (0..2); the older
// layout has timezone_string[0]. A non-empty timezone is unambiguous.
if (input[144] > 2) return true;
// With an empty timezone, byte 177 is the older mqtt_server[0] but the
// newer timezone_offset. If it cannot be an offset, it is also unambiguous.
const int8_t newer_offset = static_cast<int8_t>(input[177]);
// Byte 176 is the older timezone_offset but the final byte of the newer
// timezone string (always NUL for values saved through the CLI).
return input[144] == 0 &&
(input[176] != 0 || newer_offset < -12 || newer_offset > 14);
}
// Headerless files have no checksum or magic, so their integrity cannot be
// proven. These checks intentionally reject obvious random data (unterminated
// strings and impossible flag/range values) without demanding application-level
// values that a real but sparsely configured device may not have set.
inline bool hasTerminatedText(const uint8_t* input, size_t size, size_t offset, size_t field_size) {
if (input == nullptr || offset > size || field_size > size - offset) return false;
for (size_t i = 0; i < field_size; ++i) {
if (input[offset + i] == '\0') return true;
}
return false;
}
inline bool hasPlausibleCommonFields(const uint8_t* input, size_t size, bool pre_wifi_power) {
if (input == nullptr || size < sizeof(OldMQTTPrefs)) return false;
const size_t timezone_offset = pre_wifi_power
? offsetof(PreWifiPowerOldMQTTPrefs, timezone_string)
: offsetof(OldMQTTPrefs, timezone_string);
const size_t utc_offset = pre_wifi_power
? offsetof(PreWifiPowerOldMQTTPrefs, timezone_offset)
: offsetof(OldMQTTPrefs, timezone_offset);
const size_t timezone_size = 32;
const int8_t timezone_hours = static_cast<int8_t>(input[utc_offset]);
return input[offsetof(OldMQTTPrefs, mqtt_status_enabled)] <= 1 &&
input[offsetof(OldMQTTPrefs, mqtt_packets_enabled)] <= 1 &&
input[offsetof(OldMQTTPrefs, mqtt_raw_enabled)] <= 1 &&
input[offsetof(OldMQTTPrefs, mqtt_tx_enabled)] <= 2 &&
(pre_wifi_power || input[offsetof(OldMQTTPrefs, wifi_power_save)] <= 2) &&
timezone_hours >= -12 && timezone_hours <= 14 &&
hasTerminatedText(input, size, offsetof(OldMQTTPrefs, mqtt_origin),
32) &&
hasTerminatedText(input, size, offsetof(OldMQTTPrefs, mqtt_iata),
8) &&
hasTerminatedText(input, size, offsetof(OldMQTTPrefs, wifi_ssid),
32) &&
hasTerminatedText(input, size, offsetof(OldMQTTPrefs, wifi_password),
64) &&
hasTerminatedText(input, size, timezone_offset, timezone_size);
}
inline bool hasPlausibleSlotText(const uint8_t* input, size_t size, size_t slot_count,
size_t preset_offset, size_t host_offset, size_t username_offset,
size_t password_offset, size_t token_offset, size_t topic_offset,
size_t audience_offset) {
const size_t no_field = static_cast<size_t>(-1);
for (size_t i = 0; i < slot_count; ++i) {
if (!hasTerminatedText(input, size, preset_offset + i * 24, 24) ||
!hasTerminatedText(input, size, host_offset + i * 64, 64) ||
!hasTerminatedText(input, size, username_offset + i * 32, 32) ||
!hasTerminatedText(input, size, password_offset + i * 64, 64) ||
(token_offset != no_field && !hasTerminatedText(input, size, token_offset + i * 48, 48)) ||
(topic_offset != no_field && !hasTerminatedText(input, size, topic_offset + i * 96, 96)) ||
(audience_offset != no_field && !hasTerminatedText(input, size, audience_offset + i * 64, 64))) {
return false;
}
}
return true;
}
inline bool hasPlausibleSharedAuth(const uint8_t* input, size_t size, size_t owner_offset,
size_t email_offset) {
return hasTerminatedText(input, size, owner_offset, 65) &&
hasTerminatedText(input, size, email_offset, 64);
}
inline bool isPlausibleLegacy(Source source, const uint8_t* input, size_t size) {
const size_t no_field = static_cast<size_t>(-1);
switch (source) {
case Source::LegacyPreSlot: {
if (size != sizeof(OldMQTTPrefs)) return false;
const bool pre_wifi_power = looksLikePreWifiPower(input, size);
const size_t server_offset = pre_wifi_power
? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_server)
: offsetof(OldMQTTPrefs, mqtt_server);
const size_t username_offset = pre_wifi_power
? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_username)
: offsetof(OldMQTTPrefs, mqtt_username);
const size_t password_offset = pre_wifi_power
? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_password)
: offsetof(OldMQTTPrefs, mqtt_password);
const size_t us_enabled = pre_wifi_power
? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_analyzer_us_enabled)
: offsetof(OldMQTTPrefs, mqtt_analyzer_us_enabled);
const size_t eu_enabled = pre_wifi_power
? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_analyzer_eu_enabled)
: offsetof(OldMQTTPrefs, mqtt_analyzer_eu_enabled);
return hasPlausibleCommonFields(input, size, pre_wifi_power) &&
input[us_enabled] <= 1 && input[eu_enabled] <= 1 &&
hasTerminatedText(input, size, server_offset, 64) &&
hasTerminatedText(input, size, username_offset, 32) &&
hasTerminatedText(input, size, password_offset, 64) &&
hasTerminatedText(input, size, pre_wifi_power
? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_owner_public_key)
: offsetof(OldMQTTPrefs, mqtt_owner_public_key), 65) &&
hasTerminatedText(input, size, pre_wifi_power
? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_email)
: offsetof(OldMQTTPrefs, mqtt_email), 64);
}
case Source::LegacyThreeSlotBase:
return size == sizeof(ThreeSlotBaseMQTTPrefs) &&
hasPlausibleCommonFields(input, size, false) &&
hasPlausibleSharedAuth(input, size,
offsetof(ThreeSlotBaseMQTTPrefs, mqtt_owner_public_key),
offsetof(ThreeSlotBaseMQTTPrefs, mqtt_email)) &&
hasPlausibleSlotText(input, size, 3,
offsetof(ThreeSlotBaseMQTTPrefs, mqtt_slot_preset),
offsetof(ThreeSlotBaseMQTTPrefs, mqtt_slot_host),
offsetof(ThreeSlotBaseMQTTPrefs, mqtt_slot_username),
offsetof(ThreeSlotBaseMQTTPrefs, mqtt_slot_password),
no_field, no_field, no_field);
case Source::LegacyThreeSlot:
return size == sizeof(ThreeSlotMQTTPrefs) &&
hasPlausibleCommonFields(input, size, false) &&
hasPlausibleSharedAuth(input, size,
offsetof(ThreeSlotMQTTPrefs, mqtt_owner_public_key),
offsetof(ThreeSlotMQTTPrefs, mqtt_email)) &&
hasPlausibleSlotText(input, size, 3,
offsetof(ThreeSlotMQTTPrefs, mqtt_slot_preset),
offsetof(ThreeSlotMQTTPrefs, mqtt_slot_host),
offsetof(ThreeSlotMQTTPrefs, mqtt_slot_username),
offsetof(ThreeSlotMQTTPrefs, mqtt_slot_password),
offsetof(ThreeSlotMQTTPrefs, mqtt_slot_token),
offsetof(ThreeSlotMQTTPrefs, mqtt_slot_topic), no_field);
case Source::LegacySixSlotBase:
case Source::LegacySixSlotAudience:
case Source::LegacySixSlotAudienceRx:
case Source::LegacySixSlot: {
const size_t expected_size = source == Source::LegacySixSlotBase ? LEGACY6_BASE_SIZE
: source == Source::LegacySixSlotAudience ? LEGACY6_AUDIENCE_SIZE
: source == Source::LegacySixSlotAudienceRx ? LEGACY6_AUDIENCE_RX_SIZE
: sizeof(Legacy6SlotMQTTPrefs);
const size_t audience_offset = source == Source::LegacySixSlotBase
? no_field : offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_audience);
const bool has_rx = source == Source::LegacySixSlotAudienceRx ||
source == Source::LegacySixSlot;
const bool has_ntp = source == Source::LegacySixSlot;
return size == expected_size && hasPlausibleCommonFields(input, size, false) &&
hasPlausibleSharedAuth(input, size,
offsetof(Legacy6SlotMQTTPrefs, mqtt_owner_public_key),
offsetof(Legacy6SlotMQTTPrefs, mqtt_email)) &&
(!has_rx || input[offsetof(Legacy6SlotMQTTPrefs, mqtt_rx_enabled)] <= 1) &&
(!has_ntp || hasTerminatedText(input, size,
offsetof(Legacy6SlotMQTTPrefs, mqtt_ntp_server), 64)) &&
hasPlausibleSlotText(input, size, MQTT_PREFS_SLOT_COUNT,
offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_preset),
offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_host),
offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_username),
offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_password),
offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_token),
offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_topic), audience_offset);
}
default:
return false;
}
}
inline void migratePreSlot(const OldMQTTPrefs& old_prefs, MQTTPrefs* prefs) {
memcpy(prefs->mqtt_origin, old_prefs.mqtt_origin, sizeof(prefs->mqtt_origin));
memcpy(prefs->mqtt_iata, old_prefs.mqtt_iata, sizeof(prefs->mqtt_iata));
prefs->mqtt_status_enabled = old_prefs.mqtt_status_enabled;
prefs->mqtt_packets_enabled = old_prefs.mqtt_packets_enabled;
prefs->mqtt_raw_enabled = old_prefs.mqtt_raw_enabled;
prefs->mqtt_tx_enabled = old_prefs.mqtt_tx_enabled;
prefs->mqtt_status_interval = old_prefs.mqtt_status_interval;
memcpy(prefs->wifi_ssid, old_prefs.wifi_ssid, sizeof(prefs->wifi_ssid));
memcpy(prefs->wifi_password, old_prefs.wifi_password, sizeof(prefs->wifi_password));
prefs->wifi_power_save = old_prefs.wifi_power_save;
memcpy(prefs->timezone_string, old_prefs.timezone_string, sizeof(prefs->timezone_string));
prefs->timezone_offset = old_prefs.timezone_offset;
memcpy(prefs->mqtt_owner_public_key, old_prefs.mqtt_owner_public_key,
sizeof(prefs->mqtt_owner_public_key));
memcpy(prefs->mqtt_email, old_prefs.mqtt_email, sizeof(prefs->mqtt_email));
strncpy(prefs->mqtt_slot_preset[0], old_prefs.mqtt_analyzer_us_enabled == 1
? "analyzer-us" : "none", sizeof(prefs->mqtt_slot_preset[0]) - 1);
strncpy(prefs->mqtt_slot_preset[1], old_prefs.mqtt_analyzer_eu_enabled == 1
? "analyzer-eu" : "none", sizeof(prefs->mqtt_slot_preset[1]) - 1);
if (old_prefs.mqtt_server[0] != '\0' && old_prefs.mqtt_port > 0) {
strncpy(prefs->mqtt_slot_preset[2], "custom", sizeof(prefs->mqtt_slot_preset[2]) - 1);
strncpy(prefs->mqtt_slot_host[2], old_prefs.mqtt_server,
sizeof(prefs->mqtt_slot_host[2]) - 1);
prefs->mqtt_slot_port[2] = old_prefs.mqtt_port;
strncpy(prefs->mqtt_slot_username[2], old_prefs.mqtt_username,
sizeof(prefs->mqtt_slot_username[2]) - 1);
strncpy(prefs->mqtt_slot_password[2], old_prefs.mqtt_password,
sizeof(prefs->mqtt_slot_password[2]) - 1);
} else {
strncpy(prefs->mqtt_slot_preset[2], "none", sizeof(prefs->mqtt_slot_preset[2]) - 1);
}
}
inline void migratePreWifiPower(const PreWifiPowerOldMQTTPrefs& old_prefs, MQTTPrefs* prefs) {
OldMQTTPrefs normalized = {};
memcpy(normalized.mqtt_origin, old_prefs.mqtt_origin, sizeof(normalized.mqtt_origin));
memcpy(normalized.mqtt_iata, old_prefs.mqtt_iata, sizeof(normalized.mqtt_iata));
normalized.mqtt_status_enabled = old_prefs.mqtt_status_enabled;
normalized.mqtt_packets_enabled = old_prefs.mqtt_packets_enabled;
normalized.mqtt_raw_enabled = old_prefs.mqtt_raw_enabled;
normalized.mqtt_tx_enabled = old_prefs.mqtt_tx_enabled;
normalized.mqtt_status_interval = old_prefs.mqtt_status_interval;
memcpy(normalized.wifi_ssid, old_prefs.wifi_ssid, sizeof(normalized.wifi_ssid));
memcpy(normalized.wifi_password, old_prefs.wifi_password, sizeof(normalized.wifi_password));
normalized.wifi_power_save = prefs->wifi_power_save; // field did not exist yet
memcpy(normalized.timezone_string, old_prefs.timezone_string, sizeof(normalized.timezone_string));
normalized.timezone_offset = old_prefs.timezone_offset;
memcpy(normalized.mqtt_server, old_prefs.mqtt_server, sizeof(normalized.mqtt_server));
normalized.mqtt_port = old_prefs.mqtt_port;
memcpy(normalized.mqtt_username, old_prefs.mqtt_username, sizeof(normalized.mqtt_username));
memcpy(normalized.mqtt_password, old_prefs.mqtt_password, sizeof(normalized.mqtt_password));
normalized.mqtt_analyzer_us_enabled = old_prefs.mqtt_analyzer_us_enabled;
normalized.mqtt_analyzer_eu_enabled = old_prefs.mqtt_analyzer_eu_enabled;
memcpy(normalized.mqtt_owner_public_key, old_prefs.mqtt_owner_public_key,
sizeof(normalized.mqtt_owner_public_key));
memcpy(normalized.mqtt_email, old_prefs.mqtt_email, sizeof(normalized.mqtt_email));
migratePreSlot(normalized, prefs);
}
template <typename T>
inline void migrateThreeSlotCommon(const T& old_prefs, MQTTPrefs* prefs) {
memcpy(prefs->mqtt_origin, old_prefs.mqtt_origin, sizeof(prefs->mqtt_origin));
memcpy(prefs->mqtt_iata, old_prefs.mqtt_iata, sizeof(prefs->mqtt_iata));
prefs->mqtt_status_enabled = old_prefs.mqtt_status_enabled;
prefs->mqtt_packets_enabled = old_prefs.mqtt_packets_enabled;
prefs->mqtt_raw_enabled = old_prefs.mqtt_raw_enabled;
prefs->mqtt_tx_enabled = old_prefs.mqtt_tx_enabled;
prefs->mqtt_status_interval = old_prefs.mqtt_status_interval;
memcpy(prefs->wifi_ssid, old_prefs.wifi_ssid, sizeof(prefs->wifi_ssid));
memcpy(prefs->wifi_password, old_prefs.wifi_password, sizeof(prefs->wifi_password));
prefs->wifi_power_save = old_prefs.wifi_power_save;
memcpy(prefs->timezone_string, old_prefs.timezone_string, sizeof(prefs->timezone_string));
prefs->timezone_offset = old_prefs.timezone_offset;
for (int i = 0; i < 3; i++) {
memcpy(prefs->mqtt_slot_preset[i], old_prefs.mqtt_slot_preset[i], sizeof(prefs->mqtt_slot_preset[i]));
memcpy(prefs->mqtt_slot_host[i], old_prefs.mqtt_slot_host[i], sizeof(prefs->mqtt_slot_host[i]));
prefs->mqtt_slot_port[i] = old_prefs.mqtt_slot_port[i];
memcpy(prefs->mqtt_slot_username[i], old_prefs.mqtt_slot_username[i], sizeof(prefs->mqtt_slot_username[i]));
memcpy(prefs->mqtt_slot_password[i], old_prefs.mqtt_slot_password[i], sizeof(prefs->mqtt_slot_password[i]));
}
memcpy(prefs->mqtt_owner_public_key, old_prefs.mqtt_owner_public_key, sizeof(prefs->mqtt_owner_public_key));
memcpy(prefs->mqtt_email, old_prefs.mqtt_email, sizeof(prefs->mqtt_email));
}
inline void migrateThreeSlot(const ThreeSlotBaseMQTTPrefs& old_prefs, MQTTPrefs* prefs) {
migrateThreeSlotCommon(old_prefs, prefs);
}
inline void migrateThreeSlot(const ThreeSlotMQTTPrefs& old_prefs, MQTTPrefs* prefs) {
migrateThreeSlotCommon(old_prefs, prefs);
for (int i = 0; i < 3; i++) {
memcpy(prefs->mqtt_slot_token[i], old_prefs.mqtt_slot_token[i], sizeof(prefs->mqtt_slot_token[i]));
memcpy(prefs->mqtt_slot_topic[i], old_prefs.mqtt_slot_topic[i], sizeof(prefs->mqtt_slot_topic[i]));
}
}
inline void migrateLegacySixSlotCommon(const Legacy6SlotMQTTPrefs& old_prefs, MQTTPrefs* prefs) {
memcpy(prefs->mqtt_origin, old_prefs.mqtt_origin, sizeof(prefs->mqtt_origin));
memcpy(prefs->mqtt_iata, old_prefs.mqtt_iata, sizeof(prefs->mqtt_iata));
prefs->mqtt_status_enabled = old_prefs.mqtt_status_enabled;
prefs->mqtt_packets_enabled = old_prefs.mqtt_packets_enabled;
prefs->mqtt_raw_enabled = old_prefs.mqtt_raw_enabled;
prefs->mqtt_tx_enabled = old_prefs.mqtt_tx_enabled;
prefs->mqtt_status_interval = old_prefs.mqtt_status_interval;
memcpy(prefs->wifi_ssid, old_prefs.wifi_ssid, sizeof(prefs->wifi_ssid));
memcpy(prefs->wifi_password, old_prefs.wifi_password, sizeof(prefs->wifi_password));
prefs->wifi_power_save = old_prefs.wifi_power_save;
memcpy(prefs->timezone_string, old_prefs.timezone_string, sizeof(prefs->timezone_string));
prefs->timezone_offset = old_prefs.timezone_offset;
memcpy(prefs->mqtt_slot_preset, old_prefs.mqtt_slot_preset, sizeof(prefs->mqtt_slot_preset));
memcpy(prefs->mqtt_slot_host, old_prefs.mqtt_slot_host, sizeof(prefs->mqtt_slot_host));
memcpy(prefs->mqtt_slot_port, old_prefs.mqtt_slot_port, sizeof(prefs->mqtt_slot_port));
memcpy(prefs->mqtt_slot_username, old_prefs.mqtt_slot_username, sizeof(prefs->mqtt_slot_username));
memcpy(prefs->mqtt_slot_password, old_prefs.mqtt_slot_password, sizeof(prefs->mqtt_slot_password));
memcpy(prefs->mqtt_owner_public_key, old_prefs.mqtt_owner_public_key, sizeof(prefs->mqtt_owner_public_key));
memcpy(prefs->mqtt_email, old_prefs.mqtt_email, sizeof(prefs->mqtt_email));
memcpy(prefs->mqtt_slot_token, old_prefs.mqtt_slot_token, sizeof(prefs->mqtt_slot_token));
memcpy(prefs->mqtt_slot_topic, old_prefs.mqtt_slot_topic, sizeof(prefs->mqtt_slot_topic));
}
inline void migrateLegacySixSlot(const Legacy6SlotMQTTPrefs& old_prefs, Source source,
MQTTPrefs* prefs) {
migrateLegacySixSlotCommon(old_prefs, prefs);
if (source == Source::LegacySixSlotAudience || source == Source::LegacySixSlotAudienceRx ||
source == Source::LegacySixSlot) {
memcpy(prefs->mqtt_slot_audience, old_prefs.mqtt_slot_audience,
sizeof(prefs->mqtt_slot_audience));
}
if (source == Source::LegacySixSlotAudienceRx || source == Source::LegacySixSlot) {
prefs->mqtt_rx_enabled = old_prefs.mqtt_rx_enabled;
}
if (source == Source::LegacySixSlot) {
memcpy(prefs->mqtt_ntp_server, old_prefs.mqtt_ntp_server,
sizeof(prefs->mqtt_ntp_server));
}
}
} // namespace MQTTPrefsCodec
#endif // WITH_MQTT_BRIDGE
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <stdint.h>
// Pure recovery policy for the three MQTT preference transaction files. The
// writer first moves the old primary to .bak, then moves the verified .tmp to
// the primary name. On a reset, the loader uses this policy before decoding
// /mqtt_prefs. "Preserve" is deliberately distinct from "Usable": it covers
// an unsupported newer layout, corruption, or an unreadable file and must
// never be replaced by an older image.
namespace MQTTPrefsRecovery {
enum class FileState : uint8_t {
Missing,
Usable,
Preserve,
};
enum class Action : uint8_t {
None,
KeepPrimary,
PromoteTemp,
PromoteBackup,
};
inline Action select(FileState primary, FileState temp, FileState backup) {
// A primary of any kind owns the name. In particular, do not roll a newer
// or corrupt primary back to an older backup just because it cannot be read
// by this firmware.
if (primary != FileState::Missing) return Action::KeepPrimary;
// A completed temp is the new image and wins over the old backup.
if (temp == FileState::Usable) return Action::PromoteTemp;
// If temp is opaque but a known-good backup exists, boot from the backup.
// The caller may discard the opaque temp once that usable backup has become
// primary. Otherwise, rename the opaque temp into the empty primary name so
// the normal loader can hold it.
if (temp == FileState::Preserve) {
return backup == FileState::Usable ? Action::PromoteBackup : Action::PromoteTemp;
}
// No temp survived. The backup is the only recoverable image, even when it
// is a newer layout that this firmware must preserve rather than decode.
if (backup != FileState::Missing) return Action::PromoteBackup;
return Action::None;
}
} // namespace MQTTPrefsRecovery
+299
View File
@@ -0,0 +1,299 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
// /mqtt_prefs is a raw binary persistence format. Keep the layout-only types
// independent from CommonCLI so the migration decoder can be tested on the host
// without pulling in Arduino, filesystem, or radio dependencies.
#ifdef WITH_MQTT_BRIDGE
// Must match MAX_MQTT_SLOTS in MQTTPresets.h. CommonCLI.h enforces that link on
// firmware builds; keeping this header standalone avoids importing preset data
// into host migration tests.
static const int MQTT_PREFS_SLOT_COUNT = 6;
// Old MQTT preferences layout (pre-slot firmware) -- used only for migration detection.
struct OldMQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
uint8_t wifi_power_save;
char timezone_string[32];
int8_t timezone_offset;
char mqtt_server[64];
uint16_t mqtt_port;
char mqtt_username[32];
char mqtt_password[64];
uint8_t mqtt_analyzer_us_enabled;
uint8_t mqtt_analyzer_eu_enabled;
char mqtt_owner_public_key[65];
char mqtt_email[64];
};
// The pre-WiFi-power pre-slot layout has the same frozen size as
// OldMQTTPrefs, but timezone/server start one byte earlier. A conservative
// classifier distinguishes meaningful configurations before migration.
struct PreWifiPowerOldMQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
char timezone_string[32];
int8_t timezone_offset;
char mqtt_server[64];
uint16_t mqtt_port;
char mqtt_username[32];
char mqtt_password[64];
uint8_t mqtt_analyzer_us_enabled;
uint8_t mqtt_analyzer_eu_enabled;
char mqtt_owner_public_key[65];
char mqtt_email[64];
};
// MQTT preferences stored separately from NodePrefs to avoid upstream layout
// conflicts. The full layout is the frozen v1 payload baseline. The prefix
// before observer settings is also an explicitly supported v1 payload: it was
// used before the observer fields were appended.
struct MQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
uint8_t wifi_power_save;
char timezone_string[32];
int8_t timezone_offset;
char mqtt_slot_preset[MQTT_PREFS_SLOT_COUNT][24];
char mqtt_slot_host[MQTT_PREFS_SLOT_COUNT][64];
uint16_t mqtt_slot_port[MQTT_PREFS_SLOT_COUNT];
char mqtt_slot_username[MQTT_PREFS_SLOT_COUNT][32];
char mqtt_slot_password[MQTT_PREFS_SLOT_COUNT][64];
char mqtt_owner_public_key[65];
char mqtt_email[64];
char mqtt_slot_token[MQTT_PREFS_SLOT_COUNT][48];
char mqtt_slot_topic[MQTT_PREFS_SLOT_COUNT][96];
char mqtt_slot_audience[MQTT_PREFS_SLOT_COUNT][64];
uint8_t mqtt_rx_enabled;
char mqtt_ntp_server[64];
uint8_t snmp_enabled;
char snmp_community[24];
uint8_t radio_watchdog_minutes;
uint8_t alert_enabled;
char alert_psk_hex[33];
uint16_t alert_wifi_minutes;
uint16_t alert_mqtt_minutes;
uint16_t alert_min_interval_min;
char alert_hashtag[24];
char alert_region[31];
// Neighbors publishing (PSRAM boards only). Appended at the end of the
// observer tail so a shorter (pre-neighbors) /mqtt_prefs payload from earlier
// firmware still loads with these defaulting off/24h; keeps the format at
// VERSION 1. Field order and sizes are kept byte-identical to the flex
// neighbors build so a /mqtt_prefs written by either firmware is
// interchangeable (see the offsetof static_asserts below).
uint8_t mqtt_neighbors_enabled;
uint32_t mqtt_neighbors_interval;
};
// Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose
// signed-delta comparison requires intervals below INT32_MAX ms. The 336h
// (two-week) cap stays comfortably inside that range.
static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_HOURS = 12;
static const uint32_t MQTT_NEIGHBORS_MAX_INTERVAL_HOURS = 336;
static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS = 24;
static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_MS = MQTT_NEIGHBORS_MIN_INTERVAL_HOURS * 3600000UL;
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.
// - 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.
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;
// /mqtt_prefs starts with a self-describing 8-byte header. Headerless files
// are deployed legacy layouts and continue to be distinguished by size.
static const uint8_t MQTT_PREFS_MAGIC[4] = {0xF5, 'M', 'Q', 'P'};
static const uint16_t MQTT_PREFS_VERSION = 1;
struct MQTTPrefsHeader {
uint8_t magic[4];
uint16_t version;
uint16_t payload_len;
};
// 3-slot MQTTPrefs layout. Array dimensions changed in the current format, so
// it must be field-copied rather than read into MQTTPrefs directly.
struct ThreeSlotMQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
uint8_t wifi_power_save;
char timezone_string[32];
int8_t timezone_offset;
char mqtt_slot_preset[3][24];
char mqtt_slot_host[3][64];
uint16_t mqtt_slot_port[3];
char mqtt_slot_username[3][32];
char mqtt_slot_password[3][64];
char mqtt_owner_public_key[65];
char mqtt_email[64];
uint8_t _legacy_analyzer_us_enabled;
uint8_t _legacy_analyzer_eu_enabled;
char _legacy_mqtt_server[64];
uint16_t _legacy_mqtt_port;
char _legacy_mqtt_username[32];
char _legacy_mqtt_password[64];
char mqtt_slot_token[3][48];
char mqtt_slot_topic[3][96];
};
// The earlier 3-slot format preceded token/topic fields.
struct ThreeSlotBaseMQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
uint8_t wifi_power_save;
char timezone_string[32];
int8_t timezone_offset;
char mqtt_slot_preset[3][24];
char mqtt_slot_host[3][64];
uint16_t mqtt_slot_port[3];
char mqtt_slot_username[3][32];
char mqtt_slot_password[3][64];
char mqtt_owner_public_key[65];
char mqtt_email[64];
uint8_t _legacy_analyzer_us_enabled;
uint8_t _legacy_analyzer_eu_enabled;
char _legacy_mqtt_server[64];
uint16_t _legacy_mqtt_port;
char _legacy_mqtt_username[32];
char _legacy_mqtt_password[64];
};
// Headerless 6-slot layout shipped to the deployed flex fleet. It retains the
// removed `_legacy_*` block, so it too is field-copied into MQTTPrefs.
struct Legacy6SlotMQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
uint8_t wifi_power_save;
char timezone_string[32];
int8_t timezone_offset;
char mqtt_slot_preset[MQTT_PREFS_SLOT_COUNT][24];
char mqtt_slot_host[MQTT_PREFS_SLOT_COUNT][64];
uint16_t mqtt_slot_port[MQTT_PREFS_SLOT_COUNT];
char mqtt_slot_username[MQTT_PREFS_SLOT_COUNT][32];
char mqtt_slot_password[MQTT_PREFS_SLOT_COUNT][64];
char mqtt_owner_public_key[65];
char mqtt_email[64];
uint8_t _legacy_analyzer_us_enabled;
uint8_t _legacy_analyzer_eu_enabled;
char _legacy_mqtt_server[64];
uint16_t _legacy_mqtt_port;
char _legacy_mqtt_username[32];
char _legacy_mqtt_password[64];
char mqtt_slot_token[MQTT_PREFS_SLOT_COUNT][48];
char mqtt_slot_topic[MQTT_PREFS_SLOT_COUNT][96];
char mqtt_slot_audience[MQTT_PREFS_SLOT_COUNT][64];
uint8_t mqtt_rx_enabled;
char mqtt_ntp_server[64];
};
// Historical headerless 6-slot variants. They share a common prefix but only
// later files contain the appended audience, RX, and NTP fields.
static const size_t LEGACY6_BASE_SIZE = 2452;
static const size_t LEGACY6_AUDIENCE_SIZE = 2836;
static const size_t LEGACY6_AUDIENCE_RX_SIZE = 2840;
// Frozen on-flash layouts; every firmware and native fixture build checks them.
static_assert(sizeof(MQTTPrefsHeader) == 8, "versioned /mqtt_prefs header must stay 8 bytes");
static_assert(offsetof(MQTTPrefs, snmp_enabled) == MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE,
"v1 pre-observer /mqtt_prefs boundary changed");
static_assert(sizeof(MQTTPrefs) == MQTT_PREFS_V1_FULL_PAYLOAD_SIZE,
"v1 /mqtt_prefs payload layout changed");
// Lock the neighbors tail to the flex neighbors build's layout so a /mqtt_prefs
// written by either firmware is byte-for-byte interchangeable. The enable flag
// lands in the old struct's zeroed trailing padding (offset 2857), and the
// interval begins exactly at the pre-neighbors payload size (2860) so a
// pre-neighbors read stops right before it and the interval keeps its default.
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(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,
"frozen post-WiFi-power discriminator offset changed");
static_assert(offsetof(OldMQTTPrefs, timezone_string) == 145,
"frozen post-WiFi-power timezone offset changed");
static_assert(offsetof(OldMQTTPrefs, timezone_offset) == 177,
"frozen post-WiFi-power UTC offset changed");
static_assert(offsetof(OldMQTTPrefs, mqtt_server) == 178,
"frozen post-WiFi-power server offset changed");
static_assert(offsetof(PreWifiPowerOldMQTTPrefs, timezone_string) == 144,
"frozen pre-WiFi-power timezone offset changed");
static_assert(offsetof(PreWifiPowerOldMQTTPrefs, timezone_offset) == 176,
"frozen pre-WiFi-power UTC offset changed");
static_assert(offsetof(PreWifiPowerOldMQTTPrefs, mqtt_server) == 177,
"frozen pre-WiFi-power server offset changed");
static_assert(sizeof(ThreeSlotBaseMQTTPrefs) == 1032, "frozen early 3-slot /mqtt_prefs layout changed");
static_assert(sizeof(ThreeSlotMQTTPrefs) == 1464, "frozen 3-slot /mqtt_prefs layout changed");
static_assert(offsetof(ThreeSlotMQTTPrefs, mqtt_slot_token) == 1030,
"frozen 3-slot token offset changed");
static_assert(offsetof(ThreeSlotMQTTPrefs, mqtt_slot_topic) == 1174,
"frozen 3-slot topic offset changed");
static_assert(offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_audience) == LEGACY6_BASE_SIZE,
"frozen early 6-slot /mqtt_prefs prefix changed");
static_assert(offsetof(Legacy6SlotMQTTPrefs, mqtt_rx_enabled) == LEGACY6_AUDIENCE_SIZE,
"frozen audience 6-slot /mqtt_prefs prefix changed");
static_assert(offsetof(Legacy6SlotMQTTPrefs, mqtt_ntp_server) == 2837,
"frozen RX 6-slot /mqtt_prefs prefix changed");
static_assert(sizeof(Legacy6SlotMQTTPrefs) == 2904, "frozen deployed-fleet /mqtt_prefs layout changed");
#endif // WITH_MQTT_BRIDGE
+40 -6
View File
@@ -1,5 +1,6 @@
#pragma once
#include <cstdint>
#include <cstring>
// Maximum number of configurable MQTT connection slots (available to all builds for struct layout).
@@ -49,14 +50,36 @@ struct MQTTPresetDef {
const char* userpass_password; // MQTT_AUTH_USERPASS: embedded password, or nullptr to use mqttN.password
};
// True when preset uses MQTT_AUTH_USERPASS but credentials come from slot prefs (mqttN.username/password).
static inline bool mqttPresetNeedsSlotCredentials(const MQTTPresetDef* preset) {
// Sentinel: resolve MQTT username from device public-key hex at connect time.
// Braces match topic placeholders ({device}/{iata}); never send this string to the broker.
static const char MQTT_USERPASS_USERNAME_PUBKEY[] = "{pubkey}";
static inline bool mqttPresetUsesDevicePubkeyUsername(const MQTTPresetDef* preset) {
return preset && preset->auth_type == MQTT_AUTH_USERPASS &&
(!preset->userpass_username || !preset->userpass_password);
preset->userpass_username &&
strcmp(preset->userpass_username, MQTT_USERPASS_USERNAME_PUBKEY) == 0;
}
// True when USERPASS username must come from mqttN.username (null embedded username).
// "{pubkey}" is an embedded sentinel, so it does not need a slot username.
static inline bool mqttPresetNeedsSlotUsername(const MQTTPresetDef* preset) {
return preset && preset->auth_type == MQTT_AUTH_USERPASS &&
!preset->userpass_username;
}
// True when USERPASS password must come from mqttN.password (null embedded password).
static inline bool mqttPresetNeedsSlotPassword(const MQTTPresetDef* preset) {
return preset && preset->auth_type == MQTT_AUTH_USERPASS &&
!preset->userpass_password;
}
// True when preset uses MQTT_AUTH_USERPASS but at least one credential comes from slot prefs.
static inline bool mqttPresetNeedsSlotCredentials(const MQTTPresetDef* preset) {
return mqttPresetNeedsSlotUsername(preset) || mqttPresetNeedsSlotPassword(preset);
}
// Number of built-in presets
static const int MQTT_PRESET_COUNT = 25;
static const int MQTT_PRESET_COUNT = 29;
// Keep the certificate and preset tables in one translation unit. Defining
// these as header-local constants created a complete flash copy in every MQTT
@@ -123,12 +146,17 @@ extern const char ISRG_ROOT_X1[] PROGMEM =
// Built-in preset definitions (stored in flash)
extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {
// name url server rootCA auth topic keepalive tls enabled interval user pass
// name url audience rootCA auth topic tokenLife retain keepAlive user pass
{ "analyzer-us", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", "mqtt-us-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "analyzer-eu", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", "mqtt-eu-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "nz-analyzer", "wss://meshcore-mqtt-1.baird.io:443", "meshcore-mqtt-1.baird.io", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "meshmapper", "wss://mqtt.meshmapper.net:443/mqtt", "mqtt.meshmapper.net", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "meshrank", "mqtts://meshrank.net:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHRANK, 0, false, 0, nullptr, nullptr },
// waev token_lifetime is 3300 (55 min) on purpose: the broker's real JWT TTL is
// 60 min, and claiming less keeps fresh tokens accepted even with ~5 min of fast
// device-clock skew (and off any exp-iat<=3600 boundary strictness). Do NOT
// "fix" this to 3600 -- the renewal race is handled separately by
// tokenRenewalBufferSecs() in MQTTBridge, which renews another 5 min earlier.
{ "waev", "wss://mqtt.waev.app:443/mqtt", "mqtt.waev.app", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 3300, false, 55, nullptr, nullptr },
{ "meshomatic", "wss://us-east.meshomatic.net:443/mqtt", "us-east.meshomatic.net", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "cascadiamesh", "wss://mqtt-v1.cascadiamesh.org:443/mqtt", "mqtt-v1.cascadiamesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
@@ -138,7 +166,7 @@ extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {
{ "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 },
{ "coloradomesh", "wss://mqtt.meshcore.coloradomesh.org:1883","mqtt.meshcore.coloradomesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, 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 },
@@ -149,6 +177,12 @@ extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {
{ "rflab", "wss://mqtt.rflab.io:443", "mqtt.rflab.io", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "ipnt.uk", "wss://mqtt.ipnt.uk:443", "mqtt.ipnt.uk", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "flmesh", "wss://mcmqtt.jntconnections.com:443", "mcmqtt.jntconnections.com", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "corecomms", "wss://mqtt.corecomms.net:443/mqtt", "mqtt.corecomms.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "meshtexas", "wss://mqtt.meshtexas.org:443/mqtt", "mqtt.meshtexas.org", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
// Username is device pubkey hex at connect; password from mqttN.password. No TLS.
{ "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 },
};
#else
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <stddef.h>
#include <stdarg.h>
#include <stdio.h>
// Bounded, clamping printf-append for the fixed-size CLI reply buffers used by
// MQTTBridge's status/stats/diag formatters. Factored out of MQTTBridge so the
// bound is provable on the host instead of holding "by input-size accident"
// (see the A1 out-of-bounds-write finding, 2026-07-19 MQTT observer review).
//
// Appends `fmt...` to `buf` starting at offset `*pos`, then advances `*pos` by
// the number of characters actually written, CLAMPED to [0, bufsize-1]. Because
// snprintf returns the *would-have-written* length, the naive
// `*pos += snprintf(buf + *pos, bufsize - *pos, ...)` idiom can push `*pos` past
// `bufsize` after a truncated append; the next append then computes
// `bufsize - *pos` as a huge size_t and `buf + *pos` past the end, writing out of
// bounds. Clamping `*pos` here makes every subsequent append a safe no-op once
// the buffer is full.
//
// buf is always left NUL-terminated (vsnprintf guarantees this for bufsize > 0).
// No-ops on null buf/pos or bufsize == 0. A negative incoming *pos is treated as
// 0. Typical use: `int pos = 0;` then a sequence of replyAppendf() calls.
static inline void replyAppendf(char* buf, size_t bufsize, int* pos, const char* fmt, ...) {
if (!buf || !pos || bufsize == 0) return;
if (*pos < 0) *pos = 0;
// Full: no room for anything but the terminator. Keep buf NUL-terminated and
// leave *pos pinned at the last writable index.
if ((size_t)*pos >= bufsize - 1) {
*pos = (int)bufsize - 1;
buf[*pos] = '\0';
return;
}
size_t remaining = bufsize - (size_t)*pos;
va_list args;
va_start(args, fmt);
int n = vsnprintf(buf + *pos, remaining, fmt, args);
va_end(args);
// Encoding error: vsnprintf still NUL-terminated buf + *pos; leave *pos as-is.
if (n < 0) return;
*pos += n;
if ((size_t)*pos >= bufsize) *pos = (int)bufsize - 1; // clamp truncated append
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <stddef.h>
// Small ownership helpers for MQTT runtime buffers. They intentionally keep
// each buffer independent: a failed allocation leaves that buffer null (so its
// caller can use its stack fallback) without discarding the other buffers.
namespace MQTTRuntimeBufferLifecycle {
template <typename Allocator>
inline void* allocateIfMissing(void* buffer, size_t size, Allocator allocate) {
return buffer != nullptr ? buffer : allocate(size);
}
template <typename Deallocator>
inline void* release(void* buffer, Deallocator deallocate) {
if (buffer != nullptr) {
deallocate(buffer);
}
return nullptr;
}
} // namespace MQTTRuntimeBufferLifecycle
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include "MQTTObserverValidation.h"
#include "MQTTTopicTemplate.h"
// Pure MQTT publication-topic policy shared by MQTTBridge and the native tests.
// Keep these values aligned with MQTTBridge::MQTTMessageType; the bridge passes
// its enum value as an int so this helper stays independent of ESP/Arduino types.
enum MQTTPublicationType {
MQTT_PUBLICATION_STATUS = 0,
MQTT_PUBLICATION_PACKETS = 1,
MQTT_PUBLICATION_RAW = 2,
MQTT_PUBLICATION_NEIGHBORS = 3,
};
enum MQTTTopicRouteStyle {
MQTT_ROUTE_MESHCORE,
MQTT_ROUTE_MESHRANK,
MQTT_ROUTE_CUSTOM,
};
static inline bool mqttTopicSlotIndexValid(int index, size_t slot_count) {
return index >= 0 && (size_t)index < slot_count;
}
static inline const char* mqttPublicationTypeName(int type) {
switch (type) {
case MQTT_PUBLICATION_STATUS: return "status";
case MQTT_PUBLICATION_PACKETS: return "packets";
case MQTT_PUBLICATION_RAW: return "raw";
case MQTT_PUBLICATION_NEIGHBORS: return "neighbors";
default: return NULL;
}
}
static inline bool mqttWriteTopic(char* buf, size_t buf_size, const char* format,
const char* first, const char* second,
const char* third) {
if (!buf || buf_size == 0 || !format || !first || !second || !third) return false;
buf[0] = '\0';
int written = snprintf(buf, buf_size, format, first, second, third);
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.
static inline bool mqttBuildPublicationTopic(MQTTTopicRouteStyle style, int type,
const char* custom_template,
const char* iata, const char* device,
const char* token,
char* buf, size_t buf_size) {
if (!buf || buf_size == 0) return false;
buf[0] = '\0';
const char* type_name = mqttPublicationTypeName(type);
if (!type_name) return false;
switch (style) {
case MQTT_ROUTE_MESHCORE:
if (!mqttIataValid(iata) || strcmp(iata, "XXX") == 0 || !device || device[0] == '\0') {
return false;
}
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' ||
!device || device[0] == '\0') {
return false;
}
return mqttWriteTopic(buf, buf_size, "meshrank/uplink/%s/%s/%s",
token, device, type_name);
case MQTT_ROUTE_CUSTOM:
return mqttSubstituteTopic(custom_template, iata, device, token, type_name,
buf, buf_size);
default:
return false;
}
}
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <stddef.h>
#include <string.h>
// Expand the {iata} {device} {token} {type} placeholders in a custom MQTT topic
// template. Factored out of MQTTBridge::substituteTopicTemplate so the (bounded)
// string expansion can be unit-tested on the host; the bridge passes its cached
// _iata / _device_id, the slot token, and the message-type string.
//
// Returns false on buffer overflow or an empty result. buf is always
// NUL-terminated. A null value substitutes as empty; an unknown "{...}" token is
// copied through verbatim.
static inline bool mqttSubstituteTopic(const char* tmpl, const char* iata,
const char* device, const char* token,
const char* type_str, char* buf, size_t buf_size) {
if (!buf || buf_size == 0) return false;
if (!iata) iata = "";
if (!device) device = "";
if (!token) token = "";
if (!type_str) type_str = "";
size_t out = 0;
const char* p = tmpl ? tmpl : "";
while (*p && out < buf_size - 1) {
const char* sub = NULL;
size_t adv = 0;
if (strncmp(p, "{iata}", 6) == 0) {
sub = iata; adv = 6;
} else if (strncmp(p, "{device}", 8) == 0) {
sub = device; adv = 8;
} else if (strncmp(p, "{token}", 7) == 0) {
sub = token; adv = 7;
} else if (strncmp(p, "{type}", 6) == 0) {
sub = type_str; adv = 6;
}
if (sub) {
size_t len = strlen(sub);
if (out + len >= buf_size) {
buf[out] = '\0'; // keep buf terminated even on the overflow path
return false;
}
memcpy(buf + out, sub, len);
out += len;
p += adv;
} else {
buf[out++] = *p++;
}
}
buf[out] = '\0';
// The loop also stops when the output buffer is full. If input remains,
// report overflow just as we do for an oversized placeholder substitution;
// callers must never publish a silently truncated topic.
if (*p) return false;
return out > 0;
}
+2 -1
View File
@@ -514,8 +514,9 @@ bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) {
return false;
}
bool NRF52Board::startOTAUpdate(const char *id, char reply[]) {
bool NRF52Board::startOTAUpdate(const char *id, char reply[], bool force_ap) {
(void)id;
(void)force_ap;
if (ota_active) {
format_ota_reply(reply);
+1 -1
View File
@@ -65,7 +65,7 @@ public:
virtual void shutdownPeripherals();
virtual void powerOff() override;
virtual bool getBootloaderVersion(char* version, size_t max_len) override;
virtual bool startOTAUpdate(const char *id, char reply[]) override;
virtual bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override;
virtual bool stopOTAUpdate(char reply[]) override;
virtual void sleep(uint32_t secs) override;
bool isExternalPowered() override;
+10
View File
@@ -7,6 +7,16 @@
#ifdef ESP32
#include <FS.h>
// TFT_eSPI (pulled in by the tracker variants' display driver) defines
// FS_NO_GLOBALS, which suppresses FS.h's own `using fs::File`. Without this,
// File never reaches global scope and every TU that reaches FS.h through
// TFT_eSPI first fails with "'File' has not been declared" -- here and in
// simple_repeater/MyMesh.h. Restore exactly what FS.h would have done.
// Cannot use fs::File explicitly instead: File is also the global type on the
// nRF52/RP2040 paths, which have no fs namespace.
#if defined(FS_NO_GLOBALS)
using fs::File;
#endif
#endif
#define MAX_PACKET_HASHES (128+32)
+205
View File
@@ -0,0 +1,205 @@
#pragma once
#include <stdint.h>
// Fork-owned, dependency-free spec for the WebConfig "config batch / reboot /
// stop" decision + timing core, plus its host tests (test/test_webconfig_batch/).
//
// This is the Phase 6 counterpart of MQTTLifecycle.h: a PURE state machine that
// captures exactly what src/helpers/esp32/WebConfigServer.cpp decides today, so
// the POST-accept / drain / result-read / reboot / stop transitions can be
// exercised deterministically without AsyncWebServer, ArduinoJson, WiFi, or the
// FreeRTOS mutex/refcount.
//
// WIRED: WebConfigServer.cpp calls these functions directly, so they are
// load-bearing and the host tests cover production behavior rather than a
// parallel copy of it. MAX_BATCH and STOP_WARN_MS in WebConfigServer.h alias
// kMaxBatch/kStopWarnMs here, so the constants cannot drift either.
//
// Two deliberate asymmetries remain between this spec and its caller:
//
// 1. finishRebootAt() returns 0 for "no reboot scheduled", but the caller must
// only ASSIGN _reboot_at when the result is non-zero. _reboot_at is not
// solely batch-owned -- the manual /api/reboot route arms it from the
// async_tcp task, possibly while a batch is still draining -- so an
// unconditional assign would silently cancel a manual reboot.
// 2. classifyPost() is consulted in two phases by handleConfigPost, because the
// change count is only known after the `set` map is parsed, and parsing must
// not precede the Replay/Busy answer (a replayed POST carrying a bad key
// must still receive its 202).
//
// The file:line references below point at the behavior each function mirrors.
//
// Behavior source (all line refs against WebConfigServer.{h,cpp} at the time of
// writing): constants at .h:90-96,155-161; POST accept at .cpp:610-719; drain at
// .cpp:289-334; result read at .cpp:721-791; reboot fire at .cpp:262-265;
// isRebootPending at .cpp:70-74; stop gating at .cpp:185-255.
namespace WebConfigBatch {
// Constants, verbatim from WebConfigServer.
static const int kMaxBatch = 24; // .h:90 MAX_BATCH
static const uint32_t kDrainPacingMs = 25; // .cpp:296 inter-command gap
static const uint32_t kRebootFallbackMs = 30000; // .cpp:331 drain-finish fallback
static const uint32_t kRebootConfirmMs = 3000; // .cpp:784 first result-read arm
static const uint32_t kStopWarnMs = 10000; // .h:95 STOP_WARN_MS
// The batch lifecycle. A fresh POST moves Idle->Pending; the drainer moves
// Pending->Done; Done stays re-readable until the next POST claims the slot;
// finalizeTeardown() resets to Idle.
enum class State : uint8_t {
Idle = 0,
Pending,
Done,
};
// millis() idioms. elapsedMs uses unsigned wraparound (correct across one 32-bit
// rollover). scheduleAt mirrors the production wrap-guard: every _reboot_at /
// _stop_warn_at assignment does `if (t == 0) t = 1;` so 0 keeps meaning
// "unscheduled" even when the deadline lands exactly on the rollover boundary.
static inline uint32_t elapsedMs(uint32_t now, uint32_t then) { return now - then; }
static inline uint32_t scheduleAt(uint32_t now, uint32_t delay) {
const uint32_t t = now + delay;
return t == 0 ? 1u : t;
}
// Signed wrap-safe "deadline reached", matching the production
// `(int32_t)(now - deadline) >= 0` comparisons.
static inline bool deadlineReached(uint32_t now, uint32_t deadline) {
return (int32_t)(now - deadline) >= 0;
}
// --------------------------------------------------------------------------
// POST accept classification (.cpp:637-718). Precedence, verbatim from the
// source: an in-flight/finished batch with the SAME reqid is an idempotent
// replay (commands are NOT re-applied); a DIFFERENT reqid while a batch is still
// PENDING is rejected as busy; otherwise a batch with no changes and no reboot
// is a no-op, and anything else is accepted. Note the asymmetry: a different
// reqid while DONE is NOT busy -- the new batch overwrites the DONE slot.
//
// Assumes the request already passed reqid grammar (WebConfigKeys::wcIsValidReqId)
// and per-key allowlist/secret validation, which are covered by test_webconfig_keys.
// --------------------------------------------------------------------------
enum class PostOutcome : uint8_t {
Replay, // 202; reqid matches the current batch, commands not re-applied
Busy, // 409; a different batch is still PENDING
Accept, // 202; a new batch is accepted (from Idle, or overwriting a Done slot)
NoChanges, // 400; nothing to do (no changes and no reboot requested)
};
static inline PostOutcome classifyPost(State state, bool reqid_matches_current,
int change_count, bool reboot_after) {
if (state != State::Idle && reqid_matches_current) return PostOutcome::Replay;
if (state == State::Pending) return PostOutcome::Busy; // reqid differs (match handled above)
if (change_count <= 0 && !reboot_after) return PostOutcome::NoChanges;
return PostOutcome::Accept;
}
// The state string a Replay body reports mirrors the batch state (.cpp:640):
// "done" when the matched batch already finished, else "pending".
static inline const char* replayStateName(State state) {
return state == State::Done ? "done" : "pending";
}
// --------------------------------------------------------------------------
// Drain (.cpp:289-333). One command per tick.
// --------------------------------------------------------------------------
// The drainer waits only BETWEEN commands: never before the first (batch_next
// == 0 runs immediately and fires onConfigBatchStart), never after the last, and
// otherwise until the 25 ms pacing gap elapses. The pacing compare is SIGNED to
// mirror the source verbatim (.cpp:296 `(int32_t)(now - _batch_last_cmd) < 25`),
// matching deadlineReached()'s signedness; for all reachable inputs (elapsed
// 0..25 ms) it is identical to the unsigned form.
static inline bool drainMustWait(int batch_next, int batch_count,
uint32_t now, uint32_t last_cmd_ms) {
return batch_next > 0 && batch_next < batch_count &&
(int32_t)(now - last_cmd_ms) < (int32_t)kDrainPacingMs;
}
// all_ok is a sticky AND across command replies; a reply counts as ok iff it
// begins with "OK" (.cpp:314). Once false it stays false.
static inline bool nextAllOk(bool prev_all_ok, bool reply_is_ok) {
return prev_all_ok && reply_is_ok;
}
// The batch is finished once the post-increment drain index reaches the count
// (.cpp:319-321).
static inline bool drainFinished(int batch_next_after_increment, int batch_count) {
return batch_next_after_increment >= batch_count;
}
// On finish, a reboot-requested + all-ok batch arms the 30 s fallback deadline;
// a partially-failed batch (all_ok == false) never reboots (.cpp:324-333).
// Returns the reboot_at deadline, or 0 for "no reboot scheduled".
static inline uint32_t finishRebootAt(bool batch_reboot, bool batch_all_ok, uint32_t now) {
return (batch_reboot && batch_all_ok) ? scheduleAt(now, kRebootFallbackMs) : 0u;
}
// --------------------------------------------------------------------------
// Result read (.cpp:738-786).
// --------------------------------------------------------------------------
enum class ResultOutcome : uint8_t {
Idle, // 200 "idle" -- no batch; any valid reqid is echoed
Unknown, // 404 -- a batch exists but the reqid does not match it
Pending, // 200 "pending"
Done, // 200 "done" (+ per-command results)
};
static inline ResultOutcome classifyResult(State state, bool reqid_matches_current) {
if (state == State::Idle) return ResultOutcome::Idle; // no reqid check while idle
if (!reqid_matches_current) return ResultOutcome::Unknown;
return state == State::Pending ? ResultOutcome::Pending : ResultOutcome::Done;
}
// The "reboot" flag reported in a Done body (.cpp:767): only a fully-OK,
// reboot-requested batch advertises a pending reboot.
static inline bool doneReportsReboot(bool batch_reboot, bool batch_all_ok) {
return batch_reboot && batch_all_ok;
}
// The first Done read arms the confirmed (3 s) reboot exactly once (.cpp:777-786):
// the !already_armed guard makes later reads idempotent, so polling cannot push
// the deadline out. When this returns true the caller sets armed = true and
// reboot_at = confirmRebootAt(now).
static inline bool shouldArmConfirmReboot(State state, bool batch_reboot,
bool batch_all_ok, bool already_armed) {
return state == State::Done && batch_reboot && batch_all_ok && !already_armed;
}
static inline uint32_t confirmRebootAt(uint32_t now) {
return scheduleAt(now, kRebootConfirmMs);
}
// --------------------------------------------------------------------------
// Reboot fire (.cpp:262-265) and isRebootPending (.cpp:70-74).
// --------------------------------------------------------------------------
static inline bool rebootDue(uint32_t reboot_at, uint32_t now) {
return reboot_at != 0 && deadlineReached(now, reboot_at);
}
// isRebootPending() reports true only for a config-save reboot in the Done
// state, so the manual /api/reboot path (batch_reboot == false) is deliberately
// NOT reported as pending even though _reboot_at is set.
static inline bool isConfigRebootPending(uint32_t reboot_at, bool batch_reboot, State state) {
return reboot_at != 0 && batch_reboot && state == State::Done;
}
// --------------------------------------------------------------------------
// Stop gating (.cpp:244-255). Teardown waits indefinitely for in-flight async
// handlers to drain (refs == 0); the STOP_WARN_MS timer only triggers a one-time
// diagnostic -- it never forces teardown.
// --------------------------------------------------------------------------
enum class StopAction : uint8_t {
Finalize, // refs == 0: finalizeTeardown() may run now
Warn, // refs > 0 and the warn deadline passed, not yet warned: log once
Wait, // refs > 0: keep the session alive and wait
};
static inline StopAction stopStep(uint32_t handler_refs, bool already_warned,
uint32_t stop_warn_at, uint32_t now) {
if (handler_refs == 0) return StopAction::Finalize;
if (!already_warned && stop_warn_at != 0 && deadlineReached(now, stop_warn_at)) {
return StopAction::Warn;
}
return StopAction::Wait;
}
} // namespace WebConfigBatch
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include <string.h>
#include "MQTTPresets.h" // MAX_MQTT_SLOTS
// Classification of the config keys the web portal is allowed to drive through
// the CLI `set` handlers. Factored out of WebConfigServer.cpp so the allowlist
// and the (attacker-facing) key parsing can be unit-tested on the host without
// pulling in the whole ESP32 web server (see test/test_webconfig_keys).
//
// Everything here is pure string logic. The functions are `static inline` so
// each translation unit that includes this gets its own copy (there are only
// two: WebConfigServer.cpp and the test), avoiding any ODR concern.
// Keys mapping to CLI `set <key> <value>` handlers. Everything not listed here
// is rejected, so a crafted request can't reach arbitrary commands (`erase`,
// etc.) through the batch. The portal's admin-password field is classified
// separately, see wcIsAdminPasswordKey below.
static const char* const WC_ALLOWED_SET_KEYS[] = {
// NodePrefs (radio / node)
"name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"cad", "radio.rxgain", "radio.fem.rxgain", "repeat",
"advert.interval", "flood.advert.interval",
"flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect",
// MQTTPrefs (WiFi / MQTT / misc observer)
"wifi.ssid", "wifi.pwd", "wifi.powersave",
"mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw",
"mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.neighbors", "mqtt.neighbors.interval",
"mqtt.ntp", "mqtt.owner", "mqtt.email",
"timezone", "timezone.offset", "snmp", "snmp.community",
};
static const char* const WC_ALLOWED_SLOT_KEYS[] = {
"preset", "server", "port", "username", "password", "token", "topic", "audience",
};
// True when `key` is a well-formed per-slot key ("mqttN.<field>" with N in
// 1..MAX_MQTT_SLOTS). The shortest such key is "mqttN.x" (7 chars), and this
// probes key[4..6], so the length guard must come first -- an attacker-supplied
// "mqtt" or "m" would otherwise read past the terminator.
static inline bool wcIsSlotKeyPrefix(const char* key) {
return strlen(key) >= 7 && memcmp(key, "mqtt", 4) == 0
&& key[4] >= '1' && key[4] <= ('0' + MAX_MQTT_SLOTS) && key[5] == '.';
}
static inline bool wcIsAllowedSetKey(const char* key) {
for (size_t i = 0; i < sizeof(WC_ALLOWED_SET_KEYS) / sizeof(WC_ALLOWED_SET_KEYS[0]); i++) {
if (strcmp(key, WC_ALLOWED_SET_KEYS[i]) == 0) return true;
}
// mqtt<1-6>.<field>
if (wcIsSlotKeyPrefix(key)) {
for (size_t i = 0; i < sizeof(WC_ALLOWED_SLOT_KEYS) / sizeof(WC_ALLOWED_SLOT_KEYS[0]); i++) {
if (strcmp(&key[6], WC_ALLOWED_SLOT_KEYS[i]) == 0) return true;
}
}
return false;
}
// The admin password maps to the top-level `password` command, not a setter, so
// it is classified apart from the `set` allowlist. It is the only key that gets
// this treatment, which is what keeps the allowlist the sole route to `set` and
// leaves no general path from a batch to arbitrary top-level CLI commands.
static inline bool wcIsAdminPasswordKey(const char* key) {
return strcmp(key, "password") == 0;
}
static inline bool wcIsValidAdminPassword(const char* value) {
if (value == NULL) return false;
const size_t len = strlen(value);
if (len == 0 || len > 15) return false; // NodePrefs::password[16], including NUL
for (size_t i = 0; i < len; i++) {
if (value[i] == '\r' || value[i] == '\n') return false; // reject, never silently strip
}
return true;
}
// Keys carrying a secret whose stored value is masked with the placeholder in
// the UI; a POST echoing the placeholder for one of these is dropped (unchanged).
static inline bool wcIsSecretKey(const char* key) {
if (strcmp(key, "wifi.pwd") == 0) return true;
if (wcIsSlotKeyPrefix(key)
&& (strcmp(&key[6], "password") == 0 || strcmp(&key[6], "token") == 0)) return true;
return false;
}
// 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
// save/result correlation contract.
static inline bool wcIsValidReqId(const char* reqid) {
if (reqid == NULL || strlen(reqid) != 16) return false;
for (size_t i = 0; i < 16; i++) {
char c = reqid[i];
if (!((c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F'))) return false;
}
return true;
}
File diff suppressed because it is too large. Load diff
+122 -4
View File
@@ -11,6 +11,8 @@
#include <Timezone.h>
#include "helpers/JWTHelper.h"
#include "helpers/MQTTPresets.h"
#include "helpers/MQTTLifecycle.h"
#include <atomic>
#ifdef WITH_SNMP
class MeshSNMPAgent; // Forward declaration
@@ -50,6 +52,14 @@ 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
#define WITH_MQTT_NEIGHBORS 1
#endif
/**
* @brief Bridge implementation using MQTT protocol for packet transport
*
@@ -107,6 +117,8 @@ private:
uint8_t reconnect_backoff; // 0..4 index into backoff table
uint8_t max_backoff_failures; // consecutive failures at max backoff level
bool circuit_breaker_tripped; // true = stop reconnecting until reconfigured
unsigned long connected_at_ms; // millis() of last successful connect (0 = not connected);
// gates the stability-based backoff reset in maintenance
unsigned long last_reconnect_attempt;
unsigned long last_log_time; // Throttle disconnect log messages
unsigned long last_deferred_log_ms; // Throttle "connect deferred" log spam (Phase 1)
@@ -212,6 +224,14 @@ private:
// Pending slot reconfigure: set from CLI (Core 1), processed by MQTT task (Core 0)
volatile bool _slot_reconfigure_pending[RUNTIME_MQTT_SLOTS];
// Pending on-connect status publish: set from the onConnect callback (which
// runs on the esp-mqtt event task, NOT this bridge task), consumed by the MQTT
// task (Core 0). publishStatusToSlot() touches the shared status doc/buffer/
// origin that publishStatus() also uses, so it must run only on the bridge
// task -- the callback just raises this flag. Same idiom as
// _slot_reconfigure_pending; a single-byte volatile store/load is atomic.
volatile bool _status_publish_pending[RUNTIME_MQTT_SLOTS];
// CLI-requested forced NTP sync, marshalled onto the MQTT task (Core 0).
// All NTP I/O (_ntp_client, configTime) must run on Core 0; the CLI thread
// (Core 1) sets _ntp_force_requested and blocks in requestForcedNtpSync()
@@ -243,6 +263,16 @@ private:
volatile bool _ntp_estimate_ok;
volatile uint32_t _ntp_estimate_epoch;
// Cooperative-shutdown handshake (Phase 5). The loop task (Core 1) raises
// _stop_requested through the lifecycle Coordinator; the MQTT task (Core 0)
// sees it, tears down its own clients on Core 0 (where the mbedTLS contexts
// live), sets _stop_acked LAST, and self-terminates. end() waits for the ack
// before freeing the queue/buffers. Plain volatile matches the existing
// NTP/reconfigure handshake idiom above; replacing all of these with a command
// channel / task notifications is explicitly deferred (see MQTT_OWNERSHIP.md).
volatile bool _stop_requested = false;
volatile bool _stop_acked = false;
// Timezone handling.
// _timezone_storage is inline class storage (zero heap) that is reconfigured
// via setRules() whenever the preferred timezone string changes. _timezone
@@ -287,6 +317,26 @@ private:
char _status_json_buffer[STATUS_JSON_BUFFER_SIZE];
#endif
#if defined(WITH_MQTT_NEIGHBORS)
// Persistent PSRAM copy of the neighbors-table JSON. The mesh (Core 1) builds
// the payload into its own transient buffer, hands it here via
// requestPublishNeighbors(), and the MQTT task (Core 0) publishes this copy.
// Allocated in allocateRuntimeBuffers()/freed in releaseRuntimeBuffers() like
// the other PSRAM buffers (nullptr if the allocation failed).
char* _neighbors_json_buffer;
size_t _neighbors_publish_len;
// Release/acquire handoff from the mesh loop (Core 1) to the MQTT task (Core 0).
// A second snapshot is dropped while the current one is still publishing.
std::atomic<bool> _neighbors_publish_pending;
// Written by the MQTT task (Core 0), read by the CLI (Core 1) for `get mqtt.status`.
enum NeighborsResult : uint8_t { NBR_RESULT_NONE, NBR_RESULT_OK, NBR_RESULT_FAIL };
std::atomic<uint8_t> _neighbors_last_result;
// Written by the mesh loop (Core 1), read by the CLI (Core 1). Cached schedule
// summary so the wrap-safe millis math stays on the mesh side that owns the timer.
std::atomic<uint8_t> _neighbors_phase;
std::atomic<uint32_t> _neighbors_secs_until_next;
#endif
// JSON document scratch space - inline StaticJsonDocument keeps the pool off the MQTT
// task stack and eliminates two separate heap allocations (fragmentation reduction).
StaticJsonDocument<PUBLISH_JSON_BUFFER_SIZE> _packet_json_doc;
@@ -297,6 +347,7 @@ private:
// the MQTT memory-defrag work - persistent MQTT clients no longer churn
// the heap, so gray-zone / critical-restart trackers are unnecessary.
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
// Status publish retry tracking
@@ -311,7 +362,6 @@ private:
// Queue staleness tracking
unsigned long _queue_disconnected_since; // 0 = has connected slots
static const unsigned long QUEUE_STALE_MS = 300000UL; // Flush queue after 5 min disconnected
#ifdef WITH_SNMP
MeshSNMPAgent* _snmp_agent;
@@ -340,7 +390,7 @@ private:
mesh::MillisecondClock* _ms; // For uptime
// Topic building
enum MQTTMessageType { MSG_STATUS, MSG_PACKETS, MSG_RAW };
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);
@@ -362,6 +412,7 @@ private:
void maintainSlotConnections(); // Maintain all slot connections (token renewal, reconnect)
void maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted);
bool createSlotAuthToken(int index); // Create/renew JWT token for a slot
unsigned long slotTokenLifetime(int index) const; // effective JWT lifetime (preset/default minus slot stagger), seconds
bool publishToSlot(int index, const char* topic, const char* payload, bool retained = false, uint8_t qos = 0);
bool publishToAllSlots(const char* topic, const char* payload, bool retained = false, uint8_t qos = 0);
void publishStatusToSlot(int index);
@@ -381,6 +432,11 @@ private:
const uint8_t* raw_data = nullptr, int raw_len = 0,
float snr = 0.0f, float rssi = 0.0f);
bool publishRaw(mesh::Packet* packet);
#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.
bool publishNeighbors();
#endif
void queuePacket(mesh::Packet* packet, bool is_tx);
void dequeuePacket();
bool isAnySlotConnected();
@@ -399,6 +455,32 @@ private:
void getClientVersion(char* buffer, size_t buffer_size) const;
void logMemoryStatus();
void refreshOriginFromPrefs();
// begin()/end()-scoped PSRAM buffers. Each allocation is independent so a
// transient heap shortage degrades to the existing stack fallback instead
// of making the bridge unusable.
void allocateRuntimeBuffers();
void releaseRuntimeBuffers();
// --- Cooperative lifecycle (Phase 5) ---------------------------------------
// The pure state machine, bounded stop timeout, and OTA barrier live in
// src/helpers/MQTTLifecycle.h and are host-tested by test/test_mqtt_lifecycle/.
// This nested Ops binds that spec to FreeRTOS/PsychicMqttClient. The
// Coordinator is owned and driven ONLY by the loop task (Core 1) from
// begin()/end(); the MQTT task (Core 0) communicates solely through the
// _stop_requested/_stop_acked flags above. Methods are defined in the .cpp.
class LifecycleOps : public MQTTLifecycle::Ops {
public:
explicit LifecycleOps(MQTTBridge* bridge) : _b(bridge) {}
uint32_t nowMs() override;
void startTask() override;
void deliverStop() override;
void releaseResources() override;
void onStopComplete(bool clean) override;
private:
MQTTBridge* _b;
};
LifecycleOps _lifecycle_ops;
MQTTLifecycle::Coordinator _lifecycle;
// Observer config (MQTT/WiFi/timezone/SNMP/alert), persisted to /mqtt_prefs.
MQTTPrefs* _obs = nullptr;
@@ -424,7 +506,7 @@ public:
* "set mqttN.preset <name>". Handles teardown of old connection and
* setup of new one.
*
* @param slot_index Slot index (0-2)
* @param slot_index Slot index (0 to RUNTIME_MQTT_SLOTS-1)
* @param preset_name Preset name: "analyzer-us", "analyzer-eu", "nz-analyzer", "meshmapper", "custom", "none"
*/
void setSlotPreset(int slot_index, const char* preset_name);
@@ -434,7 +516,7 @@ public:
* Configure custom broker settings for a slot. Only applies when the
* slot's preset is "custom".
*
* @param slot_index Slot index (0-2)
* @param slot_index Slot index (0 to RUNTIME_MQTT_SLOTS-1)
* @param host Broker hostname
* @param port Broker port
* @param username MQTT username (empty for anonymous)
@@ -451,9 +533,37 @@ public:
void setBuildDate(const char* build_date);
void storeRawRadioData(const uint8_t* raw_data, int len, float snr, float rssi);
void setMessageTypes(bool status, bool packets, bool raw);
#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.
static const size_t NEIGHBORS_JSON_BUFFER_SIZE = 10240;
// 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
// publish-pending flag for the MQTT task; a request is dropped if one is
// already in flight or the buffer is unavailable.
void requestPublishNeighbors(const char* json, size_t len);
// Periodic-neighbors schedule, reported by the mesh loop for `get mqtt.status`.
// The mesh owns the timer; the bridge only caches the summary so the wrap-safe
// millis math stays on the side that already has those helpers.
enum NeighborsPhase : uint8_t {
NBR_SCHEDULED, // waiting for the next publish; secs_until_next is valid
NBR_ACTIVE, // zero-hop refresh or scope queries in flight
NBR_DUE, // publish is due, waiting on the bridge/WiFi to come up
};
void setNeighborsSchedule(NeighborsPhase phase, uint32_t secs_until_next);
#endif
int getConnectedBrokers() const;
int getQueueSize() const;
bool isReady() const;
/** True only after a CLEAN cooperative stop -- end() received the MQTT task's
* acknowledgment within the timeout. A timed-out/forced stop returns false so
* OTA flashing is withheld until a clean start/stop cycle. Mirrors
* MQTTLifecycle::mayBeginFlash(); read on the loop task (Core 1). */
bool canFlashAfterStop() const { return _lifecycle.mayBeginFlash(); }
static unsigned long getWifiConnectedAtMillis();
@@ -473,6 +583,11 @@ public:
bool isSlotEnabledAndAttempted(int slot_index) const;
const char* getSlotPresetName(int slot_index) const;
static int getRuntimeSlotCount() { return RUNTIME_MQTT_SLOTS; }
/** Max slots that can be connected at once: 5 with PSRAM, 2 without (each
* WSS/TLS connection needs ~40KB for mbedTLS buffers). This is the number of
* usefully-configurable servers; RUNTIME_MQTT_SLOTS carries a spare for
* reconfiguration. Safe to call before begin(). */
static int getMaxActiveSlots();
/** Resolved origin for MQTT JSON: node_name when mqtt_origin is empty, else mqtt_origin (with quote stripping). */
static void getEffectiveMqttOrigin(const char* node_name, const MQTTPrefs* obs,
char* buf, size_t buf_size);
@@ -502,6 +617,9 @@ public:
/** True after this bridge has successfully set the RTC from NTP this boot. */
bool hasNtpTime() const { return _ntp_synced; }
static void formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs);
/** On-demand publish-health + heap snapshot for `get mqtt.stats` (per-slot ok/err,
* outbox size, free/max heap, queue depth). */
static void formatMqttStatsReply(char* buf, size_t bufsize);
// Structured status used by the browser configuration page. Publish
// counters are optional because not every supported PsychicMqttClient
// version exposes them.
File diff suppressed because it is too large. Load diff
+273 -121
View File
@@ -19,62 +19,23 @@
#endif
#include "WebConfigHtml.h"
#include "helpers/WebConfigKeys.h"
// Placeholder sent instead of stored secrets; POSTs carrying it are dropped
// so an untouched password field never overwrites the stored value.
static const char SECRET_SENTINEL[] = "********";
// Keys the web UI may drive through the CLI `set` handlers. Everything else
// is rejected, so a crafted request can't reach arbitrary commands (`erase`,
// `password`, ...) through the batch.
static const char* const ALLOWED_SET_KEYS[] = {
// NodePrefs (radio / node)
"name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"cad", "radio.rxgain", "radio.fem.rxgain", "repeat", "advert.interval",
"flood.advert.interval",
"flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect",
// MQTTPrefs (WiFi / MQTT / misc observer)
"wifi.ssid", "wifi.pwd", "wifi.powersave",
"mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw",
"mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.ntp", "mqtt.owner", "mqtt.email",
"timezone", "timezone.offset", "snmp", "snmp.community",
};
static const char* const ALLOWED_SLOT_KEYS[] = {
"preset", "server", "port", "username", "password", "token", "topic", "audience",
};
static bool isAllowedSetKey(const char* key, bool has_mqtt) {
if (!key) return false;
for (size_t i = 0; i < sizeof(ALLOWED_SET_KEYS) / sizeof(ALLOWED_SET_KEYS[0]); i++) {
if (strcmp(key, ALLOWED_SET_KEYS[i]) == 0) {
const bool mqtt_only = strncmp(key, "mqtt.", 5) == 0
|| strncmp(key, "timezone", 8) == 0
|| strncmp(key, "snmp", 4) == 0;
return has_mqtt || !mqtt_only;
}
}
// mqtt<1-6>.<field>
#ifdef WITH_MQTT_BRIDGE
if (!has_mqtt) return false;
if (strlen(key) > 6 && strncmp(key, "mqtt", 4) == 0
&& key[4] >= '1' && key[4] <= ('0' + MAX_MQTT_SLOTS)
&& key[5] == '.') {
for (size_t i = 0; i < sizeof(ALLOWED_SLOT_KEYS) / sizeof(ALLOWED_SLOT_KEYS[0]); i++) {
if (strcmp(&key[6], ALLOWED_SLOT_KEYS[i]) == 0) return true;
}
}
#else
(void)has_mqtt;
#endif
return false;
if (!key || !wcIsAllowedSetKey(key)) return false;
const bool mqtt_only = strncmp(key, "mqtt.", 5) == 0
|| strncmp(key, "mqtt", 4) == 0
|| strncmp(key, "timezone", 8) == 0
|| strncmp(key, "snmp", 4) == 0;
return has_mqtt || !mqtt_only;
}
static bool isSecretKey(const char* key) {
if (!key) return false;
if (strcmp(key, "wifi.pwd") == 0) return true;
if (strlen(key) > 6 && strncmp(key, "mqtt", 4) == 0 && key[5] == '.'
&& (strcmp(&key[6], "password") == 0 || strcmp(&key[6], "token") == 0)) return true;
return false;
return key && wcIsSecretKey(key);
}
// Constant-time-ish comparison so login timing doesn't leak a prefix match.
@@ -97,7 +58,9 @@ struct WCLock {
};
WebConfigServer* WebConfigServer::_active = NULL;
AsyncWebServer* WebConfigServer::_host = NULL;
static volatile bool webconfig_button_toggle_requested = false;
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,
@@ -105,7 +68,6 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow
: _cb(callbacks), _mqtt_prefs(mqtt_prefs), _owns_wifi(owns_wifi), _pub_key(pub_key),
_fw_ver(fw_ver), _role(role), _board_name(board_name) {
_mux = xSemaphoreCreateMutex();
_active = this;
#ifdef WITH_MQTT_BRIDGE
MQTTPrefs* obs = static_cast<MQTTPrefs*>(_mqtt_prefs);
@@ -130,7 +92,7 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow
}
WebConfigServer::~WebConfigServer() {
if (_active == this) _active = NULL;
detachRoutes();
if (_mux) vSemaphoreDelete(_mux);
}
@@ -201,8 +163,9 @@ bool WebConfigServer::takeButtonToggleRequest() {
bool WebConfigServer::isRebootPending() {
WebConfigServer* w = _active;
return w != NULL && w->_reboot_at != 0 && w->_batch_reboot &&
w->_batch_state == BATCH_DONE;
return w != NULL && WebConfigBatch::isConfigRebootPending(
w->_reboot_at, w->_batch_reboot,
toSpecState(w->_batch_state));
}
bool WebConfigServer::getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len) {
@@ -232,6 +195,10 @@ bool WebConfigServer::startSetupMode(char reply[]) {
// the AP is up. STA stays unconnected - the bridge won't touch WiFi
// while wifi_ssid is empty, and `start webconfig ap` requires it stopped.
WiFi.mode(WIFI_AP_STA);
// Setup mode has no login. Drop any STA association so the open setup API is
// reachable only from the setup AP, not from the operator's LAN.
WiFi.setAutoReconnect(false);
WiFi.disconnect(false, true);
snprintf(_ap_ssid, sizeof(_ap_ssid), "MeshCore-Setup-%02X%02X", _pub_key[0], _pub_key[1]);
#ifdef WEBCONFIG_AP_PASSWORD
bool ap_ok = WiFi.softAP(_ap_ssid, WEBCONFIG_AP_PASSWORD);
@@ -249,9 +216,12 @@ bool WebConfigServer::startSetupMode(char reply[]) {
_dns = new DNSServer();
_dns->start(53, "*", ip); // captive portal: every name resolves to us
if (!promote_lan) createServer();
_mode = MODE_SETUP;
if (!promote_lan) createServer();
_was_setup_ap = true;
NodeSnapshot node = {};
_cb->getNodeSnapshot(node);
_initial_setup = _wifi_ssid[0] == 0 && node.admin_password[0] != 0;
_last_activity = millis();
WiFi.scanNetworks(true); // pre-populate the SSID picker
@@ -268,8 +238,8 @@ bool WebConfigServer::startLanMode(char reply[]) {
strcpy(reply, "Err: WiFi not connected");
return false;
}
createServer();
_mode = MODE_LAN;
createServer();
_last_activity = millis();
NodeSnapshot node = {};
@@ -306,36 +276,41 @@ bool WebConfigServer::startAutoMode(char reply[]) {
}
void WebConfigServer::createServer() {
_server = new AsyncWebServer(80);
if (_host == NULL) {
_host = new AsyncWebServer(80);
_server = _host;
registerRoutes();
} else {
_server = _host;
}
// iOS caches plain-HTTP GETs aggressively (keyed by URL, surviving even a
// device reflash behind the same IP), which poisons /api/config/result and
// friends with stale responses from earlier sessions. Forbid caching on
// every response; the HTML is small enough to refetch per visit.
// DefaultHeaders is process-global and survives a stop/start cycle. Adding
// this on every start would retain duplicate header nodes indefinitely.
static bool cache_header_added = false;
if (!cache_header_added) {
DefaultHeaders::Instance().addHeader("Cache-Control", "no-store");
cache_header_added = true;
}
registerRoutes();
attachRoutes();
_server->begin();
}
void WebConfigServer::requestStop() {
if (_mode == MODE_OFF && !_stopping) return;
detachRoutes();
if (_server) _server->end();
if (_dns) _dns->stop();
_mode = MODE_OFF;
_stopping = true;
// Deleting an AsyncWebServer with live connections is a known crash source;
// give in-flight responses a grace period before freeing.
_delete_at = millis() + 2000;
if (_delete_at == 0) _delete_at = 1;
_stop_warn_at = WebConfigBatch::scheduleAt(millis(), STOP_WARN_MS);
_stop_warned = false;
}
void WebConfigServer::finalizeTeardown() {
delete _server;
// Async requests keep a pointer to their server until disconnect. Retain the
// listener and route table for the firmware lifetime; only detach and reclaim
// the per-session state.
_server = NULL;
delete _dns;
_dns = NULL;
@@ -355,8 +330,10 @@ void WebConfigServer::finalizeTeardown() {
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
}
_initial_setup = false;
_stopping = false;
_delete_at = 0;
_stop_warn_at = 0;
_stop_warned = false;
_connect_deadline = 0;
_reboot_at = 0;
_batch_state = BATCH_IDLE;
@@ -369,15 +346,27 @@ void WebConfigServer::finalizeTeardown() {
void WebConfigServer::tick(uint32_t now) {
if (_stopping) {
if (_delete_at && (int32_t)(now - _delete_at) >= 0) finalizeTeardown();
uint32_t refs = handlerRefCount();
switch (WebConfigBatch::stopStep(refs, _stop_warned, _stop_warn_at, now)) {
case WebConfigBatch::StopAction::Finalize:
finalizeTeardown();
break;
case WebConfigBatch::StopAction::Warn:
_stop_warned = true;
Serial.printf("WC: stop waiting for %lu handler(s); retaining session safely\n",
(unsigned long)refs);
break;
case WebConfigBatch::StopAction::Wait:
break;
}
return;
}
if (_mode == MODE_OFF) return;
if (_mode == MODE_CONNECTING) {
if (WiFi.status() == WL_CONNECTED) {
createServer();
_mode = MODE_LAN;
createServer();
_connect_deadline = 0;
_last_activity = now;
Serial.printf("WebConfig ready: http://%s/\n", WiFi.localIP().toString().c_str());
@@ -397,7 +386,7 @@ void WebConfigServer::tick(uint32_t now) {
if (_batch_state == BATCH_PENDING) drainBatch(now);
if (_reboot_at && (int32_t)(now - _reboot_at) >= 0) {
if (WebConfigBatch::rebootDue(_reboot_at, now)) {
Serial.printf("WC: rebooting now (%s)\n", _batch_reboot_armed ? "confirmed" : "fallback");
_cb->rebootNow(); // does not return
}
@@ -432,7 +421,8 @@ void WebConfigServer::drainBatch(uint32_t now) {
if (_batch_next == 0) {
WCLock prefs_lock(_mux);
_cb->onConfigBatchStart();
} else if (_batch_next < _batch_count && (int32_t)(now - _batch_last_cmd) < 25) {
} else if (WebConfigBatch::drainMustWait(_batch_next, _batch_count,
now, _batch_last_cmd)) {
return; // let the WiFi task breathe between flash writes
}
if (_batch_next < _batch_count) {
@@ -440,7 +430,9 @@ void WebConfigServer::drainBatch(uint32_t now) {
BatchEntry& e = _batch[_batch_next++];
e.reply[0] = 0;
uint32_t t0 = millis();
const char* value = e.cmd + strlen("set ") + strlen(e.key) + 1;
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");
@@ -470,6 +462,9 @@ void WebConfigServer::drainBatch(uint32_t now) {
}
} else {
_cb->execCommand(e.cmd, e.reply);
// The CLI password command echoes the new secret. Never return it to a
// browser client, especially over the open setup AP.
if (admin_pwd) strcpy(e.reply, "OK");
// Keep the response snapshot current after MQTT/companion CLI handlers
// persist a WiFi field. The browser can then soft-reload without a stale
// value even before the requested reboot happens.
@@ -484,14 +479,14 @@ void WebConfigServer::drainBatch(uint32_t now) {
}
}
if (e.reply[0] == 0) strcpy(e.reply, "OK");
// A wizard save must not reboot away from a rejected setting before the
// operator can correct it. A reboot-only batch (zero commands) is still
// allowed; otherwise every command must report success.
if (_batch_reboot && strncmp(e.reply, "OK", 2) != 0) _batch_reboot = false;
_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));
if (_batch_next < _batch_count) return; // more commands next tick
if (!WebConfigBatch::drainFinished(_batch_next, _batch_count)) {
return; // more commands next tick
}
}
if (_standalone_wifi_dirty) {
_standalone_wifi_dirty = false;
@@ -504,7 +499,7 @@ void WebConfigServer::drainBatch(uint32_t now) {
break;
}
}
_batch_reboot = false;
_batch_all_ok = false;
}
}
{
@@ -513,14 +508,12 @@ void WebConfigServer::drainBatch(uint32_t now) {
}
WCLock lock(_mux);
_batch_state = BATCH_DONE;
if (_batch_reboot) {
// Fallback only: the real 3 s reboot timer is armed when the client reads
// /api/config/result (handleConfigResult), so the browser gets its
// confirmation before the AP/WiFi drops. This covers a client that
// disconnected and never polls - generous enough for a phone that got
// bounced off the AP mid-save to rejoin and fetch its confirmation.
_reboot_at = now + 30000;
if (_reboot_at == 0) _reboot_at = 1;
const uint32_t reboot_at =
WebConfigBatch::finishRebootAt(_batch_reboot, _batch_all_ok, now);
if (reboot_at != 0) {
// Fallback only: a successful result read replaces this with the shorter
// confirmation delay. Failed batches remain available for correction.
_reboot_at = reboot_at;
}
}
@@ -535,26 +528,94 @@ static void wcLogReq(AsyncWebServerRequest* r) {
Serial.printf("WC: http %s %s\n", r->methodToString(), r->url().c_str());
}
void WebConfigServer::attachRoutes() {
portENTER_CRITICAL(&s_wc_route_mux);
_active = this;
portEXIT_CRITICAL(&s_wc_route_mux);
}
void WebConfigServer::detachRoutes() {
portENTER_CRITICAL(&s_wc_route_mux);
if (_active == this) _active = NULL;
portEXIT_CRITICAL(&s_wc_route_mux);
}
uint32_t WebConfigServer::handlerRefCount() const {
portENTER_CRITICAL(&s_wc_route_mux);
uint32_t refs = _handler_refs;
portEXIT_CRITICAL(&s_wc_route_mux);
return refs;
}
void WebConfigServer::dispatchRequest(AsyncWebServerRequest* req,
RequestHandler handler) {
wcLogReq(req);
WebConfigServer* target = NULL;
portENTER_CRITICAL(&s_wc_route_mux);
if (_active != NULL) {
target = _active;
target->_handler_refs++;
}
portEXIT_CRITICAL(&s_wc_route_mux);
if (target == NULL) {
req->send(503, "application/json", "{\"error\":\"webconfig stopped\"}");
return;
}
(target->*handler)(req);
portENTER_CRITICAL(&s_wc_route_mux);
if (target->_handler_refs > 0) target->_handler_refs--;
portEXIT_CRITICAL(&s_wc_route_mux);
}
void WebConfigServer::registerRoutes() {
_server->on("/", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleRoot(r); });
_server->on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleStatus(r); });
_server->on("/api/presets", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handlePresets(r); });
_server->on("/api/login", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleLogin(r); },
_server->on("/", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleRoot);
});
_server->on("/api/status", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleStatus);
});
_server->on("/api/presets", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handlePresets);
});
_server->on("/api/login", HTTP_POST, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleLogin);
},
NULL, collectBody);
_server->on("/api/logout", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleLogout(r); });
_server->on("/api/logout", HTTP_POST, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleLogout);
});
// NB: plain-string routes match sub-paths too ("/api/config" matches
// "/api/config/result") and handlers run in registration order, so the more
// specific route MUST be registered first or it never fires. This was why
// save confirmations were lost: result polls were answered with config JSON.
_server->on("/api/config/result", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigResult(r); });
_server->on("/api/config", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigGet(r); });
_server->on("/api/config", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigPost(r); },
_server->on("/api/config/result", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleConfigResult);
});
_server->on("/api/config", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleConfigGet);
});
_server->on("/api/config", HTTP_POST, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleConfigPost);
},
NULL, collectBody);
_server->on("/api/stats", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleStats(r); });
_server->on("/api/scan", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleScan(r); });
_server->on("/api/reboot", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleReboot(r); });
_server->on("/api/portal/exit", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handlePortalExit(r); });
_server->onNotFound([this](AsyncWebServerRequest* r) { wcLogReq(r); handleNotFound(r); });
_server->on("/api/stats", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleStats);
});
_server->on("/api/scan", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleScan);
});
_server->on("/api/reboot", HTTP_POST, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleReboot);
});
_server->on("/api/portal/exit", HTTP_POST, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handlePortalExit);
});
_server->onNotFound([](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleNotFound);
});
}
// Accumulate a small JSON body into request->_tempObject (freed automatically
@@ -627,6 +688,8 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
doc["mode"] = (_mode == MODE_SETUP) ? "setup" : "lan";
doc["auth"] = authed;
doc["needs_setup"] = (_wifi_ssid[0] == 0);
doc["needs_password"] = _initial_setup;
doc["password_supported"] = node.admin_password[0] != 0;
doc["name"] = (const char*)node.name;
char node_id[17];
for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]);
@@ -638,9 +701,11 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
#ifdef WITH_MQTT_BRIDGE
doc["runtime_slots"] = has_mqtt ? RUNTIME_MQTT_SLOTS : 0;
doc["max_slots"] = has_mqtt ? MAX_MQTT_SLOTS : 0;
doc["active_slots"] = has_mqtt ? MQTTBridge::getMaxActiveSlots() : 0;
#else
doc["runtime_slots"] = 0;
doc["max_slots"] = 0;
doc["active_slots"] = 0;
#endif
doc["mqtt"] = has_mqtt;
doc["capabilities"] = node.capabilities;
@@ -763,6 +828,8 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
: obs->mqtt_tx_enabled == 1 ? "on" : "off";
mqtt["rx"] = (bool)obs->mqtt_rx_enabled;
mqtt["interval"] = obs->mqtt_status_interval / 60000;
mqtt["neighbors"] = (bool)obs->mqtt_neighbors_enabled;
mqtt["neighbors_interval"] = obs->mqtt_neighbors_interval / 3600000UL;
mqtt["timezone"] = (const char*)obs->timezone_string;
mqtt["timezone_offset"] = obs->timezone_offset;
mqtt["ntp"] = (const char*)obs->mqtt_ntp_server;
@@ -806,13 +873,42 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
return;
}
bool reboot_after = doc["reboot"] | false;
const char* reqid = doc["reqid"] | "";
if (!wcIsValidReqId(reqid)) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
}
JsonObject set = doc["set"];
WCLock lock(_mux);
// A DONE batch stays readable until the next POST claims the slot, so a
// client that lost the result response can re-poll instead of failing.
if (_batch_state == BATCH_PENDING) {
req->send(409, "application/json", "{\"error\":\"busy\"}");
const WebConfigBatch::State bstate = toSpecState(_batch_state);
const bool reqid_matches = strcmp(reqid, _batch_reqid) == 0;
const WebConfigBatch::PostOutcome pre =
WebConfigBatch::classifyPost(bstate, reqid_matches, 1, reboot_after);
if (pre == WebConfigBatch::PostOutcome::Replay) {
StaticJsonDocument<96> ack;
ack["state"] = WebConfigBatch::replayStateName(bstate);
ack["count"] = _batch_count;
ack["reqid"] = (const char*)_batch_reqid;
String out;
serializeJson(ack, out);
req->send(202, "application/json", out);
return;
}
if (pre == WebConfigBatch::PostOutcome::Busy) {
StaticJsonDocument<96> busy;
busy["error"] = "busy";
busy["reqid"] = (const char*)_batch_reqid;
String out;
serializeJson(busy, out);
req->send(409, "application/json", out);
return;
}
if (_mode == MODE_SETUP && _initial_setup && !set.containsKey("password")
&& (reboot_after || set.containsKey("wifi.ssid"))) {
req->send(400, "application/json",
"{\"error\":\"admin password required for initial setup\"}");
return;
}
@@ -820,10 +916,22 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
for (JsonPair kv : set) {
const char* key = kv.key().c_str();
const char* val = kv.value().as<const char*>();
if (!val || !isAllowedSetKey(key, _mqtt_prefs != NULL)) {
char err[96];
snprintf(err, sizeof(err), "{\"error\":\"bad key\",\"key\":\"%.32s\"}", key);
req->send(400, "application/json", err);
const bool admin_pwd = wcIsAdminPasswordKey(key);
if (!val || (!isAllowedSetKey(key, _mqtt_prefs != NULL) && !admin_pwd)) {
char safe_key[33];
strncpy(safe_key, key, sizeof(safe_key) - 1);
safe_key[sizeof(safe_key) - 1] = 0;
StaticJsonDocument<128> err;
err["error"] = "bad key";
err["key"] = safe_key;
String out;
serializeJson(err, out);
req->send(400, "application/json", out);
return;
}
if (admin_pwd && !wcIsValidAdminPassword(val)) {
req->send(400, "application/json",
"{\"error\":\"admin password must be 1-15 characters with no line breaks\"}");
return;
}
if (isSecretKey(key) && strcmp(val, SECRET_SENTINEL) == 0) continue; // unchanged
@@ -834,9 +942,10 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
BatchEntry& e = _batch[count];
strncpy(e.key, key, sizeof(e.key) - 1);
e.key[sizeof(e.key) - 1] = 0;
// Build "set <key> <value>", stripping CR/LF so a value can't smuggle in
// a second command.
int pos = snprintf(e.cmd, sizeof(e.cmd), "set %s ", key);
// Build the allowlisted CLI command, stripping CR/LF so a value cannot
// 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++) {
if (*p == '\r' || *p == '\n') continue;
e.cmd[pos++] = *p;
@@ -844,7 +953,8 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
e.cmd[pos] = 0;
count++;
}
if (count == 0 && !reboot_after) {
if (WebConfigBatch::classifyPost(bstate, reqid_matches, count, reboot_after) ==
WebConfigBatch::PostOutcome::NoChanges) {
req->send(400, "application/json", "{\"error\":\"no changes\"}");
return;
}
@@ -852,6 +962,9 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
_batch_next = 0;
_batch_reboot = reboot_after;
_batch_reboot_armed = false;
_batch_all_ok = true;
strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1);
_batch_reqid[sizeof(_batch_reqid) - 1] = 0;
_standalone_wifi_dirty = false;
_batch_state = BATCH_PENDING; // tick() picks it up on the loop task
uint32_t du = millis() + 60000;
@@ -859,33 +972,67 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
_diag_until = du;
Serial.printf("WC: config POST accepted, %d cmds, reboot=%d\n", count, (int)reboot_after);
char msg[64];
snprintf(msg, sizeof(msg), "{\"state\":\"pending\",\"count\":%d}", count);
req->send(202, "application/json", msg);
StaticJsonDocument<96> ack;
ack["state"] = "pending";
ack["count"] = count;
ack["reqid"] = (const char*)_batch_reqid;
String out;
serializeJson(ack, out);
req->send(202, "application/json", out);
}
void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { Serial.println("WC: result read -> 503 (mode off)"); req->send(503); return; }
if (!checkAuth(req)) { Serial.println("WC: result read -> 401"); req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
if (!req->hasParam("reqid")) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
}
String requested_reqid = req->getParam("reqid")->value();
if (!wcIsValidReqId(requested_reqid.c_str())) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
}
// Entry print BEFORE the lock (racy state read is fine for diag): if this
// 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);
if (_batch_state == BATCH_IDLE) {
const WebConfigBatch::ResultOutcome outcome = WebConfigBatch::classifyResult(
toSpecState(_batch_state),
strcmp(requested_reqid.c_str(), _batch_reqid) == 0);
if (outcome == WebConfigBatch::ResultOutcome::Idle) {
Serial.println("WC: result read -> idle");
req->send(200, "application/json", "{\"state\":\"idle\"}");
StaticJsonDocument<64> idle;
idle["state"] = "idle";
idle["reqid"] = requested_reqid;
String out;
serializeJson(idle, out);
req->send(200, "application/json", out);
return;
}
if (_batch_state == BATCH_PENDING) {
req->send(200, "application/json", "{\"state\":\"pending\"}");
if (outcome == WebConfigBatch::ResultOutcome::Unknown) {
req->send(404, "application/json", "{\"error\":\"unknown request\"}");
return;
}
Serial.printf("WC: result read -> done (reboot=%d armed=%d)\n",
(int)_batch_reboot, (int)_batch_reboot_armed);
if (outcome == WebConfigBatch::ResultOutcome::Pending) {
StaticJsonDocument<96> pending;
pending["state"] = "pending";
pending["reqid"] = (const char*)_batch_reqid;
String out;
serializeJson(pending, out);
req->send(200, "application/json", out);
return;
}
Serial.printf("WC: result read -> done (reboot=%d armed=%d all_ok=%d)\n",
(int)_batch_reboot, (int)_batch_reboot_armed,
(int)_batch_all_ok);
DynamicJsonDocument doc(6144);
doc["state"] = "done";
doc["reboot"] = _batch_reboot;
doc["reboot"] =
WebConfigBatch::doneReportsReboot(_batch_reboot, _batch_all_ok);
doc["all_ok"] = _batch_all_ok;
doc["reqid"] = (const char*)_batch_reqid;
JsonArray results = doc.createNestedArray("results");
for (int i = 0; i < _batch_count; i++) {
JsonObject r = results.createNestedObject();
@@ -893,13 +1040,14 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
r["reply"] = (const char*)_batch[i].reply;
}
// State stays DONE (re-readable) until the next POST claims the slot.
if (_batch_reboot && !_batch_reboot_armed) {
if (WebConfigBatch::shouldArmConfirmReboot(
toSpecState(_batch_state), _batch_reboot,
_batch_all_ok, _batch_reboot_armed)) {
// Confirmation delivered - reboot 3 s from now (replaces the 30 s
// drain-time fallback) so the UI can show its countdown first. Armed
// once; re-reads must not keep pushing the deadline out.
_batch_reboot_armed = true;
_reboot_at = millis() + 3000;
if (_reboot_at == 0) _reboot_at = 1;
_reboot_at = WebConfigBatch::confirmRebootAt(millis());
}
AsyncResponseStream* res = req->beginResponseStream("application/json");
@@ -971,7 +1119,11 @@ void WebConfigServer::handlePresets(AsyncWebServerRequest* req) {
// What the UI must collect for this preset to connect
if (p.topic_style == MQTT_TOPIC_MESHRANK) {
o["needs"] = "token";
} else if (mqttPresetNeedsSlotCredentials(&p)) {
} else if (mqttPresetNeedsSlotUsername(&p) && mqttPresetNeedsSlotPassword(&p)) {
o["needs"] = "userpass";
} else if (mqttPresetNeedsSlotPassword(&p)) {
o["needs"] = "password";
} else if (mqttPresetNeedsSlotUsername(&p)) {
o["needs"] = "userpass";
} else {
o["needs"] = "none";
+28 -6
View File
@@ -26,6 +26,7 @@
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <helpers/WebConfigBatch.h>
class AsyncWebServer;
class AsyncWebServerRequest;
@@ -140,7 +141,7 @@ public:
bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal
bool startLanMode(char reply[]); // bind to existing STA connection
bool startAutoMode(char reply[]); // use saved WiFi, or setup AP when absent
void requestStop(); // stop listening now, free after grace period
void requestStop(); // stop listening and detach this session
void tick(uint32_t now); // call every loop iteration
Mode mode() const { return _mode; }
@@ -148,11 +149,19 @@ public:
bool isStopping() const { return _stopping; }
private:
static const int MAX_BATCH = 24;
static const int MAX_BATCH = WebConfigBatch::kMaxBatch;
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 };
static WebConfigBatch::State toSpecState(BatchState state) {
switch (state) {
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 `set` key (echoed back to the UI)
char key[24]; // allowlisted config key (echoed back to the UI)
char cmd[160]; // full CLI command (may contain secrets - never echoed)
char reply[160];
};
@@ -171,14 +180,18 @@ private:
Mode _mode = MODE_OFF;
bool _stopping = false;
bool _was_setup_ap = false;
bool _initial_setup = false;
uint32_t _connect_deadline = 0;
char _wifi_ssid[32] = {0};
char _wifi_password[64] = {0};
uint8_t _wifi_power_save = 1;
char _ap_ssid[33] = {0};
// Most-recently-created instance, for the display's getSetupInfo() poll.
// Currently attached session, also used by the display's setup-info poll.
static WebConfigServer* _active;
// Process-lifetime listener and route table. Async requests retain their
// server pointer until disconnect, so this object is not deleted on stop.
static AsyncWebServer* _host;
// Command batch: filled by async_tcp under _mux, drained by tick().
volatile BatchState _batch_state = BATCH_IDLE;
@@ -187,6 +200,8 @@ private:
uint32_t _batch_last_cmd = 0;
bool _batch_reboot = false;
bool _batch_reboot_armed = false;
bool _batch_all_ok = true;
char _batch_reqid[24] = {0};
bool _standalone_wifi_dirty = false;
BatchEntry _batch[MAX_BATCH];
@@ -207,14 +222,21 @@ private:
uint32_t _diag_last = 0;
volatile uint32_t _last_activity = 0;
uint32_t _reboot_at = 0; // 0 = none scheduled
uint32_t _delete_at = 0; // deferred teardown deadline
uint32_t _reboot_at = 0; // 0 = none scheduled
uint32_t _stop_warn_at = 0;
bool _stop_warned = false;
uint32_t _handler_refs = 0;
volatile uint32_t _stats_wanted_until = 0;
uint32_t _stats_built_at = 0;
char _stats_json[1024] = {0};
void createServer();
void registerRoutes();
typedef void (WebConfigServer::*RequestHandler)(AsyncWebServerRequest*);
static void dispatchRequest(AsyncWebServerRequest* req, RequestHandler handler);
void attachRoutes();
void detachRoutes();
uint32_t handlerRefCount() const;
void drainBatch(uint32_t now);
void finalizeTeardown();
bool checkAuth(AsyncWebServerRequest* req);
+75
View File
@@ -0,0 +1,75 @@
#pragma once
// A no-hardware stand-in for the LoRa radio, so observer firmware can boot and
// run in an emulator (e.g. Wokwi) that models the ESP32-S3 + WiFi + display but
// has no SX1262. It's a drop-in for the concrete `radio_driver` used by the
// examples: it implements the mesh::Radio interface plus the RadioLibWrapper
// methods MyMesh/main call directly (setParams, setTxPower, getRngSeed, packet
// counters, ...). Transmits "succeed" instantly with no RF; nothing is ever
// received. WiFi/MQTT/CLI/display all run normally on top of it.
//
// Compiled only into *_sim builds (guarded by SIM_BUILD in the target). Never
// pulled into real firmware.
#include <Mesh.h>
#include <stdint.h>
#include <stddef.h>
#if defined(ESP_PLATFORM)
#include <esp_system.h> // esp_random()
#endif
static inline uint32_t _simRandom() {
#if defined(ESP_PLATFORM)
return esp_random();
#else
return (uint32_t)millis() * 2654435761u;
#endif
}
// RNG for creating a LocalIdentity without radio noise (used by radio_new_identity()).
class SimRNG : public mesh::RNG {
public:
void random(uint8_t* dest, size_t sz) override {
for (size_t i = 0; i < sz; i++) dest[i] = (uint8_t)_simRandom();
}
};
class SimRadio : public mesh::Radio {
uint32_t n_recv, n_sent, n_recv_errors;
unsigned long _send_started;
public:
explicit SimRadio(mesh::MainBoard& /*board*/) : n_recv(0), n_sent(0),
n_recv_errors(0), _send_started(0) {}
// --- mesh::Radio pure virtuals ---
void begin() override {}
int recvRaw(uint8_t* /*bytes*/, int /*sz*/) override { return 0; } // never receives
uint32_t getEstAirtimeFor(int len_bytes) override {
return (uint32_t)(len_bytes < 0 ? 0 : len_bytes) * 10 + 10; // rough, non-zero
}
float packetScore(float /*snr*/, int /*packet_len*/) override { return 0.0f; }
bool startSendRaw(const uint8_t* /*bytes*/, int /*len*/) override {
_send_started = millis();
n_sent++;
return true; // "sent" instantly
}
bool isSendComplete() override { return true; }
void onSendFinished() override { _send_started = 0; }
bool isInRecvMode() const override { return true; }
// --- mesh::Radio overrides with useful sim values ---
int getNoiseFloor() const override { return -110; }
uint32_t getPacketsRecvErrors() const override { return n_recv_errors; }
float getLastRSSI() const override { return -80.0f; }
float getLastSNR() const override { return 9.0f; }
// --- concrete RadioLibWrapper surface called directly on radio_driver ---
void setParams(float /*freq*/, float /*bw*/, uint8_t /*sf*/, uint8_t /*cr*/) {}
void setTxPower(int8_t /*dbm*/) {}
uint32_t getRngSeed() { return _simRandom(); }
uint32_t getPacketsRecv() const { return n_recv; }
uint32_t getPacketsSent() const { return n_sent; }
void resetStats() { n_recv = n_sent = n_recv_errors = 0; }
void setRxBoostedGainMode(bool) {}
bool getRxBoostedGainMode() const { return false; }
};
+1 -1
View File
@@ -41,5 +41,5 @@ public:
}
#endif
bool startOTAUpdate(const char* id, char reply[]) override { return false; };
bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override { return false; };
};
+5 -1
View File
@@ -103,7 +103,11 @@
static TFT_eSPI lcd = TFT_eSPI(160, 80);
static uint32_t curr_color;
#if defined(HELTEC_LORA_V3) || defined(HELTEC_TRACKER_V2)
// HELTEC_TRACKER_V1_1 added to upstream's HSPI guard (d30d8ed7): that board uses
// the same ST7735 panel and also needs a dedicated HSPI instance. Without it the
// #else branch references SPI1, which is not instantiated on ESP32, so every
// heltec_tracker_v1_1 env fails to compile.
#if defined(HELTEC_LORA_V3) || defined(HELTEC_TRACKER_V2) || defined(HELTEC_TRACKER_V1_1)
static SPIClass tft_spi(HSPI);
#define _spi (&tft_spi)
#else
+55
View File
@@ -0,0 +1,55 @@
# Host unit tests
Fast, hardware-free unit tests for the fork's pure logic, run on the host with
GoogleTest via PlatformIO's `native` environment. They cover the extractable
observer/WebConfig logic (validation, preset table, topic templates, key
parsing) -- the parts that don't depend on the ESP32, radio, or network stack.
Integration behavior (AsyncTCP transport, WiFi/MQTT, SoftAP) is exercised
separately; see "Local testing without hardware" in `MQTT_IMPLEMENTATION.md`.
## Running
```sh
pio test -e native # all suites
pio test -e native -f test_webconfig_keys # a single suite
```
A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions
passed). PlatformIO's "0 test cases" line is just its Unity-style counter and
does not reflect the GoogleTest count -- run the built binary directly
(`.pio/build/native/program`) to see the per-assertion breakdown.
## Suites
| Suite | Source under test | Covers |
|-------|-------------------|--------|
| `test_mqtt_presets` | `src/helpers/MQTTPresets.h` | preset lookup; table integrity (unique names, non-empty URLs, JWT-audience invariant, names fit the slot buffer); `mqttPresetNeedsSlotCredentials`; slot-count constants |
| `test_observer_validation` | `src/helpers/MQTTObserverValidation.h` | IATA (exactly 3 alphanumerics), owner key (64 hex), NTP hostname, and the buffer-fit check behind the #17 length validation -- including boundaries and nulls |
| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, admin-password classification/validation, slot-index bounds, and the short-key out-of-bounds guard (attacker-supplied keys) |
| `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_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_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_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_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_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 |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
## Conventions (and how to add a suite)
- Each `test/test_<name>/` directory builds into its **own** GoogleTest program
and must define its own `main()` (`::testing::InitGoogleTest` + `RUN_ALL_TESTS`).
- Tests are **host-only**: include only pure headers. Arduino/crypto stubs live
in `test/mocks/` (on the include path via `-I test/mocks`).
- Firmware headers are included from `src` (via `-I src`, e.g.
`#include "helpers/MQTTPresets.h"`). Some are guarded or ESP-flavored, so a
suite may need shims **before** the include -- e.g. `test_mqtt_presets` does
`#define WITH_MQTT_BRIDGE 1` (the preset table is behind that flag) and
`#define PROGMEM` (the embedded CA-cert strings are PROGMEM-qualified).
- To add a suite: create `test/test_<name>/test_<name>.cpp` with a `main()`, and
add any host-only source it links to the `native` env's `build_src_filter` in
`platformio.ini` (header-only code needs no source entry). No other wiring.
- Keep logic testable by extracting pure functions into headers (as
`MQTTObserverValidation.h` / `WebConfigKeys.h` / `MQTTTopicTemplate.h` do) and
having the firmware call the same functions.
@@ -0,0 +1,248 @@
#include <gtest/gtest.h>
#include <stdint.h>
#include <limits>
#include "helpers/MQTTConnectionPolicy.h"
namespace Policy = MQTTConnectionPolicy;
TEST(MQTTConnectionPolicy, ElapsedTimeHandlesNormalAndWrappedClocks) {
EXPECT_EQ(4000U, Policy::elapsedMs(5000U, 1000U));
const uint32_t before_wrap = std::numeric_limits<uint32_t>::max() - 99U;
EXPECT_EQ(150U, Policy::elapsedMs(50U, before_wrap));
}
TEST(MQTTConnectionPolicy, CrossSlotReconnectGuardHasExactBoundary) {
EXPECT_TRUE(Policy::reconnectGuardActive(14999U, 0U));
EXPECT_FALSE(Policy::reconnectGuardActive(15000U, 0U));
const uint32_t last = std::numeric_limits<uint32_t>::max() - 9999U;
EXPECT_TRUE(Policy::reconnectGuardActive(4999U, last));
EXPECT_FALSE(Policy::reconnectGuardActive(5000U, last));
}
TEST(MQTTConnectionPolicy, StableResetRequiresARealStartAndFullWindow) {
EXPECT_FALSE(Policy::stableConnection(500000U, 0U));
EXPECT_FALSE(Policy::stableConnection(120999U, 1000U));
EXPECT_TRUE(Policy::stableConnection(121000U, 1000U));
}
TEST(MQTTConnectionPolicy, StableResetWindowSurvivesMillisRollover) {
const uint32_t connected_at = std::numeric_limits<uint32_t>::max() - 59999U;
EXPECT_FALSE(Policy::stableConnection(59999U, connected_at));
EXPECT_TRUE(Policy::stableConnection(60000U, connected_at));
}
TEST(MQTTConnectionPolicy, BackoffLadderSaturatesAtFiveMinutes) {
EXPECT_EQ(10000U, Policy::reconnectBackoffMs(0));
EXPECT_EQ(30000U, Policy::reconnectBackoffMs(1));
EXPECT_EQ(60000U, Policy::reconnectBackoffMs(2));
EXPECT_EQ(120000U, Policy::reconnectBackoffMs(3));
EXPECT_EQ(300000U, Policy::reconnectBackoffMs(4));
EXPECT_EQ(300000U, Policy::reconnectBackoffMs(5));
EXPECT_EQ(300000U, Policy::reconnectBackoffMs(255));
}
TEST(MQTTConnectionPolicy, LaterSlotsReceiveThreeSecondStagger) {
EXPECT_EQ(10000U, Policy::reconnectDelayMs(0, 0));
EXPECT_EQ(13000U, Policy::reconnectDelayMs(0, 1));
EXPECT_EQ(315000U, Policy::reconnectDelayMs(5, 5));
}
TEST(MQTTConnectionPolicy, ReconnectDueUsesDelayBoundaryAndWrapSafeElapsedTime) {
EXPECT_FALSE(Policy::reconnectDue(12999U, 0U, 0, 1));
EXPECT_TRUE(Policy::reconnectDue(13000U, 0U, 0, 1));
const uint32_t last = std::numeric_limits<uint32_t>::max() - 4999U;
EXPECT_FALSE(Policy::reconnectDue(4999U, last, 0, 0));
EXPECT_TRUE(Policy::reconnectDue(5000U, last, 0, 0));
}
TEST(MQTTConnectionPolicy, BackoffAdvanceClimbsThenCountsFailuresAtMaximum) {
Policy::BackoffAdvance first = Policy::advanceBackoff(0, 0);
EXPECT_EQ(1, first.reconnect_backoff);
EXPECT_EQ(0, first.max_backoff_failures);
EXPECT_FALSE(first.circuit_breaker_tripped);
EXPECT_TRUE(first.should_reconnect);
Policy::BackoffAdvance enters_maximum = Policy::advanceBackoff(4, 0);
EXPECT_EQ(5, enters_maximum.reconnect_backoff);
EXPECT_EQ(0, enters_maximum.max_backoff_failures);
EXPECT_FALSE(enters_maximum.circuit_breaker_tripped);
EXPECT_TRUE(enters_maximum.should_reconnect);
Policy::BackoffAdvance first_max_failure = Policy::advanceBackoff(5, 0);
EXPECT_EQ(5, first_max_failure.reconnect_backoff);
EXPECT_EQ(1, first_max_failure.max_backoff_failures);
EXPECT_FALSE(first_max_failure.circuit_breaker_tripped);
EXPECT_TRUE(first_max_failure.should_reconnect);
}
TEST(MQTTConnectionPolicy, ThirdFailureAtMaximumTripsWithoutAnotherHandshake) {
Policy::BackoffAdvance result = Policy::advanceBackoff(5, 2);
EXPECT_EQ(5, result.reconnect_backoff);
EXPECT_EQ(3, result.max_backoff_failures);
EXPECT_TRUE(result.circuit_breaker_tripped);
EXPECT_FALSE(result.should_reconnect);
}
TEST(MQTTConnectionPolicy, CircuitBreakerProbeHasExactThirtyMinuteBoundary) {
EXPECT_FALSE(Policy::circuitBreakerProbeDue(1799999U, 0U));
EXPECT_TRUE(Policy::circuitBreakerProbeDue(1800000U, 0U));
const uint32_t last = std::numeric_limits<uint32_t>::max() - 899999U;
EXPECT_FALSE(Policy::circuitBreakerProbeDue(899999U, last));
EXPECT_TRUE(Policy::circuitBreakerProbeDue(900000U, last));
}
TEST(MQTTConnectionPolicy, JwtLifetimeUsesCappedPerSlotStagger) {
EXPECT_EQ(86400U, Policy::jwtLifetimeSecs(86400U, 0));
EXPECT_EQ(86100U, Policy::jwtLifetimeSecs(86400U, 1));
EXPECT_EQ(84900U, Policy::jwtLifetimeSecs(86400U, 5));
}
TEST(MQTTConnectionPolicy, ShortJwtLifetimeUsesFivePercentPerSlot) {
EXPECT_EQ(3300U, Policy::jwtLifetimeSecs(3300U, 0));
EXPECT_EQ(3135U, Policy::jwtLifetimeSecs(3300U, 1));
EXPECT_EQ(2970U, Policy::jwtLifetimeSecs(3300U, 2));
EXPECT_EQ(85U, Policy::jwtLifetimeSecs(100U, 3));
}
TEST(MQTTConnectionPolicy, JwtLifetimeCannotUnderflowForUnexpectedSlotInput) {
EXPECT_EQ(0U, Policy::jwtLifetimeSecs(100U, 255));
}
TEST(MQTTConnectionPolicy, RenewalBufferHasOneMinuteFloorAndFiveMinuteCap) {
EXPECT_EQ(60U, Policy::renewalBufferSecs(0U));
EXPECT_EQ(60U, Policy::renewalBufferSecs(599U));
EXPECT_EQ(60U, Policy::renewalBufferSecs(600U));
EXPECT_EQ(299U, Policy::renewalBufferSecs(2999U));
EXPECT_EQ(300U, Policy::renewalBufferSecs(3000U));
EXPECT_EQ(300U, Policy::renewalBufferSecs(86400U));
}
TEST(MQTTConnectionPolicy, UnsynchronizedClockOnlyCreatesAMissingToken) {
EXPECT_TRUE(Policy::tokenNeedsRenewal(false, 0U, 0U, 300U));
EXPECT_FALSE(Policy::tokenNeedsRenewal(false, 0U, 1735693200U, 300U));
}
TEST(MQTTConnectionPolicy, SyncedClockRenewsInvalidExpiredOrImminentTokens) {
const uint32_t expires = 1735693200U;
EXPECT_TRUE(Policy::tokenNeedsRenewal(true, 1735689000U, 0U, 300U));
EXPECT_TRUE(Policy::tokenNeedsRenewal(true, 1735689000U, 999999999U, 300U));
EXPECT_FALSE(Policy::tokenNeedsRenewal(true, expires - 301U, expires, 300U));
EXPECT_TRUE(Policy::tokenNeedsRenewal(true, expires - 300U, expires, 300U));
EXPECT_TRUE(Policy::tokenNeedsRenewal(true, expires, expires, 300U));
EXPECT_TRUE(Policy::tokenNeedsRenewal(true, expires + 1U, expires, 300U));
}
TEST(MQTTConnectionPolicy, RenewalThrottleHasExactBoundaryAndHandlesRollover) {
EXPECT_FALSE(Policy::renewalAttemptAllowed(59999U, 0U));
EXPECT_TRUE(Policy::renewalAttemptAllowed(60000U, 0U));
const uint32_t last = std::numeric_limits<uint32_t>::max() - 29999U;
EXPECT_FALSE(Policy::renewalAttemptAllowed(29999U, last));
EXPECT_TRUE(Policy::renewalAttemptAllowed(30000U, last));
}
TEST(MQTTConnectionPolicy, JwtClockNeedsNtpOrAReasonableWallClock) {
EXPECT_FALSE(Policy::jwtClockAvailable(false, Policy::kJwtClockThreshold - 1U));
EXPECT_TRUE(Policy::jwtClockAvailable(false, Policy::kJwtClockThreshold));
EXPECT_TRUE(Policy::jwtClockAvailable(true, 0U));
}
TEST(MQTTConnectionPolicy, WifiBackoffLadderStartsAtFifteenSecondsAndSaturates) {
EXPECT_EQ(15000U, Policy::wifiReconnectBackoffMs(0));
EXPECT_EQ(30000U, Policy::wifiReconnectBackoffMs(1));
EXPECT_EQ(60000U, Policy::wifiReconnectBackoffMs(2));
EXPECT_EQ(120000U, Policy::wifiReconnectBackoffMs(3));
EXPECT_EQ(300000U, Policy::wifiReconnectBackoffMs(4));
// Clamps at the 300 s rung for the saturated attempt count and beyond.
EXPECT_EQ(300000U, Policy::wifiReconnectBackoffMs(5));
EXPECT_EQ(300000U, Policy::wifiReconnectBackoffMs(200));
}
TEST(MQTTConnectionPolicy, WifiBackoffAttemptClimbsThenSaturatesAtFive) {
uint8_t attempt = 0;
for (uint8_t expected = 1; expected <= 5; ++expected) {
attempt = Policy::nextWifiBackoffAttempt(attempt);
EXPECT_EQ(expected, attempt);
}
// Saturated: never advances past 5 (index stays clamped at the 300 s rung).
EXPECT_EQ(5U, Policy::nextWifiBackoffAttempt(attempt));
EXPECT_EQ(5U, Policy::nextWifiBackoffAttempt(5));
}
TEST(MQTTConnectionPolicy, WifiReconnectRequiresBothDownAndSinceAttemptToClearRung) {
const uint32_t down_since = 1000U;
const uint32_t last_attempt = 1000U;
const uint8_t attempt = 0; // 15 s rung
// Neither interval has elapsed yet.
EXPECT_FALSE(Policy::wifiReconnectDue(1000U + 14999U, down_since, last_attempt, attempt));
// Down long enough, but an attempt was made only 5 s ago (since-attempt short).
EXPECT_FALSE(Policy::wifiReconnectDue(1000U + 15000U, down_since, 1000U + 10000U, attempt));
// Both cleared at the exact boundary: due.
EXPECT_TRUE(Policy::wifiReconnectDue(1000U + 15000U, down_since, last_attempt, attempt));
}
TEST(MQTTConnectionPolicy, WifiReconnectDueSurvivesMillisRollover) {
const uint32_t down_since = std::numeric_limits<uint32_t>::max() - 100U;
const uint32_t last_attempt = down_since;
const uint8_t attempt = 0; // 15 s rung
const uint32_t now = down_since + 15000U; // wraps past zero
EXPECT_TRUE(Policy::wifiReconnectDue(now, down_since, last_attempt, attempt));
EXPECT_FALSE(Policy::wifiReconnectDue(down_since + 14999U, down_since, last_attempt, attempt));
}
// --- classifySlotActivation: the "will this slot connect on this hardware" rule
// the CLI uses to warn at `set mqttN.preset` time. Non-PSRAM Heltec V3 is the
// motivating case: RUNTIME_MQTT_SLOTS=3 but only 2 concurrent connections. ---
using Policy::SlotActivation;
TEST(SlotActivation, NonPsramV3FirstTwoConnectThirdIsOverCap) {
// slot_count = 3 (runtime array), max_active = 2 (concurrent cap).
const bool enabled[6] = {true, true, true, false, false, false};
EXPECT_EQ(SlotActivation::Connects, Policy::classifySlotActivation(0, enabled, 3, 2));
EXPECT_EQ(SlotActivation::Connects, Policy::classifySlotActivation(1, enabled, 3, 2));
// slot 3 (index 2) is the case in the field log: iterated, but skipped.
EXPECT_EQ(SlotActivation::OverActiveCap, Policy::classifySlotActivation(2, enabled, 3, 2));
}
TEST(SlotActivation, NonPsramSlotsBeyondRuntimeArrayAreNeverIterated) {
const bool enabled[6] = {true, true, true, true, false, false};
// mqtt4/5/6 (index 3-5) are outside the 3-slot runtime array on non-PSRAM.
EXPECT_EQ(SlotActivation::BeyondArray, Policy::classifySlotActivation(3, enabled, 3, 2));
EXPECT_EQ(SlotActivation::BeyondArray, Policy::classifySlotActivation(5, enabled, 3, 2));
}
TEST(SlotActivation, RankCountsOnlyEnabledLowerSlots) {
// Only slot index 2 enabled (1 and 2 disabled): it is the first enabled slot,
// so it connects even though its index equals the cap.
const bool enabled[6] = {false, false, true, false, false, false};
EXPECT_EQ(SlotActivation::Connects, Policy::classifySlotActivation(2, enabled, 3, 2));
// Gap in the middle: slots 0 and 2 enabled, slot 1 off. Slot 2 is rank 2 <= 2.
const bool enabled2[6] = {true, false, true, false, false, false};
EXPECT_EQ(SlotActivation::Connects, Policy::classifySlotActivation(2, enabled2, 3, 2));
}
TEST(SlotActivation, PsramFiveOfSixConnect) {
// PSRAM: slot_count = 6, max_active = 5. Sixth enabled slot is over the cap.
const bool enabled[6] = {true, true, true, true, true, true};
EXPECT_EQ(SlotActivation::Connects, Policy::classifySlotActivation(4, enabled, 6, 5));
EXPECT_EQ(SlotActivation::OverActiveCap, Policy::classifySlotActivation(5, enabled, 6, 5));
}
TEST(SlotActivation, DisabledAndOutOfRangeSlots) {
const bool enabled[6] = {true, false, true, false, false, false};
EXPECT_EQ(SlotActivation::Disabled, Policy::classifySlotActivation(1, enabled, 3, 2));
EXPECT_EQ(SlotActivation::Disabled, Policy::classifySlotActivation(-1, enabled, 3, 2));
EXPECT_EQ(SlotActivation::Disabled, Policy::classifySlotActivation(0, nullptr, 3, 2));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,450 @@
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "helpers/MQTTLifecycle.h"
// Phase 4 teardown-focused test seam for the MQTT bridge lifecycle. Every case
// below maps to a bullet in STABILITY_TESTABILITY_HANDOFF.md's "Required
// teardown-focused tests" list or its "OTA Teardown Barrier" scenarios. The
// behavior encoded here is derived from the current MQTTBridge control flow
// (Phase 0 "derive from code + flag" discipline); it is the contract Phase 5
// must preserve when it wires the real bridge into MQTTLifecycle::Coordinator.
namespace L = MQTTLifecycle;
namespace {
// Recording Ops double with a settable clock and an ordered call log.
struct FakeOps : public L::Ops {
uint32_t now = 0;
int start_task_calls = 0;
int deliver_stop_calls = 0;
int release_calls = 0;
int stop_complete_calls = 0;
bool last_stop_clean = false;
std::vector<std::string> log;
uint32_t nowMs() override { return now; }
void startTask() override {
start_task_calls++;
log.push_back("startTask");
}
void deliverStop() override {
deliver_stop_calls++;
log.push_back("deliverStop");
}
void releaseResources() override {
release_calls++;
log.push_back("release");
}
void onStopComplete(bool clean) override {
stop_complete_calls++;
last_stop_clean = clean;
log.push_back(clean ? "otaClean" : "otaDirty");
}
};
const uint32_t kStopTimeoutMs = 5000;
void bringUpToRunning(L::Coordinator& c) {
ASSERT_TRUE(c.requestStart());
ASSERT_EQ(L::State::Starting, c.state());
ASSERT_TRUE(c.onTaskStarted());
ASSERT_EQ(L::State::Running, c.state());
}
} // namespace
// --- Normal ordering -------------------------------------------------------
// Handoff Phase 0: "Normal begin() -> connect -> end() ordering."
TEST(MQTTLifecycle, NormalStartRunStopCycle) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
EXPECT_EQ(L::State::Stopped, c.state());
ASSERT_TRUE(c.requestStart());
EXPECT_EQ(L::State::Starting, c.state());
ASSERT_TRUE(c.onTaskStarted());
EXPECT_EQ(L::State::Running, c.state());
ASSERT_TRUE(c.requestStop());
EXPECT_EQ(L::State::StopRequested, c.state());
ASSERT_TRUE(c.onTaskStopped());
EXPECT_EQ(L::State::Stopped, c.state());
// create_task, then deliver_stop, then a single post-ack resource release
// and a clean OTA-barrier signal -- in that order.
const std::vector<std::string> expected = {"startTask", "deliverStop",
"release", "otaClean"};
EXPECT_EQ(expected, ops.log);
EXPECT_EQ(1, ops.start_task_calls);
EXPECT_EQ(1, ops.deliver_stop_calls);
EXPECT_EQ(1, ops.release_calls);
EXPECT_TRUE(ops.last_stop_clean);
}
// --- Idempotency: "Duplicate stop", "Idempotent start and stop requests" ---
TEST(MQTTLifecycle, StartIsIdempotent) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
ASSERT_TRUE(c.requestStart());
// Second start while Starting is a no-op -- no extra task creation.
EXPECT_FALSE(c.requestStart());
ASSERT_TRUE(c.onTaskStarted());
// And a no-op while Running.
EXPECT_FALSE(c.requestStart());
EXPECT_EQ(1, ops.start_task_calls);
}
TEST(MQTTLifecycle, DuplicateStopIsIdempotent) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
bringUpToRunning(c);
ASSERT_TRUE(c.requestStop());
// Duplicate stop requests do not re-deliver the stop.
EXPECT_FALSE(c.requestStop());
EXPECT_FALSE(c.requestStop());
EXPECT_EQ(1, ops.deliver_stop_calls);
ASSERT_TRUE(c.onTaskStopped());
// A stop while already Stopped is also a no-op.
EXPECT_FALSE(c.requestStop());
EXPECT_EQ(1, ops.release_calls);
}
// --- Partial / early lifecycle --------------------------------------------
// Handoff: "stop before full initialization".
TEST(MQTTLifecycle, StopBeforeFullInitialization) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
ASSERT_TRUE(c.requestStart());
EXPECT_EQ(L::State::Starting, c.state());
// Stop arrives before StartCompleted.
ASSERT_TRUE(c.requestStop());
EXPECT_EQ(L::State::StopRequested, c.state());
EXPECT_EQ(1, ops.deliver_stop_calls);
// No resources released until the task acknowledges.
EXPECT_EQ(0, ops.release_calls);
ASSERT_TRUE(c.onTaskStopped());
EXPECT_EQ(L::State::Stopped, c.state());
EXPECT_EQ(1, ops.release_calls);
}
// Handoff Phase 2/Phase 0: "Partial initialization failures ... releases only
// resources owned by that attempt." The rollback releases, but no OTA-barrier
// completion is signalled for a failed start.
TEST(MQTTLifecycle, StartFailureRollsBackResources) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
ASSERT_TRUE(c.requestStart());
ASSERT_TRUE(c.onTaskStartFailed());
EXPECT_EQ(L::State::Stopped, c.state());
EXPECT_EQ(1, ops.release_calls);
EXPECT_EQ(0, ops.stop_complete_calls);
// Recoverable: a subsequent start is accepted.
EXPECT_TRUE(c.mayRestart());
EXPECT_TRUE(c.requestStart());
EXPECT_EQ(2, ops.start_task_calls);
}
// Handoff: "restart after stop".
TEST(MQTTLifecycle, RestartAfterStop) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
bringUpToRunning(c);
ASSERT_TRUE(c.requestStop());
ASSERT_TRUE(c.onTaskStopped());
EXPECT_TRUE(c.mayRestart());
ASSERT_TRUE(c.requestStart());
EXPECT_EQ(L::State::Starting, c.state());
ASSERT_TRUE(c.onTaskStarted());
EXPECT_EQ(L::State::Running, c.state());
EXPECT_EQ(2, ops.start_task_calls);
}
// --- Timeout / fallback ----------------------------------------------------
// Handoff: "Timeout/fallback behavior when the MQTT task or client does not
// acknowledge." Models the reviewed fallback replacing the abrupt vTaskDelete.
TEST(MQTTLifecycle, StopTimeoutFiresReviewedFallback) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
bringUpToRunning(c);
ops.now = 1000;
ASSERT_TRUE(c.requestStop());
EXPECT_EQ(L::State::StopRequested, c.state());
// Before the deadline, tick() is inert.
ops.now = 1000 + kStopTimeoutMs - 1;
c.tick();
EXPECT_EQ(L::State::StopRequested, c.state());
EXPECT_EQ(0, ops.release_calls);
// At the deadline the fallback fires: forced release, dirty OTA signal.
ops.now = 1000 + kStopTimeoutMs;
c.tick();
EXPECT_EQ(L::State::Stopped, c.state());
EXPECT_TRUE(c.stopTimedOut());
EXPECT_EQ(1, ops.release_calls);
EXPECT_EQ(1, ops.stop_complete_calls);
EXPECT_FALSE(ops.last_stop_clean);
// A late ack after the fallback does not double-release.
EXPECT_FALSE(c.onTaskStopped());
EXPECT_EQ(1, ops.release_calls);
}
TEST(MQTTLifecycle, TickWithoutPendingStopIsNoop) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
bringUpToRunning(c);
ops.now = 1'000'000; // far past any deadline
c.tick();
EXPECT_EQ(L::State::Running, c.state());
EXPECT_EQ(0, ops.release_calls);
}
TEST(MQTTLifecycle, AckBeforeTimeoutPreemptsFallback) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
bringUpToRunning(c);
ops.now = 100;
ASSERT_TRUE(c.requestStop());
ops.now = 100 + kStopTimeoutMs / 2;
ASSERT_TRUE(c.onTaskStopped());
EXPECT_TRUE(ops.last_stop_clean);
EXPECT_FALSE(c.stopTimedOut());
// A later tick past the original deadline must not fire a second time.
ops.now = 100 + kStopTimeoutMs * 10;
c.tick();
EXPECT_EQ(1, ops.release_calls);
EXPECT_EQ(1, ops.stop_complete_calls);
}
// --- Ownership: no access after release ------------------------------------
// Handoff: "No client, queue, buffer, or task access after its owner releases
// it," and "A completion acknowledgment before the loop task releases queues,
// buffers, or other shared resources."
TEST(MQTTLifecycle, ResourcesReleasedOnlyAfterAcknowledgment) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
bringUpToRunning(c);
EXPECT_TRUE(c.mayTouchOwnedState());
ASSERT_TRUE(c.requestStop());
// Still safe to touch owned state -- resources are not yet released.
EXPECT_TRUE(c.mayTouchOwnedState());
EXPECT_EQ(0, ops.release_calls);
ASSERT_TRUE(c.onStopBegan());
EXPECT_EQ(L::State::Stopping, c.state());
EXPECT_TRUE(c.mayTouchOwnedState());
EXPECT_EQ(0, ops.release_calls);
ASSERT_TRUE(c.onTaskStopped());
// Owner has released; nothing may touch owned state now.
EXPECT_FALSE(c.mayTouchOwnedState());
EXPECT_EQ(1, ops.release_calls);
}
// Handoff: "Cessation of new connects, publishes, retries, and
// reconfigurations" once a stop is requested.
TEST(MQTTLifecycle, NewWorkCeasesOnceStopRequested) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
EXPECT_FALSE(c.acceptsNewWork()); // Stopped
ASSERT_TRUE(c.requestStart());
EXPECT_TRUE(c.acceptsNewWork()); // Starting
ASSERT_TRUE(c.onTaskStarted());
EXPECT_TRUE(c.acceptsNewWork()); // Running
ASSERT_TRUE(c.requestStop());
EXPECT_FALSE(c.acceptsNewWork()); // StopRequested
ASSERT_TRUE(c.onStopBegan());
EXPECT_FALSE(c.acceptsNewWork()); // Stopping
ASSERT_TRUE(c.onTaskStopped());
EXPECT_FALSE(c.acceptsNewWork()); // Stopped
}
// --- Stop while doing X (the activity matrix) ------------------------------
// Handoff: "Stop while connecting, connected, publishing, retrying, renewing a
// token, running NTP, and applying a slot change." Each activity resolves to
// the same ownership contract: the stop is delivered, new work ceases, and
// resources survive until the task acknowledges. "connecting" happens during
// Starting (before StartCompleted); the rest are Running-state activities.
TEST(MQTTLifecycle, StopDuringEveryActivityHonorsTheContract) {
struct Activity {
const char* label;
bool running; // true => Running-state activity, false => Starting
};
const Activity activities[] = {
{"connecting", false}, {"connected", true},
{"publishing", true}, {"retrying", true},
{"renewingToken", true}, {"runningNtp", true},
{"applyingSlotChange", true},
};
for (const auto& a : activities) {
SCOPED_TRACE(a.label);
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
ASSERT_TRUE(c.requestStart());
if (a.running) {
ASSERT_TRUE(c.onTaskStarted());
ASSERT_EQ(L::State::Running, c.state());
} else {
ASSERT_EQ(L::State::Starting, c.state());
}
ASSERT_TRUE(c.requestStop());
EXPECT_EQ(1, ops.deliver_stop_calls);
EXPECT_FALSE(c.acceptsNewWork());
EXPECT_EQ(0, ops.release_calls); // not until ack
ASSERT_TRUE(c.onTaskStopped());
EXPECT_EQ(L::State::Stopped, c.state());
EXPECT_EQ(1, ops.release_calls);
EXPECT_TRUE(ops.last_stop_clean);
}
}
// --- Callback delivery timing ----------------------------------------------
// Handoff: "Callback delivered before stop, during stop, after disconnect, and
// after the stop acknowledgment." A callback consults mayTouchOwnedState()
// before touching owned state; only the post-acknowledgment callback is a
// no-op. ("after disconnect" is modeled as StopBegan -> Stopping, i.e. the
// task tearing the client down.)
TEST(MQTTLifecycle, CallbackGuardTracksResourceOwnership) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
bringUpToRunning(c);
EXPECT_TRUE(c.mayTouchOwnedState()); // callback before stop
ASSERT_TRUE(c.requestStop());
EXPECT_TRUE(c.mayTouchOwnedState()); // callback during stop (pre-teardown)
ASSERT_TRUE(c.onStopBegan());
EXPECT_TRUE(c.mayTouchOwnedState()); // callback after disconnect begins
ASSERT_TRUE(c.onTaskStopped());
EXPECT_FALSE(c.mayTouchOwnedState()); // callback after stop-ack => no-op
}
// --- OTA teardown barrier (release-critical) -------------------------------
// Handoff OTA barrier: "firmware erase/write must not begin until MQTT shutdown
// has reached a safe acknowledgment point."
TEST(MQTTLifecycle, OtaFlashBlockedUntilCleanStopAcknowledged) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
bringUpToRunning(c);
EXPECT_FALSE(c.mayBeginFlash()); // Running
ASSERT_TRUE(c.requestStop());
EXPECT_FALSE(c.mayBeginFlash()); // StopRequested -- not yet safe
ASSERT_TRUE(c.onStopBegan());
EXPECT_FALSE(c.mayBeginFlash()); // Stopping -- still not safe
ASSERT_TRUE(c.onTaskStopped());
EXPECT_TRUE(c.mayBeginFlash()); // clean stop => flashing permitted
EXPECT_TRUE(ops.last_stop_clean);
}
// Handoff OTA barrier: "MQTT stop times out: OTA aborts safely rather than
// writing under uncertain ownership."
TEST(MQTTLifecycle, OtaAbortsWhenStopTimesOut) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
bringUpToRunning(c);
ops.now = 500;
ASSERT_TRUE(c.requestStop());
ops.now = 500 + kStopTimeoutMs;
c.tick();
EXPECT_EQ(L::State::Stopped, c.state());
EXPECT_FALSE(c.mayBeginFlash()); // dirty stop => flashing withheld
EXPECT_FALSE(ops.last_stop_clean);
// A fresh clean start/stop cycle clears the latch and re-enables flashing.
ASSERT_TRUE(c.requestStart());
EXPECT_FALSE(c.stopTimedOut());
ASSERT_TRUE(c.onTaskStarted());
ASSERT_TRUE(c.requestStop());
ASSERT_TRUE(c.onTaskStopped());
EXPECT_TRUE(c.mayBeginFlash());
}
// Handoff OTA barrier: "the bridge must not be restarted while flash writing is
// active" -- expressed here as: no restart while a stop is in progress.
TEST(MQTTLifecycle, NoRestartWhileStopInProgress) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
bringUpToRunning(c);
ASSERT_TRUE(c.requestStop());
EXPECT_FALSE(c.mayRestart());
EXPECT_FALSE(c.requestStart()); // rejected while StopRequested
ASSERT_TRUE(c.onStopBegan());
EXPECT_FALSE(c.mayRestart());
EXPECT_FALSE(c.requestStart()); // rejected while Stopping
ASSERT_TRUE(c.onTaskStopped());
EXPECT_TRUE(c.mayRestart());
EXPECT_TRUE(c.requestStart());
}
// Handoff OTA barrier: "Repeated failed OTA attempts do not ... leave the
// bridge permanently stopped."
TEST(MQTTLifecycle, RepeatedFailedStopsLeaveBridgeRestartable) {
FakeOps ops;
L::Coordinator c(ops, kStopTimeoutMs);
for (int attempt = 0; attempt < 3; ++attempt) {
SCOPED_TRACE(attempt);
ASSERT_TRUE(c.requestStart());
ASSERT_TRUE(c.onTaskStarted());
ops.now += 1000;
ASSERT_TRUE(c.requestStop());
ops.now += kStopTimeoutMs;
c.tick(); // times out (dirty)
EXPECT_EQ(L::State::Stopped, c.state());
EXPECT_TRUE(c.mayRestart()); // never permanently stuck
}
EXPECT_EQ(3, ops.start_task_calls);
}
// --- Diagnostics -----------------------------------------------------------
TEST(MQTTLifecycle, StateAndEventNamesAreStable) {
EXPECT_STREQ("Stopped", L::stateName(L::State::Stopped));
EXPECT_STREQ("Running", L::stateName(L::State::Running));
EXPECT_STREQ("StopRequested", L::stateName(L::State::StopRequested));
EXPECT_STREQ("StartRequested", L::eventName(L::Event::StartRequested));
EXPECT_STREQ("StopTimedOut", L::eventName(L::Event::StopTimedOut));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,180 @@
#include <gtest/gtest.h>
#include <stdint.h>
#include "helpers/MQTTPacketQueuePolicy.h"
namespace QueuePolicy = MQTTPacketQueuePolicy;
TEST(MQTTPacketQueuePolicy, EnqueuesWhileCapacityRemains) {
EXPECT_EQ(QueuePolicy::EnqueueAction::Enqueue,
QueuePolicy::enqueueAction(0, 6));
EXPECT_EQ(QueuePolicy::EnqueueAction::Enqueue,
QueuePolicy::enqueueAction(5, 6));
}
TEST(MQTTPacketQueuePolicy, EvictsOldestAtOrAboveCapacity) {
EXPECT_EQ(QueuePolicy::EnqueueAction::EvictOldestThenEnqueue,
QueuePolicy::enqueueAction(6, 6));
EXPECT_EQ(QueuePolicy::EnqueueAction::EvictOldestThenEnqueue,
QueuePolicy::enqueueAction(7, 6));
}
TEST(MQTTPacketQueuePolicy, RejectsQueueWithZeroCapacity) {
EXPECT_EQ(QueuePolicy::EnqueueAction::Reject,
QueuePolicy::enqueueAction(0, 0));
}
TEST(MQTTPacketQueuePolicy, DisconnectedQueueFlushesAtExactStaleBoundary) {
const uint32_t started = 1000;
EXPECT_FALSE(QueuePolicy::shouldFlushDisconnected(
started + QueuePolicy::kDisconnectedStaleMs - 1, started));
EXPECT_TRUE(QueuePolicy::shouldFlushDisconnected(
started + QueuePolicy::kDisconnectedStaleMs, started));
EXPECT_TRUE(QueuePolicy::shouldFlushDisconnected(
started + QueuePolicy::kDisconnectedStaleMs + 1, started));
}
TEST(MQTTPacketQueuePolicy, ZeroDisconnectedTimestampMeansNotStarted) {
EXPECT_FALSE(QueuePolicy::shouldFlushDisconnected(UINT32_MAX, 0));
}
TEST(MQTTPacketQueuePolicy, DisconnectedStaleTimerSurvivesMillisWrap) {
const uint32_t started = UINT32_MAX - 100;
EXPECT_FALSE(QueuePolicy::shouldFlushDisconnected(198, started, 300));
EXPECT_TRUE(QueuePolicy::shouldFlushDisconnected(199, started, 300));
}
TEST(MQTTPacketQueuePolicy, DrainIsGentleThroughFivePackets) {
for (size_t count = 0; count <= QueuePolicy::kBacklogThreshold; ++count) {
const QueuePolicy::DrainBudget budget = QueuePolicy::drainBudget(count);
EXPECT_EQ(QueuePolicy::kGentleDrainCount, budget.max_packets) << count;
EXPECT_EQ(QueuePolicy::kGentleDrainBudgetMs, budget.max_time_ms) << count;
}
}
TEST(MQTTPacketQueuePolicy, DrainBurstsAboveFivePackets) {
const QueuePolicy::DrainBudget budget =
QueuePolicy::drainBudget(QueuePolicy::kBacklogThreshold + 1);
EXPECT_EQ(QueuePolicy::kBurstDrainCount, budget.max_packets);
EXPECT_EQ(QueuePolicy::kBurstDrainBudgetMs, budget.max_time_ms);
}
TEST(MQTTPacketQueuePolicy, DrainTimeBudgetUsesInclusiveBoundary) {
EXPECT_TRUE(QueuePolicy::drainTimeAvailable(129, 100, 30));
EXPECT_TRUE(QueuePolicy::drainTimeAvailable(130, 100, 30));
EXPECT_FALSE(QueuePolicy::drainTimeAvailable(131, 100, 30));
}
TEST(MQTTPacketQueuePolicy, DrainTimeBudgetSurvivesMillisWrap) {
const uint32_t started = UINT32_MAX - 10;
EXPECT_TRUE(QueuePolicy::drainTimeAvailable(19, started, 30));
EXPECT_FALSE(QueuePolicy::drainTimeAvailable(20, started, 30));
}
TEST(MQTTPacketQueuePolicy, NewPacketIsReadyWithoutRetryDeadline) {
EXPECT_TRUE(QueuePolicy::retryReady(100, 0, 0));
EXPECT_TRUE(QueuePolicy::retryReady(100, 500, 0));
}
TEST(MQTTPacketQueuePolicy, RetryBecomesReadyAtExactDeadline) {
EXPECT_FALSE(QueuePolicy::retryReady(499, 500, 1));
EXPECT_TRUE(QueuePolicy::retryReady(500, 500, 1));
EXPECT_TRUE(QueuePolicy::retryReady(501, 500, 1));
}
TEST(MQTTPacketQueuePolicy, RetryDeadlineSurvivesMillisWrap) {
const uint32_t deadline = 100;
EXPECT_FALSE(QueuePolicy::retryReady(UINT32_MAX - 50, deadline, 1));
EXPECT_FALSE(QueuePolicy::retryReady(99, deadline, 1));
EXPECT_TRUE(QueuePolicy::retryReady(100, deadline, 1));
}
TEST(MQTTPacketQueuePolicy, WrappedZeroIsARealRetryDeadline) {
EXPECT_FALSE(QueuePolicy::retryReady(UINT32_MAX, 0, 1));
EXPECT_TRUE(QueuePolicy::retryReady(0, 0, 1));
}
TEST(MQTTPacketQueuePolicy, SuccessfulPublishCompletesWithoutChangingAttempts) {
const QueuePolicy::RetryDecision decision =
QueuePolicy::retryDecision(true, 2, 1234);
EXPECT_EQ(QueuePolicy::RetryAction::Complete, decision.action);
EXPECT_EQ(2, decision.retry_attempts);
EXPECT_EQ(0U, decision.delay_ms);
EXPECT_EQ(0U, decision.next_retry_ms);
}
TEST(MQTTPacketQueuePolicy, FailedPublishSchedulesBoundedRetry) {
const QueuePolicy::RetryDecision minimum =
QueuePolicy::retryDecision(false, 0, 400);
EXPECT_EQ(QueuePolicy::RetryAction::Schedule, minimum.action);
EXPECT_EQ(1, minimum.retry_attempts);
EXPECT_EQ(300U, minimum.delay_ms);
EXPECT_EQ(700U, minimum.next_retry_ms);
const QueuePolicy::RetryDecision maximum =
QueuePolicy::retryDecision(false, 1, 599);
EXPECT_EQ(QueuePolicy::RetryAction::Schedule, maximum.action);
EXPECT_EQ(2, maximum.retry_attempts);
EXPECT_EQ(499U, maximum.delay_ms);
EXPECT_EQ(1098U, maximum.next_retry_ms);
}
TEST(MQTTPacketQueuePolicy, ThirdFailedPublishSchedulesFinalRetry) {
const QueuePolicy::RetryDecision decision =
QueuePolicy::retryDecision(false, 2, 1000);
EXPECT_EQ(QueuePolicy::RetryAction::Schedule, decision.action);
EXPECT_EQ(QueuePolicy::kMaxQos0RetryAttempts, decision.retry_attempts);
}
TEST(MQTTPacketQueuePolicy, FailureAfterFinalRetryDropsPacket) {
const QueuePolicy::RetryDecision decision =
QueuePolicy::retryDecision(false, QueuePolicy::kMaxQos0RetryAttempts, 1000);
EXPECT_EQ(QueuePolicy::RetryAction::Drop, decision.action);
EXPECT_EQ(QueuePolicy::kMaxQos0RetryAttempts, decision.retry_attempts);
EXPECT_EQ(0U, decision.delay_ms);
EXPECT_EQ(0U, decision.next_retry_ms);
}
TEST(MQTTPacketQueuePolicy, RetrySchedulingDeadlineMayWrapToZero) {
// This timestamp has jitter 98, so its 398 ms delay wraps to exactly zero.
const uint32_t now = UINT32_MAX - 397;
ASSERT_EQ(98U, now % QueuePolicy::kRetryDelayJitterMs);
const QueuePolicy::RetryDecision decision =
QueuePolicy::retryDecision(false, 0, now);
EXPECT_EQ(QueuePolicy::RetryAction::Schedule, decision.action);
EXPECT_EQ(398U, decision.delay_ms);
EXPECT_EQ(0U, decision.next_retry_ms);
EXPECT_FALSE(QueuePolicy::retryReady(UINT32_MAX, decision.next_retry_ms,
decision.retry_attempts));
EXPECT_TRUE(QueuePolicy::retryReady(0, decision.next_retry_ms,
decision.retry_attempts));
}
TEST(MQTTPacketQueuePolicy, PartialPublishCountsAsDeliveredEitherWay) {
EXPECT_TRUE(QueuePolicy::queuedPacketPublished(true, true));
EXPECT_TRUE(QueuePolicy::queuedPacketPublished(true, false)); // packet ok, raw failed
EXPECT_TRUE(QueuePolicy::queuedPacketPublished(false, true)); // raw ok, packet failed
EXPECT_FALSE(QueuePolicy::queuedPacketPublished(false, false)); // neither reached a slot
}
TEST(MQTTPacketQueuePolicy, PublishOutcomePairingDrivesRetryDecision) {
// packet succeeds / raw fails -> completed, no retry.
QueuePolicy::RetryDecision d =
QueuePolicy::retryDecision(QueuePolicy::queuedPacketPublished(true, false), 0, 1234U);
EXPECT_EQ(QueuePolicy::RetryAction::Complete, d.action);
// raw succeeds / packet fails -> also completed.
d = QueuePolicy::retryDecision(QueuePolicy::queuedPacketPublished(false, true), 0, 1234U);
EXPECT_EQ(QueuePolicy::RetryAction::Complete, d.action);
// both fail on a fresh packet -> scheduled for a bounded retry.
d = QueuePolicy::retryDecision(QueuePolicy::queuedPacketPublished(false, false), 0, 1234U);
EXPECT_EQ(QueuePolicy::RetryAction::Schedule, d.action);
EXPECT_EQ(1U, d.retry_attempts);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,316 @@
#include <gtest/gtest.h>
#include <ArduinoJson.h>
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
#include "helpers/MQTTPayloadBuilder.h"
namespace {
constexpr const char* kTimestamp = "2026-07-18T12:34:56.123456+00:00";
static int buildMinimalStatus(JsonDocument& scratch, char* buffer, size_t buffer_size) {
return MQTTPayloadBuilder::buildStatusMessage(
scratch, "DEN Repeater", "0123456789ABCDEF", "Heltec V3", "v1.16.0",
"915.000000,62.5,7,5", "MeshCore", "online", kTimestamp,
buffer, buffer_size);
}
static int buildRepresentativePacket(JsonDocument& scratch, const char* direction,
float score, const uint8_t* path, int path_hops,
int path_hash_size, const char* raw,
char* buffer, size_t buffer_size) {
return MQTTPayloadBuilder::buildPacketMessage(
scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, direction,
"12:34:56", "18/07/2026", 42, 4, "D", 20, raw,
10.26f, -87, score, "89ABCDEF01234567", path, path_hops,
path_hash_size, 64, buffer, buffer_size);
}
TEST(MQTTPayloadBuilder, MinimalStatusHasExactRequiredContract) {
JsonDocument scratch;
char buffer[768];
int len = buildMinimalStatus(scratch, buffer, sizeof(buffer));
ASSERT_GT(len, 0);
EXPECT_EQ(static_cast<size_t>(len), strlen(buffer));
EXPECT_STREQ(
"{\"status\":\"online\",\"timestamp\":\"2026-07-18T12:34:56.123456+00:00\","
"\"origin\":\"DEN Repeater\",\"origin_id\":\"0123456789ABCDEF\","
"\"model\":\"Heltec V3\",\"firmware_version\":\"v1.16.0\","
"\"radio\":\"915.000000,62.5,7,5\",\"client_version\":\"MeshCore\"}",
buffer);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_FALSE(parsed["repeat"].is<JsonVariant>());
EXPECT_FALSE(parsed["stats"].is<JsonVariant>());
}
TEST(MQTTPayloadBuilder, StatusIncludesRepeatAndEveryRequestedStatistic) {
JsonDocument scratch;
char buffer[1024];
int len = MQTTPayloadBuilder::buildStatusMessage(
scratch, "node", "id", "model", "firmware", "radio", "client", "online",
kTimestamp, buffer, sizeof(buffer), 4200, 86400, 3, 6, -112,
11, 22, 4, 180864, 31, 47, "on");
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ("on", parsed["repeat"].as<const char*>());
JsonObject stats = parsed["stats"].as<JsonObject>();
ASSERT_FALSE(stats.isNull());
EXPECT_EQ(4200, stats["battery_mv"].as<int>());
EXPECT_EQ(86400, stats["uptime_secs"].as<int>());
EXPECT_EQ(3, stats["errors"].as<int>());
EXPECT_EQ(6, stats["queue_len"].as<int>());
EXPECT_EQ(-112, stats["noise_floor"].as<int>());
EXPECT_EQ(11, stats["tx_air_secs"].as<int>());
EXPECT_EQ(22, stats["rx_air_secs"].as<int>());
EXPECT_EQ(4, stats["recv_errors"].as<int>());
EXPECT_EQ(180864, stats["internal_heap"].as<int>());
EXPECT_EQ(31, stats["packets_sent"].as<int>());
EXPECT_EQ(47, stats["packets_received"].as<int>());
}
TEST(MQTTPayloadBuilder, StatusOmissionSentinelsRemainOmitted) {
JsonDocument scratch;
char buffer[768];
int len = MQTTPayloadBuilder::buildStatusMessage(
scratch, "node", "id", "model", "firmware", "radio", "client", "online",
kTimestamp, buffer, sizeof(buffer), -1, -1, -1, -1, -999,
-1, -1, -1, -1, -1, -1, nullptr);
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_FALSE(parsed["stats"].is<JsonVariant>());
EXPECT_FALSE(parsed["repeat"].is<JsonVariant>());
}
TEST(MQTTPayloadBuilder, StringsAreEscapedAndRoundTrip) {
const char* origin = "node \"north\"\\rack\nline";
const char* model = "Heltec\tV3";
JsonDocument scratch;
char buffer[1024];
int len = MQTTPayloadBuilder::buildStatusMessage(
scratch, origin, "id", model, "v1", "radio", "client", "online",
kTimestamp, buffer, sizeof(buffer));
ASSERT_GT(len, 0);
EXPECT_NE(std::string::npos, std::string(buffer).find("\\\"north\\\""));
EXPECT_NE(std::string::npos, std::string(buffer).find("\\\\rack\\nline"));
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ(origin, parsed["origin"].as<const char*>());
EXPECT_STREQ(model, parsed["model"].as<const char*>());
}
TEST(MQTTPayloadBuilder, RxPacketIncludesMetricsScaledScoreAndPath) {
const uint8_t path[] = {0xAA, 0xBB, 0x01, 0x2F};
JsonDocument scratch;
char buffer[2048];
int len = buildRepresentativePacket(
scratch, "rx", 0.125f, path, 2, 2, "A0B1C2D3", buffer, sizeof(buffer));
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ("PACKET", parsed["type"].as<const char*>());
EXPECT_STREQ("rx", parsed["direction"].as<const char*>());
EXPECT_STREQ("42", parsed["len"].as<const char*>());
EXPECT_STREQ("4", parsed["packet_type"].as<const char*>());
EXPECT_STREQ("20", parsed["payload_len"].as<const char*>());
EXPECT_STREQ("10.3", parsed["SNR"].as<const char*>());
EXPECT_STREQ("-87", parsed["RSSI"].as<const char*>());
EXPECT_STREQ("125", parsed["score"].as<const char*>());
JsonArray parsed_path = parsed["path"].as<JsonArray>();
ASSERT_EQ(2U, parsed_path.size());
EXPECT_STREQ("aabb", parsed_path[0].as<const char*>());
EXPECT_STREQ("012f", parsed_path[1].as<const char*>());
}
TEST(MQTTPayloadBuilder, TxPacketOmitsReceiveOnlyMetricsAndAbsentPath) {
JsonDocument scratch;
char buffer[2048];
int len = buildRepresentativePacket(
scratch, "tx", 0.5f, nullptr, 0, 0, "A0B1", buffer, sizeof(buffer));
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_FALSE(parsed["SNR"].is<JsonVariant>());
EXPECT_FALSE(parsed["RSSI"].is<JsonVariant>());
EXPECT_FALSE(parsed["score"].is<JsonVariant>());
EXPECT_FALSE(parsed["path"].is<JsonVariant>());
}
TEST(MQTTPayloadBuilder, RxPacketOmitsUnknownNanScore) {
JsonDocument scratch;
char buffer[2048];
int len = buildRepresentativePacket(
scratch, "rx", std::nanf(""), nullptr, 0, 0, "A0B1", buffer, sizeof(buffer));
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_TRUE(parsed["SNR"].is<const char*>());
EXPECT_TRUE(parsed["RSSI"].is<const char*>());
EXPECT_FALSE(parsed["score"].is<JsonVariant>());
}
TEST(MQTTPayloadBuilder, RawMessageHasExactContractAndEscapesData) {
char buffer[512];
int len = MQTTPayloadBuilder::buildRawMessage(
"node \"A\"", "id\\1", kTimestamp, "AA\nBB", buffer, sizeof(buffer));
ASSERT_GT(len, 0);
EXPECT_EQ(static_cast<size_t>(len), strlen(buffer));
EXPECT_STREQ(
"{\"origin\":\"node \\\"A\\\"\",\"origin_id\":\"id\\\\1\","
"\"timestamp\":\"2026-07-18T12:34:56.123456+00:00\","
"\"type\":\"RAW\",\"data\":\"AA\\nBB\"}",
buffer);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ("AA\nBB", parsed["data"].as<const char*>());
}
TEST(MQTTPayloadBuilder, ExactOutputSizeSucceedsAndOneByteShortFailsCleanly) {
JsonDocument scratch;
char reference[768];
int reference_len = buildMinimalStatus(scratch, reference, sizeof(reference));
ASSERT_GT(reference_len, 0);
std::vector<char> exact(static_cast<size_t>(reference_len) + 1);
EXPECT_EQ(reference_len, buildMinimalStatus(scratch, exact.data(), exact.size()));
EXPECT_STREQ(reference, exact.data());
std::vector<char> short_buffer(static_cast<size_t>(reference_len), 'x');
EXPECT_EQ(0, buildMinimalStatus(scratch, short_buffer.data(), short_buffer.size()));
EXPECT_EQ('\0', short_buffer[0]);
EXPECT_EQ(0, buildMinimalStatus(scratch, short_buffer.data(), 1));
EXPECT_EQ('\0', short_buffer[0]);
EXPECT_EQ(0, buildMinimalStatus(scratch, nullptr, exact.size()));
}
TEST(MQTTPayloadBuilder, MaximumRepresentativePacketAndRawPayloadsRemainValid) {
uint8_t path[64];
for (size_t i = 0; i < sizeof(path); ++i) path[i] = static_cast<uint8_t>(i);
std::string raw(510, 'A');
JsonDocument scratch;
char packet_buffer[2048];
int packet_len = buildRepresentativePacket(
scratch, "rx", 1.0f, path, 16, 4, raw.c_str(),
packet_buffer, sizeof(packet_buffer));
ASSERT_GT(packet_len, 0);
JsonDocument packet;
ASSERT_FALSE(deserializeJson(packet, packet_buffer));
EXPECT_EQ(510U, strlen(packet["raw"].as<const char*>()));
JsonArray parsed_path = packet["path"].as<JsonArray>();
ASSERT_EQ(16U, parsed_path.size());
EXPECT_STREQ("00010203", parsed_path[0].as<const char*>());
EXPECT_STREQ("3c3d3e3f", parsed_path[15].as<const char*>());
char raw_buffer[1024];
int raw_len = MQTTPayloadBuilder::buildRawMessage(
"node", "0123456789ABCDEF", kTimestamp, raw.c_str(),
raw_buffer, sizeof(raw_buffer));
ASSERT_GT(raw_len, 0);
JsonDocument parsed_raw;
ASSERT_FALSE(deserializeJson(parsed_raw, raw_buffer));
EXPECT_EQ(510U, strlen(parsed_raw["data"].as<const char*>()));
}
TEST(MQTTPayloadBuilder, NeighborsMessageRoundTripsSelfAndEntries) {
MQTTPayloadBuilder::NeighborsMessageEntry neighbors[] = {
{"0011223344556677", 9.75f, 42, "DEN,APRS", "active"},
{"8899AABBCCDDEEFF", -3.5f, 3600, "", "stale"},
};
JsonDocument scratch;
char buffer[1024];
int len = MQTTPayloadBuilder::buildNeighborsMessage(
scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS",
neighbors, 2, buffer, sizeof(buffer));
ASSERT_GT(len, 0);
EXPECT_EQ(static_cast<size_t>(len), strlen(buffer));
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ("2026-07-18T12:34:56.123456+00:00", parsed["timestamp"].as<const char*>());
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*>());
JsonArray arr = parsed["neighbors"].as<JsonArray>();
ASSERT_EQ(2U, arr.size());
EXPECT_STREQ("0011223344556677", arr[0]["pubkey"].as<const char*>());
EXPECT_FLOAT_EQ(9.75f, arr[0]["snr"].as<float>());
EXPECT_EQ(42U, arr[0]["heard_secs_ago"].as<uint32_t>());
EXPECT_STREQ("DEN,APRS", arr[0]["scopes"].as<const char*>());
EXPECT_STREQ("active", arr[0]["status"].as<const char*>());
EXPECT_STREQ("8899AABBCCDDEEFF", arr[1]["pubkey"].as<const char*>());
EXPECT_STREQ("", arr[1]["scopes"].as<const char*>());
EXPECT_STREQ("stale", arr[1]["status"].as<const char*>());
}
TEST(MQTTPayloadBuilder, NeighborsMessageHandlesEmptyTableAndNullScopes) {
JsonDocument scratch;
char buffer[256];
int len = MQTTPayloadBuilder::buildNeighborsMessage(
scratch, "node", "id", kTimestamp, 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*>());
JsonArray arr = parsed["neighbors"].as<JsonArray>();
ASSERT_TRUE(arr.isNull() == false);
EXPECT_EQ(0U, arr.size());
}
TEST(MQTTPayloadBuilder, NeighborsMessageDropsTailWhenBufferFills) {
// Twenty entries far exceed a tight buffer; the builder must emit a prefix
// that still parses as complete JSON rather than truncating mid-document.
MQTTPayloadBuilder::NeighborsMessageEntry neighbors[20];
static char keys[20][17];
for (int i = 0; i < 20; i++) {
snprintf(keys[i], sizeof(keys[i]), "%016X", i);
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].scopes = "DEN";
neighbors[i].status = "active";
}
JsonDocument scratch;
char buffer[512];
int len = MQTTPayloadBuilder::buildNeighborsMessage(
scratch, "node", "id", kTimestamp, "DEN", neighbors, 20,
buffer, sizeof(buffer));
ASSERT_GT(len, 0);
EXPECT_LT(static_cast<size_t>(len), sizeof(buffer));
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
JsonArray arr = parsed["neighbors"].as<JsonArray>();
ASSERT_FALSE(arr.isNull());
EXPECT_GT(arr.size(), 0U);
EXPECT_LT(arr.size(), 20U);
// Kept entries are the head of the input, in order.
EXPECT_STREQ(keys[0], arr[0]["pubkey"].as<const char*>());
}
} // namespace
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,623 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <map>
#include <string>
#include <vector>
#include "helpers/MQTTPrefsAtomicStore.h"
#include "helpers/MQTTPrefsRecovery.h"
namespace AtomicStore = MQTTPrefsAtomicStore;
namespace Recovery = MQTTPrefsRecovery;
namespace {
enum class FailurePoint {
None,
Begin,
HeaderWrite,
PayloadWrite,
ImageWrite,
Finish,
Commit,
};
class InMemoryStore {
public:
explicit InMemoryStore(FailurePoint failure, bool preexisting_recovery_temp = false)
: _failure(failure), _preexisting_recovery_temp(preexisting_recovery_temp) {
_files["/mqtt_prefs"] = {'o', 'l', 'd', '-', 'p', 'r', 'e', 'f', 's'};
if (_preexisting_recovery_temp) _files["/mqtt_prefs.tmp"] = {'r', 'e', 'c', 'o', 'v', 'e', 'r'};
}
bool begin() {
++begin_calls;
if (_preexisting_recovery_temp) return false;
_files.erase("/mqtt_prefs.tmp");
_open = _failure != FailurePoint::Begin;
_owns_temp = _open;
return _open;
}
size_t write(const uint8_t* bytes, size_t size) {
++write_calls;
if (!_open) return 0;
const bool should_fail = (write_calls == 1 && _failure == FailurePoint::HeaderWrite) ||
(write_calls == 2 && _failure == FailurePoint::PayloadWrite);
const size_t written = should_fail && size > 0 ? size - 1 : size;
_staging.insert(_staging.end(), bytes, bytes + written);
return written;
}
bool finish() {
++finish_calls;
_open = false;
if (_failure == FailurePoint::Finish) return false;
_files["/mqtt_prefs.tmp"] = _staging;
_finished = true;
return true;
}
bool commit() {
++commit_calls;
if (_failure == FailurePoint::Commit) return false;
_files["/mqtt_prefs"] = _files["/mqtt_prefs.tmp"];
_files.erase("/mqtt_prefs.tmp");
_finished = false;
return true;
}
void abort() {
++abort_calls;
_open = false;
_staging.clear();
// Mirrors MQTTPrefsFileStore: after finish(), a failed commit may already
// have moved the old primary to .bak, so the verified temp is recovery
// data rather than disposable staging.
if (_owns_temp && !_finished) _files.erase("/mqtt_prefs.tmp");
_finished = false;
_owns_temp = false;
}
const std::vector<uint8_t>& source() const { return _files.at("/mqtt_prefs"); }
bool tempExists() const { return _files.count("/mqtt_prefs.tmp") != 0; }
int begin_calls = 0;
int write_calls = 0;
int finish_calls = 0;
int commit_calls = 0;
int abort_calls = 0;
private:
FailurePoint _failure;
bool _preexisting_recovery_temp = false;
bool _open = false;
bool _finished = false;
bool _owns_temp = false;
std::vector<uint8_t> _staging;
std::map<std::string, std::vector<uint8_t>> _files;
};
AtomicStore::Result run(InMemoryStore* store) {
const uint8_t header[] = {0xf5, 'M', 'Q', 'P', 1, 0, 0x09, 0x00};
const uint8_t payload[] = {'n', 'e', 'w', '-', 'p', 'r', 'e', 'f', 's'};
return AtomicStore::write(*store, header, sizeof(header), payload, sizeof(payload));
}
AtomicStore::Result runWithObserverTail(InMemoryStore* store) {
const uint8_t header[] = {0xf5, 'M', 'Q', 'P', 1, 0, 0x0a, 0x00};
// The final three bytes stand in for the observer tail transferred from a
// legacy /com_prefs file. They must be committed before that source is compacted.
const uint8_t payload[] = {'m', 'i', 'g', 'r', 'a', 't', 'e', 0x91, 0x7e, 0xa5};
return AtomicStore::write(*store, header, sizeof(header), payload, sizeof(payload));
}
class LegacyComPrefs {
public:
LegacyComPrefs() : bytes({'l', 'e', 'g', 'a', 'c', 'y', '-', 'c', 'o', 'm'}) {}
void compactAfterMqttCommit(const std::vector<uint8_t>& mqtt_bytes) {
const std::vector<uint8_t> observer_tail = {0x91, 0x7e, 0xa5};
mqtt_tail_present_before_compaction = mqtt_bytes.size() >= observer_tail.size() &&
std::equal(observer_tail.rbegin(), observer_tail.rend(), mqtt_bytes.rbegin());
bytes = {'c', 'o', 'm', 'p', 'a', 'c', 't'};
++compact_calls;
}
std::vector<uint8_t> bytes;
bool mqtt_tail_present_before_compaction = false;
int compact_calls = 0;
};
class LegacyNodePrefs {
public:
LegacyNodePrefs() : bytes({'l', 'e', 'g', 'a', 'c', 'y', '-', 'n', 'o', 'd', 'e'}) {}
void migrateAfterMqttCommit(const std::vector<uint8_t>& mqtt_bytes) {
const std::vector<uint8_t> observer_tail = {0x91, 0x7e, 0xa5};
mqtt_tail_present_before_removal = mqtt_bytes.size() >= observer_tail.size() &&
std::equal(observer_tail.rbegin(), observer_tail.rend(), mqtt_bytes.rbegin());
bytes.clear(); // model removal after current-layout /com_prefs is written
++migration_calls;
}
std::vector<uint8_t> bytes;
bool mqtt_tail_present_before_removal = false;
int migration_calls = 0;
};
// Models the final old-name migration separately from the MQTT transaction:
// /com_prefs is absent while /node_prefs is authoritative. A failed temp write
// or rename must leave that source as the only usable preference image.
class InMemoryCommonPrefsStore {
public:
explicit InMemoryCommonPrefsStore(FailurePoint failure) : _failure(failure) {
_files["/node_prefs"] = {'l', 'e', 'g', 'a', 'c', 'y', '-', 'n', 'o', 'd', 'e'};
}
bool begin() {
++begin_calls;
_files.erase("/com_prefs.tmp");
_staging.clear();
_open = _failure != FailurePoint::Begin;
return _open;
}
size_t write(const uint8_t* bytes, size_t size) {
++write_calls;
if (!_open) return 0;
const bool should_fail = _failure == FailurePoint::ImageWrite && write_calls == 2;
const size_t written = should_fail && size > 0 ? size - 1 : size;
_staging.insert(_staging.end(), bytes, bytes + written);
return written;
}
bool finish() {
++finish_calls;
_open = false;
if (_failure == FailurePoint::Finish) return false;
_files["/com_prefs.tmp"] = _staging;
return true;
}
bool commit() {
++commit_calls;
if (_failure == FailurePoint::Commit) return false;
_files["/com_prefs"] = _files["/com_prefs.tmp"];
_files.erase("/com_prefs.tmp");
return true;
}
void abort() {
++abort_calls;
_open = false;
_staging.clear();
_files.erase("/com_prefs.tmp");
}
void removeNodeSource() { _files.erase("/node_prefs"); }
const std::vector<uint8_t>& nodeSource() const { return _files.at("/node_prefs"); }
const std::vector<uint8_t>& destination() const { return _files.at("/com_prefs"); }
bool destinationExists() const { return _files.count("/com_prefs") != 0; }
bool tempExists() const { return _files.count("/com_prefs.tmp") != 0; }
bool nodeSourceIsPreferred() const {
return _files.count("/node_prefs") != 0 && _files.count("/com_prefs") == 0;
}
bool nodeSourceExists() const { return _files.count("/node_prefs") != 0; }
int begin_calls = 0;
int write_calls = 0;
int finish_calls = 0;
int commit_calls = 0;
int abort_calls = 0;
private:
FailurePoint _failure;
bool _open = false;
std::vector<uint8_t> _staging;
std::map<std::string, std::vector<uint8_t>> _files;
};
AtomicStore::ImageResult runCommonPrefsImage(InMemoryCommonPrefsStore* store) {
const uint8_t core[] = {'c', 'o', 'm', '-', 'p', 'r', 'e', 'f', 's'};
const uint8_t tail[] = {0x19, 0xa4, 0x7e};
return AtomicStore::writeImage(*store, [&core, &tail](InMemoryCommonPrefsStore& target) {
return target.write(core, sizeof(core)) == sizeof(core) &&
target.write(tail, sizeof(tail)) == sizeof(tail);
});
}
// Models the exact SPIFFS transaction used by MQTTPrefsFileStore. Files only
// move by rename: SPIFFS rejects a destination that already exists, so the old
// primary must remain available as .bak until the new temp owns the primary.
class SpiffsMqttTransaction {
public:
enum class Boundary {
BeforeBackupRename,
AfterBackupRename,
AfterPrimaryRename,
AfterBackupCleanup,
};
SpiffsMqttTransaction() {
_files["/mqtt_prefs"] = oldImage();
}
void writeVerifiedTemp() { _files["/mqtt_prefs.tmp"] = newImage(); }
void cutDuringTempWrite() { _files["/mqtt_prefs.tmp"] = {'n'}; }
void cutAt(Boundary boundary) {
writeVerifiedTemp();
if (boundary == Boundary::BeforeBackupRename) return;
rename("/mqtt_prefs", "/mqtt_prefs.bak");
if (boundary == Boundary::AfterBackupRename) return;
rename("/mqtt_prefs.tmp", "/mqtt_prefs");
if (boundary == Boundary::AfterPrimaryRename) return;
_files.erase("/mqtt_prefs.bak");
}
// Inject ordinary operation failures (as distinct from a power cut). A
// failed temp rename leaves both the verified temp and old backup intact.
bool publish(bool fail_backup_rename, bool fail_temp_rename, bool fail_cleanup) {
writeVerifiedTemp();
if (fail_backup_rename || !rename("/mqtt_prefs", "/mqtt_prefs.bak")) return false;
if (fail_temp_rename || !rename("/mqtt_prefs.tmp", "/mqtt_prefs")) return false;
if (!fail_cleanup) _files.erase("/mqtt_prefs.bak");
return true; // backup cleanup is intentionally non-fatal after publish
}
void recover(Recovery::FileState primary = Recovery::FileState::Usable,
Recovery::FileState temp = Recovery::FileState::Usable,
Recovery::FileState backup = Recovery::FileState::Usable) {
const bool had_primary = _files.count("/mqtt_prefs") != 0;
const auto stateFor = [&](const char* path, Recovery::FileState readable) {
return _files.count(path) == 0 ? Recovery::FileState::Missing : readable;
};
const Recovery::Action action = Recovery::select(
stateFor("/mqtt_prefs", primary), stateFor("/mqtt_prefs.tmp", temp),
stateFor("/mqtt_prefs.bak", backup));
if (action == Recovery::Action::PromoteTemp) {
rename("/mqtt_prefs.tmp", "/mqtt_prefs");
// Match production: once a usable temp becomes primary, every backup is
// stale and is cleared so a second save can start this boot.
if (temp == Recovery::FileState::Usable && backup != Recovery::FileState::Missing) {
_files.erase("/mqtt_prefs.bak");
}
return;
}
if (action == Recovery::Action::PromoteBackup) {
rename("/mqtt_prefs.bak", "/mqtt_prefs");
if (backup == Recovery::FileState::Usable && temp != Recovery::FileState::Missing) {
_files.erase("/mqtt_prefs.tmp");
}
return;
}
// A usable primary is authoritative, so production cleans every stale or
// incomplete transaction artifact. It only preserves artifacts when the
// primary itself is opaque.
if (had_primary && primary == Recovery::FileState::Usable) {
_files.erase("/mqtt_prefs.tmp");
_files.erase("/mqtt_prefs.bak");
}
}
bool has(const char* path) const { return _files.count(path) != 0; }
bool canStartSave() const { return !has("/mqtt_prefs.tmp") && !has("/mqtt_prefs.bak"); }
const std::vector<uint8_t>& primary() const { return _files.at("/mqtt_prefs"); }
static std::vector<uint8_t> oldImage() { return {'o', 'l', 'd'}; }
static std::vector<uint8_t> newImage() { return {'n', 'e', 'w'}; }
private:
bool rename(const char* from, const char* to) {
if (_files.count(from) == 0 || _files.count(to) != 0) return false;
_files[to] = _files[from];
_files.erase(from);
return true;
}
std::map<std::string, std::vector<uint8_t>> _files;
};
} // namespace
TEST(MQTTPrefsAtomicStore, CommitPublishesExactHeaderThenPayload) {
InMemoryStore store(FailurePoint::None);
EXPECT_EQ(AtomicStore::Result::Committed, run(&store));
EXPECT_EQ((std::vector<uint8_t>{0xf5, 'M', 'Q', 'P', 1, 0, 0x09, 0x00,
'n', 'e', 'w', '-', 'p', 'r', 'e', 'f', 's'}),
store.source());
EXPECT_FALSE(store.tempExists());
EXPECT_EQ(1, store.begin_calls);
EXPECT_EQ(2, store.write_calls);
EXPECT_EQ(1, store.finish_calls);
EXPECT_EQ(1, store.commit_calls);
EXPECT_EQ(0, store.abort_calls);
}
TEST(MQTTPrefsAtomicStore, AnyFailureAbortsAndPreservesExistingSource) {
const std::vector<uint8_t> source = {'o', 'l', 'd', '-', 'p', 'r', 'e', 'f', 's'};
const struct {
FailurePoint point;
AtomicStore::Result expected;
int writes;
int finishes;
int commits;
} cases[] = {
{FailurePoint::Begin, AtomicStore::Result::BeginFailed, 0, 0, 0},
{FailurePoint::HeaderWrite, AtomicStore::Result::HeaderWriteFailed, 1, 0, 0},
{FailurePoint::PayloadWrite, AtomicStore::Result::PayloadWriteFailed, 2, 0, 0},
{FailurePoint::Finish, AtomicStore::Result::FinishFailed, 2, 1, 0},
{FailurePoint::Commit, AtomicStore::Result::CommitFailed, 2, 1, 1},
};
for (const auto& test_case : cases) {
InMemoryStore store(test_case.point);
EXPECT_EQ(test_case.expected, run(&store));
EXPECT_EQ(source, store.source());
EXPECT_EQ(test_case.point == FailurePoint::Commit, store.tempExists());
EXPECT_EQ(1, store.begin_calls);
EXPECT_EQ(test_case.writes, store.write_calls);
EXPECT_EQ(test_case.finishes, store.finish_calls);
EXPECT_EQ(test_case.commits, store.commit_calls);
EXPECT_EQ(1, store.abort_calls);
}
}
TEST(MQTTPrefsAtomicStore, BeginFailureDoesNotErasePreexistingRecoveryTemp) {
InMemoryStore store(FailurePoint::Begin, true);
EXPECT_EQ(AtomicStore::Result::BeginFailed, run(&store));
EXPECT_TRUE(store.tempExists());
EXPECT_EQ(1, store.abort_calls);
}
TEST(MQTTPrefsAtomicStore, LegacyCrossFileUpgradeCommitsTailBeforeCompactingComPrefs) {
InMemoryStore mqtt_store(FailurePoint::None);
LegacyComPrefs com_prefs;
AtomicStore::LegacyUpgradeGate gate(true);
gate.requireMqttRewrite();
const AtomicStore::Result result = runWithObserverTail(&mqtt_store);
gate.recordMqttSave(AtomicStore::committed(result));
ASSERT_TRUE(gate.mayRewriteComPrefs());
com_prefs.compactAfterMqttCommit(mqtt_store.source());
gate.recordComPrefsRewrite();
EXPECT_TRUE(com_prefs.mqtt_tail_present_before_compaction);
EXPECT_EQ(1, com_prefs.compact_calls);
EXPECT_EQ((std::vector<uint8_t>{'c', 'o', 'm', 'p', 'a', 'c', 't'}), com_prefs.bytes);
EXPECT_FALSE(gate.mayRewriteComPrefs());
}
TEST(MQTTPrefsAtomicStore, LegacyCrossFilePowerCutPreservesBothSources) {
const std::vector<uint8_t> legacy_mqtt = {'o', 'l', 'd', '-', 'p', 'r', 'e', 'f', 's'};
const std::vector<uint8_t> legacy_com = {'l', 'e', 'g', 'a', 'c', 'y', '-', 'c', 'o', 'm'};
for (const FailurePoint point : {FailurePoint::Begin, FailurePoint::HeaderWrite,
FailurePoint::PayloadWrite, FailurePoint::Finish,
FailurePoint::Commit}) {
InMemoryStore mqtt_store(point);
LegacyComPrefs com_prefs;
AtomicStore::LegacyUpgradeGate gate(true);
gate.requireMqttRewrite();
const AtomicStore::Result result = runWithObserverTail(&mqtt_store);
gate.recordMqttSave(AtomicStore::committed(result));
if (gate.mayRewriteComPrefs()) {
com_prefs.compactAfterMqttCommit(mqtt_store.source());
gate.recordComPrefsRewrite();
}
EXPECT_FALSE(AtomicStore::committed(result));
EXPECT_EQ(legacy_mqtt, mqtt_store.source());
EXPECT_EQ(legacy_com, com_prefs.bytes);
EXPECT_EQ(0, com_prefs.compact_calls);
EXPECT_TRUE(gate.blocksComPrefsRewrite());
}
}
TEST(MQTTPrefsAtomicStore, LegacyNodePrefsMigrationWaitsForObserverTailCommit) {
InMemoryStore mqtt_store(FailurePoint::None);
LegacyNodePrefs node_prefs;
AtomicStore::LegacyUpgradeGate gate(true);
gate.requireMqttRewrite();
const AtomicStore::Result result = runWithObserverTail(&mqtt_store);
gate.recordMqttSave(AtomicStore::committed(result));
ASSERT_TRUE(gate.mayRewriteComPrefs());
node_prefs.migrateAfterMqttCommit(mqtt_store.source());
gate.recordComPrefsRewrite();
EXPECT_TRUE(node_prefs.mqtt_tail_present_before_removal);
EXPECT_EQ(1, node_prefs.migration_calls);
EXPECT_TRUE(node_prefs.bytes.empty());
}
TEST(MQTTPrefsAtomicStore, LegacyNodePrefsPowerCutPreservesSource) {
const std::vector<uint8_t> legacy_mqtt = {'o', 'l', 'd', '-', 'p', 'r', 'e', 'f', 's'};
const std::vector<uint8_t> legacy_node = {
'l', 'e', 'g', 'a', 'c', 'y', '-', 'n', 'o', 'd', 'e'};
for (const FailurePoint point : {FailurePoint::Begin, FailurePoint::HeaderWrite,
FailurePoint::PayloadWrite, FailurePoint::Finish,
FailurePoint::Commit}) {
InMemoryStore mqtt_store(point);
LegacyNodePrefs node_prefs;
AtomicStore::LegacyUpgradeGate gate(true);
gate.requireMqttRewrite();
const AtomicStore::Result result = runWithObserverTail(&mqtt_store);
gate.recordMqttSave(AtomicStore::committed(result));
if (gate.mayRewriteComPrefs()) {
node_prefs.migrateAfterMqttCommit(mqtt_store.source());
gate.recordComPrefsRewrite();
}
EXPECT_FALSE(AtomicStore::committed(result));
EXPECT_EQ(legacy_mqtt, mqtt_store.source());
EXPECT_EQ(legacy_node, node_prefs.bytes);
EXPECT_EQ(0, node_prefs.migration_calls);
EXPECT_TRUE(gate.blocksComPrefsRewrite());
}
}
TEST(MQTTPrefsAtomicStore, NodePrefsMigrationPublishesComPrefsBeforeRemovingSource) {
InMemoryCommonPrefsStore store(FailurePoint::None);
ASSERT_EQ(AtomicStore::ImageResult::Committed, runCommonPrefsImage(&store));
EXPECT_TRUE(store.nodeSourceExists()); // caller removes it only after commit
EXPECT_EQ((std::vector<uint8_t>{'c', 'o', 'm', '-', 'p', 'r', 'e', 'f', 's', 0x19, 0xa4, 0x7e}),
store.destination());
EXPECT_FALSE(store.tempExists());
store.removeNodeSource();
EXPECT_FALSE(store.nodeSourceExists());
EXPECT_TRUE(store.destinationExists());
}
TEST(MQTTPrefsAtomicStore, NodePrefsMigrationFailurePreservesSourceAndNeverPrefersPartialDestination) {
const std::vector<uint8_t> legacy_node = {
'l', 'e', 'g', 'a', 'c', 'y', '-', 'n', 'o', 'd', 'e'};
const struct {
FailurePoint point;
AtomicStore::ImageResult expected;
int writes;
int finishes;
int commits;
} cases[] = {
{FailurePoint::Begin, AtomicStore::ImageResult::BeginFailed, 0, 0, 0},
{FailurePoint::ImageWrite, AtomicStore::ImageResult::WriteFailed, 2, 0, 0},
{FailurePoint::Finish, AtomicStore::ImageResult::FinishFailed, 2, 1, 0},
{FailurePoint::Commit, AtomicStore::ImageResult::CommitFailed, 2, 1, 1},
};
for (const auto& test_case : cases) {
InMemoryCommonPrefsStore store(test_case.point);
EXPECT_EQ(test_case.expected, runCommonPrefsImage(&store));
EXPECT_EQ(legacy_node, store.nodeSource());
EXPECT_TRUE(store.nodeSourceIsPreferred());
EXPECT_FALSE(store.destinationExists());
EXPECT_FALSE(store.tempExists());
EXPECT_EQ(1, store.begin_calls);
EXPECT_EQ(test_case.writes, store.write_calls);
EXPECT_EQ(test_case.finishes, store.finish_calls);
EXPECT_EQ(test_case.commits, store.commit_calls);
EXPECT_EQ(1, store.abort_calls);
}
}
TEST(MQTTPrefsAtomicStore, SpiffsPowerCutsAtEveryPublishBoundaryLeaveRecoverableImage) {
const struct {
SpiffsMqttTransaction::Boundary boundary;
std::vector<uint8_t> expected_after_reboot;
} cases[] = {
// Temp has not become the committed image yet, so the old primary wins.
{SpiffsMqttTransaction::Boundary::BeforeBackupRename, SpiffsMqttTransaction::oldImage()},
// Old primary is .bak and verified new temp wins the recovery race.
{SpiffsMqttTransaction::Boundary::AfterBackupRename, SpiffsMqttTransaction::newImage()},
{SpiffsMqttTransaction::Boundary::AfterPrimaryRename, SpiffsMqttTransaction::newImage()},
{SpiffsMqttTransaction::Boundary::AfterBackupCleanup, SpiffsMqttTransaction::newImage()},
};
for (const auto& test_case : cases) {
SpiffsMqttTransaction store;
store.cutAt(test_case.boundary);
store.recover();
ASSERT_TRUE(store.has("/mqtt_prefs"));
EXPECT_EQ(test_case.expected_after_reboot, store.primary());
EXPECT_FALSE(store.has("/mqtt_prefs.tmp"));
EXPECT_FALSE(store.has("/mqtt_prefs.bak"));
}
}
TEST(MQTTPrefsAtomicStore, PowerCutDuringTempWriteKeepsPrimaryAndAllowsNextSave) {
SpiffsMqttTransaction store;
store.cutDuringTempWrite();
// The partial temp is opaque to the codec, but the existing primary is the
// only committed image. Recovery discards the incomplete transaction rather
// than blocking every later config save behind /mqtt_prefs.tmp.
store.recover(Recovery::FileState::Usable, Recovery::FileState::Preserve);
EXPECT_EQ(SpiffsMqttTransaction::oldImage(), store.primary());
EXPECT_FALSE(store.has("/mqtt_prefs.tmp"));
EXPECT_TRUE(store.canStartSave());
}
TEST(MQTTPrefsAtomicStore, RecoveredUsablePrimaryClearsOpaqueTransactionArtifacts) {
{
SpiffsMqttTransaction store;
store.cutAt(SpiffsMqttTransaction::Boundary::AfterBackupRename);
// A current-format temp wins; the old backup need not be decodable to be
// stale once that usable temp owns the primary name.
store.recover(Recovery::FileState::Usable, Recovery::FileState::Usable,
Recovery::FileState::Preserve);
EXPECT_EQ(SpiffsMqttTransaction::newImage(), store.primary());
EXPECT_TRUE(store.canStartSave());
}
{
SpiffsMqttTransaction store;
store.cutAt(SpiffsMqttTransaction::Boundary::AfterBackupRename);
// Conversely, when the usable backup becomes primary, an opaque temp was
// never published and must not leave saves permanently blocked.
store.recover(Recovery::FileState::Usable, Recovery::FileState::Preserve,
Recovery::FileState::Usable);
EXPECT_EQ(SpiffsMqttTransaction::oldImage(), store.primary());
EXPECT_TRUE(store.canStartSave());
}
}
TEST(MQTTPrefsAtomicStore, SpiffsRenameAndCleanupFailuresRemainRecoverable) {
{
SpiffsMqttTransaction store;
EXPECT_FALSE(store.publish(true, false, false));
store.recover();
EXPECT_EQ(SpiffsMqttTransaction::oldImage(), store.primary());
EXPECT_FALSE(store.has("/mqtt_prefs.tmp"));
EXPECT_FALSE(store.has("/mqtt_prefs.bak"));
}
{
SpiffsMqttTransaction store;
EXPECT_FALSE(store.publish(false, true, false));
EXPECT_FALSE(store.has("/mqtt_prefs"));
EXPECT_TRUE(store.has("/mqtt_prefs.tmp"));
EXPECT_TRUE(store.has("/mqtt_prefs.bak"));
store.recover();
EXPECT_EQ(SpiffsMqttTransaction::newImage(), store.primary());
EXPECT_FALSE(store.has("/mqtt_prefs.tmp"));
EXPECT_FALSE(store.has("/mqtt_prefs.bak"));
}
{
SpiffsMqttTransaction store;
EXPECT_TRUE(store.publish(false, false, true));
EXPECT_EQ(SpiffsMqttTransaction::newImage(), store.primary());
EXPECT_TRUE(store.has("/mqtt_prefs.bak"));
store.recover();
EXPECT_FALSE(store.has("/mqtt_prefs.bak"));
}
}
TEST(MQTTPrefsAtomicStore, RecoveryNeverOverwritesOpaqueNewerLayout) {
// An unreadable primary owns its name, even if an older usable backup and a
// verified temp exist. This is the downgrade-preservation invariant.
EXPECT_EQ(Recovery::Action::KeepPrimary,
Recovery::select(Recovery::FileState::Preserve, Recovery::FileState::Usable,
Recovery::FileState::Usable));
// If there is no primary, a usable backup wins over an opaque temp. Once
// promoted, production treats the backup as authoritative and clears temp.
EXPECT_EQ(Recovery::Action::PromoteBackup,
Recovery::select(Recovery::FileState::Missing, Recovery::FileState::Preserve,
Recovery::FileState::Usable));
// With no other image, an opaque backup is renamed into the empty primary
// name so CommonCLI will hold it rather than silently replace it with defaults.
EXPECT_EQ(Recovery::Action::PromoteBackup,
Recovery::select(Recovery::FileState::Missing, Recovery::FileState::Missing,
Recovery::FileState::Preserve));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+24
View File
@@ -0,0 +1,24 @@
# MQTT preferences fixture provenance
The codec tests construct synthetic, non-secret byte vectors at hard-coded
offsets. They intentionally do not serialize the production structs: a layout
change must disagree with the frozen fixture bytes and fail the field checks.
| Bytes | Historical layout | Source history |
|---:|---|---|
| 472 | Pre-slot, before and after `wifi_power_save` | Initial `/mqtt_prefs` layout; `34c8bea7` inserted WiFi power without changing the padded total size |
| 1032 | Initial three-slot layout | `b43e9618` |
| 1464 | Three slots with token/topic tails | `95874f0c` |
| 2452 | Six slots with token/topic tails | `1b5884bd` |
| 2836 | Six slots with audience tail | `1263e71d` |
| 2840 | Six slots with RX flag | `47b632aa` |
| 2904 | Six slots with NTP server | `7416d632` |
| 2736 / 2860 | Version-1 payload before observer tail / complete payload | `58b9cb66` introduced the eight-byte versioned header; the shorter form exercises its append-compatible prefix contract |
The 3024-byte raw observer-tail form is not accepted as deployed fleet data:
repository history indicates it existed briefly before versioning but was not
shipped. Tests require it to be preserved rather than guessed and rewritten.
Headerless formats have no checksum. Plausibility checks reject obvious random
or malformed content, but cannot authenticate a byte sequence that happens to
look like a valid historical struct.
@@ -0,0 +1,399 @@
#include <gtest/gtest.h>
#include <cstring>
#include <vector>
#define WITH_MQTT_BRIDGE 1
#include "helpers/MQTTPrefsCodec.h"
namespace Codec = MQTTPrefsCodec;
namespace {
MQTTPrefs defaults() {
MQTTPrefs prefs = {};
prefs.mqtt_status_enabled = 1;
prefs.mqtt_packets_enabled = 1;
prefs.mqtt_tx_enabled = 2;
prefs.mqtt_rx_enabled = 1;
prefs.mqtt_status_interval = 300000;
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);
}
strncpy(prefs.snmp_community, "public", sizeof(prefs.snmp_community) - 1);
prefs.radio_watchdog_minutes = 5;
prefs.alert_wifi_minutes = 30;
prefs.alert_mqtt_minutes = 240;
prefs.alert_min_interval_min = 60;
return prefs;
}
void writeText(std::vector<uint8_t>* bytes, size_t offset, const char* value) {
const size_t length = strlen(value);
ASSERT_LE(offset + length, bytes->size());
memcpy(bytes->data() + offset, value, length);
}
void writeLe16(std::vector<uint8_t>* bytes, size_t offset, uint16_t value) {
ASSERT_LE(offset + 2, bytes->size());
(*bytes)[offset] = static_cast<uint8_t>(value & 0xff);
(*bytes)[offset + 1] = static_cast<uint8_t>(value >> 8);
}
void writeHeader(std::vector<uint8_t>* bytes, uint16_t version, uint16_t payload_len) {
ASSERT_GE(bytes->size(), sizeof(MQTTPrefsHeader));
(*bytes)[0] = MQTT_PREFS_MAGIC[0];
(*bytes)[1] = MQTT_PREFS_MAGIC[1];
(*bytes)[2] = MQTT_PREFS_MAGIC[2];
(*bytes)[3] = MQTT_PREFS_MAGIC[3];
writeLe16(bytes, 4, version);
writeLe16(bytes, 6, payload_len);
}
Codec::DecodePlan classify(const std::vector<uint8_t>& bytes) {
const size_t prefix_size = bytes.size() < sizeof(MQTTPrefsHeader)
? bytes.size() : sizeof(MQTTPrefsHeader);
return Codec::classify(bytes.data(), prefix_size, bytes.size());
}
void fillHighEntropy(std::vector<uint8_t>* bytes) {
uint32_t state = 0x89abcdef;
for (size_t i = 0; i < bytes->size(); ++i) {
state = state * 1664525u + 1013904223u;
// Keep every byte non-NUL so this is a useful corruption fixture rather
// than a sparse/default-like legacy file.
(*bytes)[i] = static_cast<uint8_t>((state >> 24) | 0x80);
}
}
} // namespace
TEST(MQTTPrefsCodec, MigratesPostWifiPowerPreSlotFixture) {
// Frozen post-WiFi-power pre-slot offsets: status=44, ssid=48, power=144,
// timezone=145, server=178, port=242. Do not derive this fixture from structs.
std::vector<uint8_t> bytes(472, 0);
writeText(&bytes, 0, "legacy-node");
writeText(&bytes, 32, "YYZ");
bytes[40] = 1;
bytes[41] = 1;
bytes[43] = 2;
bytes[144] = 2;
writeText(&bytes, 145, "UTC");
writeText(&bytes, 178, "broker.example");
writeLe16(&bytes, 242, 1883);
writeText(&bytes, 244, "alice");
writeText(&bytes, 276, "secret");
bytes[340] = 1;
const Codec::DecodePlan plan = classify(bytes);
ASSERT_EQ(Codec::Source::LegacyPreSlot, plan.source);
ASSERT_TRUE(plan.rewrite_legacy);
ASSERT_FALSE(Codec::looksLikePreWifiPower(bytes.data(), bytes.size()));
ASSERT_TRUE(Codec::isPlausibleLegacy(plan.source, bytes.data(), bytes.size()));
OldMQTTPrefs old_prefs = {};
memcpy(&old_prefs, bytes.data(), sizeof(old_prefs));
MQTTPrefs prefs = defaults();
Codec::migratePreSlot(old_prefs, &prefs);
EXPECT_STREQ("legacy-node", prefs.mqtt_origin);
EXPECT_STREQ("analyzer-us", prefs.mqtt_slot_preset[0]);
EXPECT_STREQ("custom", prefs.mqtt_slot_preset[2]);
EXPECT_STREQ("broker.example", prefs.mqtt_slot_host[2]);
EXPECT_EQ(1883, prefs.mqtt_slot_port[2]);
EXPECT_STREQ("secret", prefs.mqtt_slot_password[2]);
}
TEST(MQTTPrefsCodec, MigratesPreWifiPowerFixtureWithConservativeHeuristic) {
// In the pre-WiFi-power variant timezone starts at 144 and server at 177;
// port still aligns at 242. A non-empty timezone makes the layout unambiguous.
std::vector<uint8_t> bytes(472, 0);
writeText(&bytes, 0, "pre-power");
writeText(&bytes, 144, "PST8PDT");
writeText(&bytes, 177, "old-broker");
writeLe16(&bytes, 242, 8883);
ASSERT_TRUE(Codec::looksLikePreWifiPower(bytes.data(), bytes.size()));
ASSERT_TRUE(Codec::isPlausibleLegacy(Codec::Source::LegacyPreSlot,
bytes.data(), bytes.size()));
PreWifiPowerOldMQTTPrefs old_prefs = {};
memcpy(&old_prefs, bytes.data(), sizeof(old_prefs));
MQTTPrefs prefs = defaults();
Codec::migratePreWifiPower(old_prefs, &prefs);
EXPECT_STREQ("PST8PDT", prefs.timezone_string);
EXPECT_STREQ("old-broker", prefs.mqtt_slot_host[2]);
EXPECT_EQ(8883, prefs.mqtt_slot_port[2]);
EXPECT_EQ(1, prefs.wifi_power_save); // retained current default; old layout had none
}
TEST(MQTTPrefsCodec, DetectsPreWifiPowerFixtureWithOnlyUtcOffsetConfigured) {
std::vector<uint8_t> bytes(472, 0);
bytes[176] = static_cast<uint8_t>(-8);
EXPECT_TRUE(Codec::looksLikePreWifiPower(bytes.data(), bytes.size()));
}
TEST(MQTTPrefsCodec, MigratesThreeSlotBaseAndExtendedFixtures) {
// Frozen 3-slot offsets: presets=178, hosts=250, ports=442, users=448,
// passwords=544, owner=736, tokens=1030, topics=1174. The base file has
// two tail-padding bytes, so its frozen size is 1032.
std::vector<uint8_t> base(1032, 0);
writeText(&base, 0, "three-base");
writeText(&base, 178, "meshmapper");
writeText(&base, 250, "three.example");
writeLe16(&base, 442, 1884);
const Codec::DecodePlan base_plan = classify(base);
ASSERT_EQ(Codec::Source::LegacyThreeSlotBase, base_plan.source);
ASSERT_TRUE(Codec::isPlausibleLegacy(base_plan.source, base.data(), base.size()));
ThreeSlotBaseMQTTPrefs old_base = {};
memcpy(&old_base, base.data(), sizeof(old_base));
MQTTPrefs base_prefs = defaults();
Codec::migrateThreeSlot(old_base, &base_prefs);
EXPECT_STREQ("three-base", base_prefs.mqtt_origin);
EXPECT_STREQ("meshmapper", base_prefs.mqtt_slot_preset[0]);
EXPECT_EQ(1884, base_prefs.mqtt_slot_port[0]);
EXPECT_STREQ("none", base_prefs.mqtt_slot_preset[3]);
std::vector<uint8_t> extended(1464, 0);
writeText(&extended, 0, "three-extended");
writeText(&extended, 178 + 24, "custom");
writeText(&extended, 250 + 64, "slot-two.example");
writeText(&extended, 1030 + 48, "token-two");
writeText(&extended, 1174 + 96, "custom/{type}");
const Codec::DecodePlan extended_plan = classify(extended);
ASSERT_EQ(Codec::Source::LegacyThreeSlot, extended_plan.source);
ASSERT_TRUE(Codec::isPlausibleLegacy(extended_plan.source, extended.data(), extended.size()));
ThreeSlotMQTTPrefs old_extended = {};
memcpy(&old_extended, extended.data(), sizeof(old_extended));
MQTTPrefs extended_prefs = defaults();
Codec::migrateThreeSlot(old_extended, &extended_prefs);
EXPECT_STREQ("custom", extended_prefs.mqtt_slot_preset[1]);
EXPECT_STREQ("token-two", extended_prefs.mqtt_slot_token[1]);
EXPECT_STREQ("custom/{type}", extended_prefs.mqtt_slot_topic[1]);
}
TEST(MQTTPrefsCodec, MigratesAllLegacySixSlotPrefixesWithoutClobberingDefaults) {
// Frozen 6-slot offsets: presets=178, hosts=322, ports=706, tokens=1588,
// topics=1876, audience=2452, rx=2836, ntp=2837.
for (const size_t size : {size_t(2452), size_t(2836), size_t(2840), size_t(2904)}) {
std::vector<uint8_t> bytes(size, 0);
writeText(&bytes, 0, "six-slot");
writeText(&bytes, 178 + 5 * 24, "custom");
writeText(&bytes, 322 + 5 * 64, "six.example");
writeText(&bytes, 1588 + 5 * 48, "token-six");
writeText(&bytes, 1876 + 5 * 96, "six/{type}");
if (size >= 2836) writeText(&bytes, 2452 + 5 * 64, "audience-six");
if (size >= 2840) bytes[2836] = 0;
if (size >= 2904) writeText(&bytes, 2837, "time.example");
const Codec::DecodePlan plan = classify(bytes);
const Codec::Source expected_source = size == 2452 ? Codec::Source::LegacySixSlotBase
: size == 2836 ? Codec::Source::LegacySixSlotAudience
: size == 2840 ? Codec::Source::LegacySixSlotAudienceRx
: Codec::Source::LegacySixSlot;
EXPECT_EQ(expected_source, plan.source) << size;
ASSERT_TRUE(plan.rewrite_legacy);
ASSERT_TRUE(Codec::isPlausibleLegacy(plan.source, bytes.data(), bytes.size())) << size;
Legacy6SlotMQTTPrefs old_prefs = {};
memcpy(&old_prefs, bytes.data(), bytes.size());
MQTTPrefs prefs = defaults();
Codec::migrateLegacySixSlot(old_prefs, plan.source, &prefs);
EXPECT_STREQ("custom", prefs.mqtt_slot_preset[5]);
EXPECT_STREQ("token-six", prefs.mqtt_slot_token[5]);
if (size < 2836) {
EXPECT_EQ('\0', prefs.mqtt_slot_audience[5][0]);
} else {
EXPECT_STREQ("audience-six", prefs.mqtt_slot_audience[5]);
}
if (size < 2840) {
EXPECT_EQ(1, prefs.mqtt_rx_enabled) << size;
} else {
EXPECT_EQ(0, prefs.mqtt_rx_enabled);
}
if (size < 2904) {
EXPECT_EQ('\0', prefs.mqtt_ntp_server[0]);
} else {
EXPECT_STREQ("time.example", prefs.mqtt_ntp_server);
}
}
}
TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) {
MQTTPrefs source = defaults();
strncpy(source.mqtt_origin, "current-node", sizeof(source.mqtt_origin) - 1);
strncpy(source.mqtt_slot_password[2], "preserve-me", sizeof(source.mqtt_slot_password[2]) - 1);
strncpy(source.alert_region, "PNW", sizeof(source.alert_region) - 1);
source.mqtt_neighbors_enabled = 1;
source.mqtt_neighbors_interval = MQTT_NEIGHBORS_MAX_INTERVAL_MS;
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::Source::Current, plan.source);
ASSERT_FALSE(plan.preserve_file);
ASSERT_TRUE(plan.observer_fields_present);
MQTTPrefs loaded = defaults();
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len);
EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source)));
}
TEST(MQTTPrefsCodec, CompatibleShortV1PayloadPreservesDefaultsBeyondObserverBoundary) {
MQTTPrefs source = defaults();
strncpy(source.mqtt_origin, "short-v1-node", sizeof(source.mqtt_origin) - 1);
strncpy(source.mqtt_ntp_server, "ntp.short.example", sizeof(source.mqtt_ntp_server) - 1);
source.snmp_enabled = 1;
strncpy(source.snmp_community, "do-not-copy", sizeof(source.snmp_community) - 1);
source.radio_watchdog_minutes = 99;
std::vector<uint8_t> bytes(sizeof(MQTTPrefsHeader) + Codec::kV1PreObserverPayloadSize, 0);
writeHeader(&bytes, MQTT_PREFS_VERSION,
static_cast<uint16_t>(Codec::kV1PreObserverPayloadSize));
memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, Codec::kV1PreObserverPayloadSize);
const Codec::DecodePlan plan = classify(bytes);
ASSERT_EQ(Codec::Source::Current, plan.source);
ASSERT_EQ(Codec::kV1PreObserverPayloadSize, plan.payload_len);
ASSERT_FALSE(plan.preserve_file);
ASSERT_FALSE(plan.observer_fields_present);
MQTTPrefs loaded = defaults();
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len);
EXPECT_STREQ("short-v1-node", loaded.mqtt_origin);
EXPECT_STREQ("ntp.short.example", loaded.mqtt_ntp_server);
EXPECT_EQ(0, loaded.snmp_enabled);
EXPECT_STREQ("public", loaded.snmp_community);
EXPECT_EQ(5, loaded.radio_watchdog_minutes);
}
TEST(MQTTPrefsCodec, PreNeighborsV1PayloadLoadsObserverFieldsAndDefaultsNeighborsTail) {
// A /mqtt_prefs written by observer/webconfig firmware before the neighbors
// tail existed: full observer fields, 2860-byte v1 payload. It must still load
// as Current (observer fields present) with the neighbors tail defaulted.
MQTTPrefs source = defaults();
strncpy(source.mqtt_origin, "pre-neighbors-node", sizeof(source.mqtt_origin) - 1);
strncpy(source.alert_region, "PNW", sizeof(source.alert_region) - 1);
source.snmp_enabled = 1;
source.alert_enabled = 1;
source.mqtt_neighbors_enabled = 0; // old struct's byte 2857 was zero padding
source.mqtt_neighbors_interval = 0x11223344; // must NOT survive a 2860-byte read
std::vector<uint8_t> bytes(sizeof(MQTTPrefsHeader) + Codec::kV1PreNeighborsPayloadSize, 0);
writeHeader(&bytes, MQTT_PREFS_VERSION,
static_cast<uint16_t>(Codec::kV1PreNeighborsPayloadSize));
memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, Codec::kV1PreNeighborsPayloadSize);
const Codec::DecodePlan plan = classify(bytes);
ASSERT_EQ(Codec::Source::Current, plan.source);
ASSERT_EQ(Codec::kV1PreNeighborsPayloadSize, plan.payload_len);
ASSERT_FALSE(plan.preserve_file);
ASSERT_TRUE(plan.observer_fields_present);
MQTTPrefs loaded = defaults();
loaded.mqtt_neighbors_enabled = 1; // pretend stale
loaded.mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; // caller's defaulted tail
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len);
EXPECT_STREQ("pre-neighbors-node", loaded.mqtt_origin);
EXPECT_STREQ("PNW", loaded.alert_region);
EXPECT_EQ(1, loaded.snmp_enabled);
EXPECT_EQ(1, loaded.alert_enabled);
// Enable flag sits at offset 2857 (inside the 2860 read) -> takes the file's 0.
// 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);
}
TEST(MQTTPrefsCodec, CorruptOrShortVersionedInputsArePreserved) {
Codec::DecodePlan plan = Codec::classify(nullptr, 0, 0);
EXPECT_EQ(Codec::Source::Corrupt, plan.source);
EXPECT_TRUE(plan.preserve_file);
std::vector<uint8_t> partial_magic = {MQTT_PREFS_MAGIC[0], MQTT_PREFS_MAGIC[1], MQTT_PREFS_MAGIC[2]};
plan = classify(partial_magic);
EXPECT_EQ(Codec::Source::Corrupt, plan.source);
EXPECT_TRUE(plan.preserve_file);
std::vector<uint8_t> short_payload(sizeof(MQTTPrefsHeader) + 4, 0);
writeHeader(&short_payload, MQTT_PREFS_VERSION,
static_cast<uint16_t>(Codec::kV1BaselinePayloadSize - 1));
plan = classify(short_payload);
EXPECT_EQ(Codec::Source::Corrupt, plan.source);
EXPECT_TRUE(plan.preserve_file);
std::vector<uint8_t> declared_short_with_full_body(
sizeof(MQTTPrefsHeader) + Codec::kV1BaselinePayloadSize, 0);
writeHeader(&declared_short_with_full_body, MQTT_PREFS_VERSION,
static_cast<uint16_t>(Codec::kV1PreObserverPayloadSize));
plan = classify(declared_short_with_full_body);
EXPECT_EQ(Codec::Source::Corrupt, plan.source);
EXPECT_TRUE(plan.preserve_file);
std::vector<uint8_t> declared_full_with_short_body(
sizeof(MQTTPrefsHeader) + Codec::kV1PreObserverPayloadSize, 0);
writeHeader(&declared_full_with_short_body, MQTT_PREFS_VERSION,
static_cast<uint16_t>(Codec::kV1BaselinePayloadSize));
plan = classify(declared_full_with_short_body);
EXPECT_EQ(Codec::Source::Corrupt, plan.source);
EXPECT_TRUE(plan.preserve_file);
std::vector<uint8_t> truncated(sizeof(MQTTPrefsHeader) + Codec::kV1BaselinePayloadSize - 1, 0);
writeHeader(&truncated, MQTT_PREFS_VERSION,
static_cast<uint16_t>(Codec::kV1BaselinePayloadSize));
plan = classify(truncated);
EXPECT_EQ(Codec::Source::Corrupt, plan.source);
EXPECT_TRUE(plan.preserve_file);
std::vector<uint8_t> trailing(sizeof(MQTTPrefsHeader) + Codec::kV1BaselinePayloadSize + 1, 0);
writeHeader(&trailing, MQTT_PREFS_VERSION,
static_cast<uint16_t>(Codec::kV1BaselinePayloadSize));
plan = classify(trailing);
EXPECT_EQ(Codec::Source::Corrupt, plan.source);
EXPECT_TRUE(plan.preserve_file);
}
TEST(MQTTPrefsCodec, LegacyPlausibilityRejectsHighEntropyBytesAtEveryWhitelistedSize) {
// A headerless raw struct has no checksum, so this only reduces false
// migrations; it cannot prove that a plausible-looking file is authentic.
for (const size_t size : {size_t(472), size_t(1032), size_t(1464), size_t(2452),
size_t(2836), size_t(2840), size_t(2904)}) {
std::vector<uint8_t> bytes(size, 0);
fillHighEntropy(&bytes);
const Codec::DecodePlan plan = classify(bytes);
ASSERT_TRUE(plan.rewrite_legacy) << size;
EXPECT_FALSE(Codec::isPlausibleLegacy(plan.source, bytes.data(), bytes.size())) << size;
}
}
TEST(MQTTPrefsCodec, UnsupportedHeaderlessSizesArePreserved) {
// 3024 was produced briefly before versioning, but repository history says
// that raw observer-tail form was not shipped. Preserve it rather than guess.
for (const size_t size : {size_t(471), size_t(473), size_t(1465), size_t(2905), size_t(3024)}) {
std::vector<uint8_t> bytes(size, 0);
const Codec::DecodePlan plan = classify(bytes);
EXPECT_EQ(Codec::Source::Corrupt, plan.source) << size;
EXPECT_TRUE(plan.preserve_file) << size;
}
}
TEST(MQTTPrefsCodec, NewerAndSameVersionExtendedPayloadsAreHeld) {
std::vector<uint8_t> newer(sizeof(MQTTPrefsHeader), 0);
writeHeader(&newer, MQTT_PREFS_VERSION + 1, 0);
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);
EXPECT_TRUE(plan.preserve_file);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,170 @@
// Host tests for the MQTT observer preset table and lookup helpers
// (src/helpers/MQTTPresets.h). Pure logic -- no ESP/radio dependencies.
//
// The preset table and lookup functions are compiled only for observer builds,
// so opt into that feature flag before including the header (the definitions are
// pure C++/data with no ESP dependencies).
#define WITH_MQTT_BRIDGE 1
#define MQTT_PRESETS_IMPLEMENTATION
#define PROGMEM // host build: the CA-cert strings in MQTTPresets.h are PROGMEM-qualified
#include <gtest/gtest.h>
#include <cstring>
#include <set>
#include <string>
#include "helpers/MQTTPresets.h"
// ---- findMQTTPreset -------------------------------------------------------
TEST(MQTTPresets, FindKnownPreset) {
const MQTTPresetDef* p = findMQTTPreset("analyzer-us");
ASSERT_NE(nullptr, p);
EXPECT_STREQ("analyzer-us", p->name);
EXPECT_EQ(MQTT_AUTH_JWT, p->auth_type);
EXPECT_EQ(MQTT_TOPIC_MESHCORE, p->topic_style);
}
TEST(MQTTPresets, FindReturnsTablePointer) {
// The returned pointer must be into the table, not a copy.
const MQTTPresetDef* p = findMQTTPreset("meshrank");
ASSERT_NE(nullptr, p);
bool in_table = false;
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
if (p == &MQTT_PRESETS[i]) { in_table = true; break; }
}
EXPECT_TRUE(in_table);
}
TEST(MQTTPresets, UnknownAndEmptyReturnNull) {
EXPECT_EQ(nullptr, findMQTTPreset("does-not-exist"));
EXPECT_EQ(nullptr, findMQTTPreset(""));
EXPECT_EQ(nullptr, findMQTTPreset(nullptr));
}
TEST(MQTTPresets, NoneAndCustomAreNotTablePresets) {
// "none"/"custom" are virtual presets handled by the CLI, not table entries.
EXPECT_EQ(nullptr, findMQTTPreset(MQTT_PRESET_NONE));
EXPECT_EQ(nullptr, findMQTTPreset(MQTT_PRESET_CUSTOM));
EXPECT_STREQ("none", MQTT_PRESET_NONE);
EXPECT_STREQ("custom", MQTT_PRESET_CUSTOM);
}
TEST(MQTTPresets, LookupIsCaseSensitive) {
EXPECT_EQ(nullptr, findMQTTPreset("Analyzer-US"));
}
// ---- table integrity ------------------------------------------------------
TEST(MQTTPresets, EveryNameIsUniqueAndNonEmpty) {
std::set<std::string> names;
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
ASSERT_NE(nullptr, MQTT_PRESETS[i].name) << "preset " << i << " has null name";
EXPECT_NE('\0', MQTT_PRESETS[i].name[0]) << "preset " << i << " has empty name";
auto res = names.insert(MQTT_PRESETS[i].name);
EXPECT_TRUE(res.second) << "duplicate preset name: " << MQTT_PRESETS[i].name;
}
EXPECT_EQ((size_t)MQTT_PRESET_COUNT, names.size());
}
TEST(MQTTPresets, EveryPresetHasAServerUrl) {
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
ASSERT_NE(nullptr, MQTT_PRESETS[i].server_url) << MQTT_PRESETS[i].name;
EXPECT_NE('\0', MQTT_PRESETS[i].server_url[0]) << MQTT_PRESETS[i].name;
}
}
TEST(MQTTPresets, JwtPresetsCarryAnAudience) {
// JWT auth needs an audience (the field doubles as the broker host here).
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
if (MQTT_PRESETS[i].auth_type == MQTT_AUTH_JWT) {
EXPECT_NE(nullptr, MQTT_PRESETS[i].jwt_audience)
<< MQTT_PRESETS[i].name << " is JWT but has no audience";
}
}
}
TEST(MQTTPresets, NamesFitTheSlotPresetBuffer) {
// Stored preset name goes into mqtt_slot_preset[MAX][24]; keep < 24 chars.
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
EXPECT_LT(strlen(MQTT_PRESETS[i].name), (size_t)24)
<< MQTT_PRESETS[i].name << " too long for slot-preset buffer";
}
}
// ---- mqttPresetNeedsSlotCredentials ---------------------------------------
TEST(MQTTPresets, EmbeddedUserpassDoesNotNeedSlotCredentials) {
// tennmesh ships an embedded username+password.
const MQTTPresetDef* p = findMQTTPreset("tennmesh");
ASSERT_NE(nullptr, p);
EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type);
EXPECT_FALSE(mqttPresetNeedsSlotCredentials(p));
}
TEST(MQTTPresets, UserpassWithoutEmbeddedCredsNeedsSlotCredentials) {
// inwmesh is USERPASS with null user/pass -> must come from mqttN.username/password.
const MQTTPresetDef* p = findMQTTPreset("inwmesh");
ASSERT_NE(nullptr, p);
EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type);
EXPECT_TRUE(mqttPresetNeedsSlotCredentials(p));
EXPECT_TRUE(mqttPresetNeedsSlotUsername(p));
EXPECT_TRUE(mqttPresetNeedsSlotPassword(p));
EXPECT_FALSE(mqttPresetUsesDevicePubkeyUsername(p));
}
TEST(MQTTPresets, NonUserpassNeverNeedsSlotCredentials) {
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
if (MQTT_PRESETS[i].auth_type != MQTT_AUTH_USERPASS) {
EXPECT_FALSE(mqttPresetNeedsSlotCredentials(&MQTT_PRESETS[i]))
<< MQTT_PRESETS[i].name;
}
}
EXPECT_FALSE(mqttPresetNeedsSlotCredentials(nullptr));
}
TEST(MQTTPresets, MeshrankIsTokenStyleNoAuth) {
const MQTTPresetDef* p = findMQTTPreset("meshrank");
ASSERT_NE(nullptr, p);
EXPECT_EQ(MQTT_TOPIC_MESHRANK, p->topic_style);
EXPECT_EQ(MQTT_AUTH_NONE, p->auth_type);
}
TEST(MQTTPresets, MeshChaun14UsesPubkeyUsernameAndNeedsPassword) {
const MQTTPresetDef* p = findMQTTPreset("mesh-chaun14");
ASSERT_NE(nullptr, p);
EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type);
EXPECT_EQ(MQTT_TOPIC_MESHCORE, p->topic_style);
EXPECT_STREQ("mqtt://mqtt.mesh.chaun14.fr:1884", p->server_url);
EXPECT_EQ(nullptr, p->ca_cert);
EXPECT_EQ(60, p->keepalive);
EXPECT_TRUE(mqttPresetUsesDevicePubkeyUsername(p));
EXPECT_STREQ(MQTT_USERPASS_USERNAME_PUBKEY, p->userpass_username);
EXPECT_FALSE(mqttPresetNeedsSlotUsername(p));
EXPECT_TRUE(mqttPresetNeedsSlotPassword(p));
EXPECT_TRUE(mqttPresetNeedsSlotCredentials(p));
}
TEST(MQTTPresets, WcmeshIsJwtWithIsrgRootX1) {
const MQTTPresetDef* p = findMQTTPreset("wcmesh");
ASSERT_NE(nullptr, p);
EXPECT_EQ(MQTT_AUTH_JWT, p->auth_type);
EXPECT_EQ(MQTT_TOPIC_MESHCORE, p->topic_style);
EXPECT_STREQ("wss://mqtt.wcmesh.com:443", p->server_url);
EXPECT_STREQ("mqtt.wcmesh.com", p->jwt_audience);
EXPECT_EQ(ISRG_ROOT_X1, p->ca_cert);
EXPECT_NE(GTS_ROOT_R4, p->ca_cert);
EXPECT_FALSE(mqttPresetNeedsSlotCredentials(p));
EXPECT_FALSE(mqttPresetUsesDevicePubkeyUsername(p));
}
// ---- slot count constants -------------------------------------------------
TEST(MQTTPresets, SlotCountsAreSane) {
EXPECT_GT(RUNTIME_MQTT_SLOTS, 0);
EXPECT_LE(RUNTIME_MQTT_SLOTS, MAX_MQTT_SLOTS);
EXPECT_EQ(6, MAX_MQTT_SLOTS); // persisted layout -- must not drift without migration
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,132 @@
// Host tests for replyAppendf (src/helpers/MQTTReplyFormat.h), the bounded
// clamping printf-append used by MQTTBridge's status/stats/diag CLI formatters.
// Proves the A1 out-of-bounds-write bound holds regardless of input size, rather
// than "by input-size accident" (see the 2026-07-19 MQTT observer review).
#include <gtest/gtest.h>
#include <cstring>
#include "helpers/MQTTReplyFormat.h"
// Lay a canary past the logical buffer so any write at buf[bufsize..] is caught.
// `logical` bytes are the buffer handed to replyAppendf; the trailing GUARD bytes
// must stay 0xAA.
static const size_t GUARD = 8;
struct Canaried {
static const size_t CAP = 256;
char mem[CAP + GUARD];
size_t logical;
explicit Canaried(size_t n) : logical(n) {
memset(mem, 0xAA, sizeof(mem));
mem[0] = '\0';
}
char* buf() { return mem; }
bool guardIntact() const {
for (size_t i = logical; i < logical + GUARD; i++) {
if ((unsigned char)mem[i] != 0xAA) return false;
}
return true;
}
};
TEST(ReplyAppendf, BasicAppendWithinBounds) {
char buf[64];
int pos = 0;
replyAppendf(buf, sizeof(buf), &pos, "> msgs: %s", "on");
EXPECT_STREQ("> msgs: on", buf);
EXPECT_EQ(pos, 10);
}
TEST(ReplyAppendf, SequenceAccumulates) {
char buf[64];
int pos = 0;
replyAppendf(buf, sizeof(buf), &pos, "> msgs: %s", "off");
replyAppendf(buf, sizeof(buf), &pos, ", %d: %s (%s)", 1, "denmesh", "ok");
replyAppendf(buf, sizeof(buf), &pos, ", q:%d", 3);
EXPECT_STREQ("> msgs: off, 1: denmesh (ok), q:3", buf);
EXPECT_EQ(pos, (int)strlen(buf));
}
TEST(ReplyAppendf, TruncationClampsPosAndTerminates) {
char buf[16];
int pos = 0;
replyAppendf(buf, sizeof(buf), &pos, "%s", "0123456789ABCDEF_TOO_LONG");
// Written up to 15 chars + NUL; pos pinned at bufsize-1.
EXPECT_EQ(pos, 15);
EXPECT_EQ(buf[15], '\0');
EXPECT_STREQ("0123456789ABCDE", buf);
}
TEST(ReplyAppendf, AppendAfterFullIsNoOp) {
char buf[8];
int pos = 0;
replyAppendf(buf, sizeof(buf), &pos, "%s", "AAAAAAAAAAAA"); // overflows
EXPECT_EQ(pos, 7);
char snapshot[8];
memcpy(snapshot, buf, sizeof(buf));
// Further appends must not write anything and must keep pos clamped.
replyAppendf(buf, sizeof(buf), &pos, ", more");
replyAppendf(buf, sizeof(buf), &pos, ", q:%d", 9);
EXPECT_EQ(pos, 7);
EXPECT_EQ(0, memcmp(snapshot, buf, sizeof(buf)));
}
// The A1 reproduction: the old `pos += snprintf(...)` idiom would let pos exceed
// bufsize after a truncated append, so the *next* append wrote at buf+pos with a
// wrapped size_t length. replyAppendf must never touch the guard bytes.
TEST(ReplyAppendf, NoWritePastBufferAcrossOverflowingChain) {
Canaried c(24);
int pos = 0;
// Mimic formatSlotDiagReply's chain, sized to blow well past 24 bytes.
replyAppendf(c.buf(), c.logical, &pos, "> mqtt%d: %s", 6, "no client");
replyAppendf(c.buf(), c.logical, &pos, ", dc:%lu", 4294967295UL);
replyAppendf(c.buf(), c.logical, &pos, ", first_disc:%lus", 4294967295UL);
replyAppendf(c.buf(), c.logical, &pos, ", %s (0x%04X)", "cert verify failed", 0x800Bu);
replyAppendf(c.buf(), c.logical, &pos, ", mbedtls:-0x%04X", 0x8010u);
replyAppendf(c.buf(), c.logical, &pos, ", sock:%d", -2147483647);
replyAppendf(c.buf(), c.logical, &pos, ", %luh ago", 1193046UL);
EXPECT_TRUE(c.guardIntact());
EXPECT_LE(pos, (int)c.logical - 1);
EXPECT_EQ(c.buf()[c.logical - 1], '\0'); // still NUL-terminated
}
TEST(ReplyAppendf, ExactFitBoundary) {
char buf[11]; // room for exactly "0123456789" + NUL
int pos = 0;
replyAppendf(buf, sizeof(buf), &pos, "%s", "0123456789");
EXPECT_EQ(pos, 10);
EXPECT_STREQ("0123456789", buf);
EXPECT_EQ(buf[10], '\0');
}
TEST(ReplyAppendf, NullAndZeroSizeAreNoOps) {
int pos = 0;
replyAppendf(nullptr, 16, &pos, "x"); // null buf
EXPECT_EQ(pos, 0);
char buf[8] = {'k', 0};
replyAppendf(buf, 0, &pos, "x"); // zero size
EXPECT_STREQ("k", buf);
replyAppendf(buf, sizeof(buf), nullptr, "x"); // null pos
EXPECT_STREQ("k", buf);
}
TEST(ReplyAppendf, BufsizeOneJustTerminates) {
char buf[1];
buf[0] = 'Z';
int pos = 0;
replyAppendf(buf, sizeof(buf), &pos, "anything");
EXPECT_EQ(pos, 0);
EXPECT_EQ(buf[0], '\0');
}
TEST(ReplyAppendf, NegativeIncomingPosTreatedAsZero) {
char buf[16];
memset(buf, 'x', sizeof(buf));
int pos = -5;
replyAppendf(buf, sizeof(buf), &pos, "hi");
EXPECT_EQ(pos, 2);
EXPECT_STREQ("hi", buf);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,70 @@
#include <gtest/gtest.h>
#include <cstdlib>
#include <vector>
#include "helpers/MQTTRuntimeBufferLifecycle.h"
namespace RuntimeBuffers = MQTTRuntimeBufferLifecycle;
TEST(MQTTRuntimeBufferLifecycle, PartialAllocationKeepsOtherBuffersAndRetriesOnlyMissing) {
std::vector<size_t> allocation_sizes;
int allocation_attempt = 0;
const auto allocate = [&allocation_sizes, &allocation_attempt](size_t size) -> void* {
allocation_sizes.push_back(size);
allocation_attempt++;
if (allocation_attempt == 2) {
return nullptr;
}
return std::malloc(size);
};
void* raw = nullptr;
void* publish = nullptr;
void* status = nullptr;
raw = RuntimeBuffers::allocateIfMissing(raw, 256, allocate);
publish = RuntimeBuffers::allocateIfMissing(publish, 2048, allocate);
status = RuntimeBuffers::allocateIfMissing(status, 768, allocate);
ASSERT_NE(nullptr, raw);
EXPECT_EQ(nullptr, publish);
ASSERT_NE(nullptr, status);
ASSERT_EQ(3U, allocation_sizes.size());
void* const initial_raw = raw;
void* const initial_status = status;
raw = RuntimeBuffers::allocateIfMissing(raw, 256, allocate);
publish = RuntimeBuffers::allocateIfMissing(publish, 2048, allocate);
status = RuntimeBuffers::allocateIfMissing(status, 768, allocate);
EXPECT_EQ(4U, allocation_sizes.size());
EXPECT_EQ(2048U, allocation_sizes.back());
EXPECT_EQ(initial_raw, raw);
EXPECT_EQ(initial_status, status);
EXPECT_NE(nullptr, publish);
int releases = 0;
const auto release = [&releases](void* allocation) {
releases++;
std::free(allocation);
};
raw = RuntimeBuffers::release(raw, release);
publish = RuntimeBuffers::release(publish, release);
status = RuntimeBuffers::release(status, release);
EXPECT_EQ(nullptr, raw);
EXPECT_EQ(nullptr, publish);
EXPECT_EQ(nullptr, status);
EXPECT_EQ(3, releases);
raw = RuntimeBuffers::release(raw, release);
publish = RuntimeBuffers::release(publish, release);
status = RuntimeBuffers::release(status, release);
EXPECT_EQ(3, releases);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,198 @@
// Host contract tests for the complete MQTT publication-topic routing policy.
#define WITH_MQTT_BRIDGE 1
#define MQTT_PRESETS_IMPLEMENTATION
#define PROGMEM
#include <gtest/gtest.h>
#include <cstring>
#include <string>
#include "helpers/MQTTPresets.h"
#include "helpers/MQTTTopicRouter.h"
namespace {
constexpr const char* IATA = "DEN";
constexpr const char* DEVICE = "0123456789ABCDEF";
constexpr const char* TOKEN = "account-token";
struct TypeCase {
int type;
const char* name;
};
const TypeCase kTypes[] = {
{MQTT_PUBLICATION_STATUS, "status"},
{MQTT_PUBLICATION_PACKETS, "packets"},
{MQTT_PUBLICATION_RAW, "raw"},
{MQTT_PUBLICATION_NEIGHBORS, "neighbors"},
};
TEST(MQTTTopicRouter, EveryMeshCorePresetSupportsEveryPublicationType) {
for (int preset_index = 0; preset_index < MQTT_PRESET_COUNT; ++preset_index) {
const MQTTPresetDef& preset = MQTT_PRESETS[preset_index];
if (preset.topic_style != MQTT_TOPIC_MESHCORE) continue;
for (const TypeCase& type : kTypes) {
char topic[128];
ASSERT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, type.type, nullptr,
IATA, DEVICE, TOKEN, topic, sizeof(topic)))
<< preset.name << " / " << type.name;
EXPECT_EQ(std::string("meshcore/DEN/0123456789ABCDEF/") + type.name, topic)
<< preset.name;
}
}
}
TEST(MQTTTopicRouter, MeshRankContractIsPacketsOnly) {
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);
}
TEST(MQTTTopicRouter, MeshCoreRequiresUsableIataAndDevice) {
char topic[64];
const char* invalid_iatas[] = {nullptr, "", "XX", "XXXX", "X/X", "XXX"};
for (const char* iata : invalid_iatas) {
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS,
nullptr, iata, DEVICE, TOKEN, topic, sizeof(topic)));
EXPECT_STREQ("", topic);
}
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS,
nullptr, IATA, nullptr, TOKEN, topic, sizeof(topic)));
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS,
nullptr, IATA, "", TOKEN, topic, sizeof(topic)));
}
TEST(MQTTTopicRouter, MeshRankRequiresTokenAndDeviceButNotIata) {
char topic[128];
const char* missing_tokens[] = {nullptr, ""};
for (const char* token : missing_tokens) {
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS,
nullptr, nullptr, DEVICE, token, topic, sizeof(topic)));
EXPECT_STREQ("", topic);
}
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS,
nullptr, nullptr, nullptr, TOKEN, topic, sizeof(topic)));
EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS,
nullptr, nullptr, DEVICE, TOKEN, topic, sizeof(topic)));
}
TEST(MQTTTopicRouter, CustomTemplateExpandsEveryType) {
for (const TypeCase& type : kTypes) {
char topic[128];
ASSERT_TRUE(mqttBuildPublicationTopic(
MQTT_ROUTE_CUSTOM, type.type, "custom/{iata}/{token}/{device}/{type}",
IATA, DEVICE, TOKEN, topic, sizeof(topic)));
EXPECT_EQ(std::string("custom/DEN/account-token/0123456789ABCDEF/") + type.name, topic);
}
}
TEST(MQTTTopicRouter, CustomLiteralDoesNotRequireIataTokenOrDevice) {
char topic[32];
ASSERT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_RAW,
"private/raw", nullptr, nullptr, nullptr,
topic, sizeof(topic)));
EXPECT_STREQ("private/raw", topic);
}
TEST(MQTTTopicRouter, EmptyCustomTemplateFailsRatherThanFallingBackImplicitly) {
char topic[64];
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_STATUS,
"", IATA, DEVICE, TOKEN, topic, sizeof(topic)));
EXPECT_STREQ("", topic);
// MQTTBridge selects the MeshCore style explicitly for a custom slot whose
// template is empty; make that default contract visible here.
EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS,
nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic)));
EXPECT_STREQ("meshcore/DEN/0123456789ABCDEF/status", topic);
}
TEST(MQTTTopicRouter, FormattedTopicsRequireRoomForTerminator) {
const char* expected = "meshcore/DEN/0123456789ABCDEF/status";
const size_t exact_size = strlen(expected) + 1;
char exact[64];
ASSERT_LE(exact_size, sizeof(exact));
EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS,
nullptr, IATA, DEVICE, TOKEN, exact, exact_size));
EXPECT_STREQ(expected, exact);
char short_buf[64];
memset(short_buf, 0x7f, sizeof(short_buf));
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS,
nullptr, IATA, DEVICE, TOKEN,
short_buf, exact_size - 1));
EXPECT_EQ('\0', short_buf[exact_size - 2]);
}
TEST(MQTTTopicRouter, CustomTopicHonorsExactBoundary) {
const char* expected = "custom/DEN/raw";
char exact[15];
static_assert(sizeof(exact) == 15, "fixture includes the terminator");
EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_RAW,
"custom/{iata}/{type}", IATA, DEVICE, TOKEN,
exact, sizeof(exact)));
EXPECT_STREQ(expected, exact);
char short_buf[14];
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_RAW,
"custom/{iata}/{type}", IATA, DEVICE, TOKEN,
short_buf, sizeof(short_buf)));
EXPECT_LT(strlen(short_buf), sizeof(short_buf));
}
TEST(MQTTTopicRouter, RejectsInvalidStyleTypeSlotAndOutput) {
char topic[64] = "dirty";
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, 99, nullptr,
IATA, DEVICE, TOKEN, topic, sizeof(topic)));
EXPECT_STREQ("", topic);
EXPECT_FALSE(mqttBuildPublicationTopic(static_cast<MQTTTopicRouteStyle>(99),
MQTT_PUBLICATION_STATUS, nullptr,
IATA, DEVICE, TOKEN, topic, sizeof(topic)));
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS,
nullptr, IATA, DEVICE, TOKEN, nullptr, 64));
EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS,
nullptr, IATA, DEVICE, TOKEN, topic, 0));
EXPECT_FALSE(mqttTopicSlotIndexValid(-1, RUNTIME_MQTT_SLOTS));
EXPECT_TRUE(mqttTopicSlotIndexValid(0, RUNTIME_MQTT_SLOTS));
EXPECT_TRUE(mqttTopicSlotIndexValid(RUNTIME_MQTT_SLOTS - 1, RUNTIME_MQTT_SLOTS));
EXPECT_FALSE(mqttTopicSlotIndexValid(RUNTIME_MQTT_SLOTS, RUNTIME_MQTT_SLOTS));
EXPECT_FALSE(mqttTopicSlotIndexValid(0, 0));
}
TEST(MQTTTopicRouter, PublicationTypeEnumValuesAreFrozen) {
// The bridge passes MQTTBridge::MQTTMessageType to mqttBuildPublicationTopic
// as an int; a compile-time static_assert in the bridge ties the two enums
// together. Freeze the router side here so its values can't drift on their own.
EXPECT_EQ(0, MQTT_PUBLICATION_STATUS);
EXPECT_EQ(1, MQTT_PUBLICATION_PACKETS);
EXPECT_EQ(2, MQTT_PUBLICATION_RAW);
EXPECT_EQ(3, MQTT_PUBLICATION_NEIGHBORS);
EXPECT_STREQ("status", mqttPublicationTypeName(MQTT_PUBLICATION_STATUS));
EXPECT_STREQ("packets", mqttPublicationTypeName(MQTT_PUBLICATION_PACKETS));
EXPECT_STREQ("raw", mqttPublicationTypeName(MQTT_PUBLICATION_RAW));
EXPECT_STREQ("neighbors", mqttPublicationTypeName(MQTT_PUBLICATION_NEIGHBORS));
}
} // namespace
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,135 @@
// Host tests for the observer input validators shared by the CLI setters
// (src/helpers/MQTTObserverValidation.h): IATA, owner key, NTP hostname, and
// the buffer-fit check behind the #17 length validation.
#include <gtest/gtest.h>
#include <string>
#include "helpers/MQTTObserverValidation.h"
// ---- IATA: exactly 3 alphanumerics ---------------------------------------
TEST(IataValid, AcceptsThreeLetters) {
EXPECT_TRUE(mqttIataValid("DEN"));
EXPECT_TRUE(mqttIataValid("den")); // case handled (setter uppercases after)
EXPECT_TRUE(mqttIataValid("LAX"));
}
TEST(IataValid, AcceptsThreeAlphanumerics) {
EXPECT_TRUE(mqttIataValid("D3N"));
EXPECT_TRUE(mqttIataValid("2M0"));
}
TEST(IataValid, RejectsWrongLength) {
EXPECT_FALSE(mqttIataValid(""));
EXPECT_FALSE(mqttIataValid("D"));
EXPECT_FALSE(mqttIataValid("DE"));
EXPECT_FALSE(mqttIataValid("DENV"));
EXPECT_FALSE(mqttIataValid("DENVER"));
}
TEST(IataValid, RejectsNonAlphanumeric) {
EXPECT_FALSE(mqttIataValid("D-N")); // topic separator-ish
EXPECT_FALSE(mqttIataValid("D N")); // space
EXPECT_FALSE(mqttIataValid("D/N")); // MQTT topic separator
EXPECT_FALSE(mqttIataValid("D+N")); // MQTT wildcard
EXPECT_FALSE(mqttIataValid("D#N")); // MQTT wildcard
}
TEST(IataValid, RejectsNull) {
EXPECT_FALSE(mqttIataValid(nullptr));
}
// ---- owner key: exactly 64 hex -------------------------------------------
static std::string hexKey(int len, char fill = 'a') { return std::string(len, fill); }
TEST(OwnerKeyValid, Accepts64Hex) {
EXPECT_TRUE(mqttOwnerKeyValid(hexKey(64, 'a').c_str()));
EXPECT_TRUE(mqttOwnerKeyValid(hexKey(64, 'F').c_str()));
EXPECT_TRUE(mqttOwnerKeyValid(
"0123456789abcdefABCDEF0123456789abcdefABCDEF0123456789abcdef0123"));
}
TEST(OwnerKeyValid, RejectsWrongLength) {
EXPECT_FALSE(mqttOwnerKeyValid(""));
EXPECT_FALSE(mqttOwnerKeyValid(hexKey(63).c_str()));
EXPECT_FALSE(mqttOwnerKeyValid(hexKey(65).c_str()));
}
TEST(OwnerKeyValid, RejectsNonHex) {
std::string k = hexKey(64);
k[10] = 'g'; // not a hex digit
EXPECT_FALSE(mqttOwnerKeyValid(k.c_str()));
k[10] = 'z';
EXPECT_FALSE(mqttOwnerKeyValid(k.c_str()));
k[10] = ' ';
EXPECT_FALSE(mqttOwnerKeyValid(k.c_str()));
}
TEST(OwnerKeyValid, RejectsNull) {
EXPECT_FALSE(mqttOwnerKeyValid(nullptr));
}
// ---- NTP hostname ---------------------------------------------------------
TEST(NtpHostnameValid, AcceptsTypicalHosts) {
EXPECT_TRUE(mqttNtpHostnameValid("pool.ntp.org"));
EXPECT_TRUE(mqttNtpHostnameValid("time.google.com"));
EXPECT_TRUE(mqttNtpHostnameValid("1.2.3.4"));
EXPECT_TRUE(mqttNtpHostnameValid("a"));
}
TEST(NtpHostnameValid, LengthBoundaryIs63) {
EXPECT_TRUE(mqttNtpHostnameValid(std::string(63, 'a').c_str()));
EXPECT_FALSE(mqttNtpHostnameValid(std::string(64, 'a').c_str()));
}
TEST(NtpHostnameValid, RejectsEmptyAndNull) {
EXPECT_FALSE(mqttNtpHostnameValid(""));
EXPECT_FALSE(mqttNtpHostnameValid(nullptr));
}
TEST(NtpHostnameValid, RejectsLeadingOrTrailingDot) {
EXPECT_FALSE(mqttNtpHostnameValid(".pool.ntp.org"));
EXPECT_FALSE(mqttNtpHostnameValid("pool.ntp.org."));
}
TEST(NtpHostnameValid, RejectsInvalidChars) {
EXPECT_FALSE(mqttNtpHostnameValid("a_b")); // underscore
EXPECT_FALSE(mqttNtpHostnameValid("a b")); // space
EXPECT_FALSE(mqttNtpHostnameValid("http://x")); // scheme / slashes
}
// ---- buffer-fit (the #17 length check) -----------------------------------
TEST(ValueFits, FitsWhenShorterThanBuffer) {
EXPECT_TRUE(mqttValueFits("abc", 4)); // 3 < 4 (room for NUL)
EXPECT_TRUE(mqttValueFits("", 1)); // empty fits any 1+ buffer
}
TEST(ValueFits, RejectsWhenExactlyBufferSizeOrLonger) {
EXPECT_FALSE(mqttValueFits("abcd", 4)); // 4 == 4, no room for NUL
EXPECT_FALSE(mqttValueFits("abcde", 4));
EXPECT_FALSE(mqttValueFits("x", 1));
}
TEST(ValueFits, RealBufferBoundaries) {
// Mirrors the actual MQTTPrefs field sizes the setters pass sizeof() for.
EXPECT_TRUE(mqttValueFits(std::string(63, 'p').c_str(), 64)); // wifi_password[64]
EXPECT_FALSE(mqttValueFits(std::string(64, 'p').c_str(), 64));
EXPECT_TRUE(mqttValueFits(std::string(31, 's').c_str(), 32)); // wifi_ssid[32]
EXPECT_FALSE(mqttValueFits(std::string(32, 's').c_str(), 32));
EXPECT_TRUE(mqttValueFits(std::string(47, 't').c_str(), 48)); // slot token[48]
EXPECT_FALSE(mqttValueFits(std::string(48, 't').c_str(), 48));
EXPECT_TRUE(mqttValueFits(std::string(95, 'x').c_str(), 96)); // slot topic[96]
EXPECT_FALSE(mqttValueFits(std::string(96, 'x').c_str(), 96));
}
TEST(ValueFits, RejectsNullOrZeroBuffer) {
EXPECT_FALSE(mqttValueFits(nullptr, 32));
EXPECT_FALSE(mqttValueFits("abc", 0));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,113 @@
// Host tests for the MQTT custom-topic placeholder expansion
// (src/helpers/MQTTTopicTemplate.h), the pure core of
// MQTTBridge::substituteTopicTemplate.
#include <gtest/gtest.h>
#include <cstring>
#include "helpers/MQTTTopicTemplate.h"
static const char* IATA = "DEN";
static const char* DEV = "abcdef0123456789";
static const char* TOK = "tok123";
TEST(TopicTemplate, SubstitutesAllPlaceholders) {
char buf[128];
ASSERT_TRUE(mqttSubstituteTopic("meshcore/{iata}/{device}/{type}", IATA, DEV, TOK, "status",
buf, sizeof(buf)));
EXPECT_STREQ("meshcore/DEN/abcdef0123456789/status", buf);
}
TEST(TopicTemplate, TokenPlaceholder) {
char buf[128];
ASSERT_TRUE(mqttSubstituteTopic("meshrank/uplink/{token}/{device}/packets",
IATA, DEV, TOK, "packets", buf, sizeof(buf)));
EXPECT_STREQ("meshrank/uplink/tok123/abcdef0123456789/packets", buf);
}
TEST(TopicTemplate, RepeatedPlaceholder) {
char buf[64];
ASSERT_TRUE(mqttSubstituteTopic("{iata}-{iata}", IATA, DEV, TOK, "raw", buf, sizeof(buf)));
EXPECT_STREQ("DEN-DEN", buf);
}
TEST(TopicTemplate, LiteralWithNoPlaceholders) {
char buf[64];
ASSERT_TRUE(mqttSubstituteTopic("plain/topic/path", IATA, DEV, TOK, "status", buf, sizeof(buf)));
EXPECT_STREQ("plain/topic/path", buf);
}
TEST(TopicTemplate, UnknownBracesCopiedVerbatim) {
char buf[64];
ASSERT_TRUE(mqttSubstituteTopic("a/{bogus}/{iata}", IATA, DEV, TOK, "status", buf, sizeof(buf)));
EXPECT_STREQ("a/{bogus}/DEN", buf);
}
TEST(TopicTemplate, TypeStringVaries) {
char buf[64];
mqttSubstituteTopic("{type}", IATA, DEV, TOK, "status", buf, sizeof(buf));
EXPECT_STREQ("status", buf);
mqttSubstituteTopic("{type}", IATA, DEV, TOK, "packets", buf, sizeof(buf));
EXPECT_STREQ("packets", buf);
mqttSubstituteTopic("{type}", IATA, DEV, TOK, "raw", buf, sizeof(buf));
EXPECT_STREQ("raw", buf);
}
TEST(TopicTemplate, NullValuesSubstituteEmpty) {
char buf[64];
ASSERT_TRUE(mqttSubstituteTopic("x/{token}/y", IATA, DEV, nullptr, "status", buf, sizeof(buf)));
EXPECT_STREQ("x//y", buf);
}
TEST(TopicTemplate, OverflowReturnsFalseNoWrite) {
// Substituting {device} (16 chars) into a template won't fit an 8-byte buffer.
char buf[8];
EXPECT_FALSE(mqttSubstituteTopic("{device}", IATA, DEV, TOK, "status", buf, sizeof(buf)));
}
TEST(TopicTemplate, LiteralOverflowReturnsFalseAndNulTerminates) {
char buf[5];
// Literal longer than the buffer: fills up to buf_size-1 and NUL-terminates.
EXPECT_FALSE(mqttSubstituteTopic("abcdefghij", IATA, DEV, TOK, "status", buf, sizeof(buf)));
EXPECT_EQ('\0', buf[4]);
EXPECT_EQ((size_t)4, strlen(buf));
}
TEST(TopicTemplate, LiteralSuffixOverflowAfterSubstitutionReturnsFalse) {
char buf[8];
EXPECT_FALSE(mqttSubstituteTopic("{iata}/tail", IATA, DEV, TOK, "status", buf, sizeof(buf)));
EXPECT_STREQ("DEN/tai", buf);
}
TEST(TopicTemplate, ExactFitLiteralSucceeds) {
char buf[5];
EXPECT_TRUE(mqttSubstituteTopic("abcd", IATA, DEV, TOK, "status", buf, sizeof(buf)));
EXPECT_STREQ("abcd", buf);
}
TEST(TopicTemplate, AlwaysNulTerminatedAndBounded) {
// Fuzz-ish: many buffer sizes never overrun and always NUL-terminate.
const char* tmpl = "meshcore/{iata}/{device}/{token}/{type}/tail";
for (size_t sz = 1; sz <= 80; sz++) {
char buf[96];
memset(buf, 0x7f, sizeof(buf));
mqttSubstituteTopic(tmpl, IATA, DEV, TOK, "packets", buf, sz);
EXPECT_LT(strlen(buf), sz) << "size " << sz; // fits with room for NUL
EXPECT_EQ('\0', buf[strlen(buf)]);
}
}
TEST(TopicTemplate, ZeroBufferOrNullFails) {
char buf[8];
EXPECT_FALSE(mqttSubstituteTopic("x", IATA, DEV, TOK, "status", buf, 0));
EXPECT_FALSE(mqttSubstituteTopic("x", IATA, DEV, TOK, "status", nullptr, 8));
}
TEST(TopicTemplate, EmptyTemplateReturnsFalse) {
char buf[8];
EXPECT_FALSE(mqttSubstituteTopic("", IATA, DEV, TOK, "status", buf, sizeof(buf)));
EXPECT_STREQ("", buf);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,206 @@
// Host contract tests for the pure WebConfig config-batch / reboot / stop state
// machine. This spec mirrors src/helpers/esp32/WebConfigServer.cpp; it is not
// yet wired into the bridge (see WebConfigBatch.h scope note).
#include <gtest/gtest.h>
#include <stdint.h>
#include <limits>
#include "helpers/WebConfigBatch.h"
namespace Batch = WebConfigBatch;
using State = WebConfigBatch::State;
// --------------------------------------------------------------------------
// POST accept classification
// --------------------------------------------------------------------------
TEST(WebConfigBatch, IdleAcceptsANewBatchWithChangesOrRebootOnly) {
// A normal save (changes present) is accepted.
EXPECT_EQ(Batch::PostOutcome::Accept,
Batch::classifyPost(State::Idle, /*reqid_matches=*/false, /*count=*/3, false));
// A reboot-only request (no changes) is still accepted.
EXPECT_EQ(Batch::PostOutcome::Accept,
Batch::classifyPost(State::Idle, false, 0, /*reboot_after=*/true));
}
TEST(WebConfigBatch, IdleWithNothingToDoIsNoChanges) {
EXPECT_EQ(Batch::PostOutcome::NoChanges,
Batch::classifyPost(State::Idle, false, 0, false));
}
TEST(WebConfigBatch, SameReqidReplaysWithoutReapplyingWhilePendingOrDone) {
// The idempotent-replay path fires for BOTH in-flight and finished batches
// when the reqid matches; commands are never re-applied.
EXPECT_EQ(Batch::PostOutcome::Replay,
Batch::classifyPost(State::Pending, /*reqid_matches=*/true, 3, false));
EXPECT_EQ(Batch::PostOutcome::Replay,
Batch::classifyPost(State::Done, /*reqid_matches=*/true, 3, false));
// Replay wins even over a would-be no-changes request.
EXPECT_EQ(Batch::PostOutcome::Replay,
Batch::classifyPost(State::Pending, true, 0, false));
// The replay body reports the batch's own state.
EXPECT_STREQ("pending", Batch::replayStateName(State::Pending));
EXPECT_STREQ("done", Batch::replayStateName(State::Done));
}
TEST(WebConfigBatch, DifferentReqidIsBusyOnlyWhilePending) {
// A second client (different reqid) while a batch is still draining => busy.
EXPECT_EQ(Batch::PostOutcome::Busy,
Batch::classifyPost(State::Pending, /*reqid_matches=*/false, 2, false));
// But once the previous batch is DONE, a different reqid is NOT busy: the new
// batch overwrites the finished slot (asymmetry with the Pending case).
EXPECT_EQ(Batch::PostOutcome::Accept,
Batch::classifyPost(State::Done, false, 2, false));
EXPECT_EQ(Batch::PostOutcome::NoChanges,
Batch::classifyPost(State::Done, false, 0, false));
}
// --------------------------------------------------------------------------
// Drain pacing / all_ok / finish
// --------------------------------------------------------------------------
TEST(WebConfigBatch, DrainNeverWaitsBeforeTheFirstOrAfterTheLastCommand) {
// batch_next == 0: the first command runs immediately (fires onConfigBatchStart).
EXPECT_FALSE(Batch::drainMustWait(0, 5, 1000, 1000));
// batch_next >= batch_count: nothing left to pace.
EXPECT_FALSE(Batch::drainMustWait(5, 5, 1000, 1000));
// A single-command (or reboot-only) batch never paces.
EXPECT_FALSE(Batch::drainMustWait(0, 1, 1000, 1000));
EXPECT_FALSE(Batch::drainMustWait(0, 0, 1000, 1000));
}
TEST(WebConfigBatch, DrainPacesTwentyFiveMillisBetweenCommandsWithInclusiveRelease) {
const uint32_t last = 1000;
// 24 ms after the previous command: still waiting.
EXPECT_TRUE(Batch::drainMustWait(2, 5, last + 24, last));
// Exactly 25 ms: the gate releases (production uses `< 25`).
EXPECT_FALSE(Batch::drainMustWait(2, 5, last + 25, last));
EXPECT_FALSE(Batch::drainMustWait(2, 5, last + 26, last));
}
TEST(WebConfigBatch, DrainPacingSurvivesMillisRollover) {
const uint32_t last = std::numeric_limits<uint32_t>::max() - 10;
EXPECT_TRUE(Batch::drainMustWait(2, 5, last + 24, last)); // 24 ms elapsed, wrapped
EXPECT_FALSE(Batch::drainMustWait(2, 5, last + 25, last)); // 25 ms elapsed, wrapped
}
TEST(WebConfigBatch, AllOkIsAStickyAndAcrossCommandReplies) {
bool all_ok = true;
all_ok = Batch::nextAllOk(all_ok, true);
EXPECT_TRUE(all_ok);
all_ok = Batch::nextAllOk(all_ok, false); // one command failed
EXPECT_FALSE(all_ok);
all_ok = Batch::nextAllOk(all_ok, true); // stays false forever after
EXPECT_FALSE(all_ok);
}
TEST(WebConfigBatch, DrainFinishesWhenTheIndexReachesTheCount) {
EXPECT_FALSE(Batch::drainFinished(4, 5));
EXPECT_TRUE(Batch::drainFinished(5, 5));
EXPECT_TRUE(Batch::drainFinished(0, 0)); // reboot-only / empty batch
}
TEST(WebConfigBatch, FinishArmsThirtySecondFallbackOnlyForAFullyOkRebootBatch) {
const uint32_t now = 100000;
EXPECT_EQ(now + Batch::kRebootFallbackMs,
Batch::finishRebootAt(/*reboot=*/true, /*all_ok=*/true, now));
// A partially-failed batch never reboots.
EXPECT_EQ(0u, Batch::finishRebootAt(true, false, now));
// No reboot requested.
EXPECT_EQ(0u, Batch::finishRebootAt(false, true, now));
}
// --------------------------------------------------------------------------
// Result read
// --------------------------------------------------------------------------
TEST(WebConfigBatch, ResultReadClassifiesIdlePendingDoneAndUnknownReqid) {
// Idle: any valid reqid gets "idle" (no reqid check while idle).
EXPECT_EQ(Batch::ResultOutcome::Idle, Batch::classifyResult(State::Idle, false));
EXPECT_EQ(Batch::ResultOutcome::Idle, Batch::classifyResult(State::Idle, true));
// Matching reqid reflects the batch state.
EXPECT_EQ(Batch::ResultOutcome::Pending, Batch::classifyResult(State::Pending, true));
EXPECT_EQ(Batch::ResultOutcome::Done, Batch::classifyResult(State::Done, true));
// A live/finished batch with a mismatched reqid is unknown (404).
EXPECT_EQ(Batch::ResultOutcome::Unknown, Batch::classifyResult(State::Pending, false));
EXPECT_EQ(Batch::ResultOutcome::Unknown, Batch::classifyResult(State::Done, false));
}
TEST(WebConfigBatch, DoneBodyAdvertisesRebootOnlyWhenFullyOk) {
EXPECT_TRUE(Batch::doneReportsReboot(true, true));
EXPECT_FALSE(Batch::doneReportsReboot(true, false));
EXPECT_FALSE(Batch::doneReportsReboot(false, true));
}
TEST(WebConfigBatch, FirstDoneReadArmsTheThreeSecondRebootExactlyOnce) {
const uint32_t now = 500000;
// First read of a fully-OK reboot batch arms.
EXPECT_TRUE(Batch::shouldArmConfirmReboot(State::Done, true, true, /*already_armed=*/false));
EXPECT_EQ(now + Batch::kRebootConfirmMs, Batch::confirmRebootAt(now));
// Already armed => never re-arms (polling can't push the deadline out).
EXPECT_FALSE(Batch::shouldArmConfirmReboot(State::Done, true, true, /*already_armed=*/true));
// Not applicable while pending, without reboot, or after a partial failure.
EXPECT_FALSE(Batch::shouldArmConfirmReboot(State::Pending, true, true, false));
EXPECT_FALSE(Batch::shouldArmConfirmReboot(State::Done, false, true, false));
EXPECT_FALSE(Batch::shouldArmConfirmReboot(State::Done, true, false, false));
}
// --------------------------------------------------------------------------
// Reboot fire / pending
// --------------------------------------------------------------------------
TEST(WebConfigBatch, RebootFiresAtOrAfterTheDeadlineAndNeverWhenUnscheduled) {
EXPECT_FALSE(Batch::rebootDue(0, 1234567)); // 0 == unscheduled
const uint32_t at = 100000;
EXPECT_FALSE(Batch::rebootDue(at, at - 1));
EXPECT_TRUE(Batch::rebootDue(at, at)); // inclusive boundary
EXPECT_TRUE(Batch::rebootDue(at, at + 1));
}
TEST(WebConfigBatch, RebootDueSurvivesMillisRollover) {
const uint32_t at = std::numeric_limits<uint32_t>::max() - 5; // near the top
EXPECT_FALSE(Batch::rebootDue(at, at - 1));
EXPECT_TRUE(Batch::rebootDue(at, at));
EXPECT_TRUE(Batch::rebootDue(at, at + 10)); // now has wrapped past zero
}
TEST(WebConfigBatch, OnlyAConfigSaveRebootInDoneStateReportsPending) {
EXPECT_TRUE(Batch::isConfigRebootPending(/*reboot_at=*/123, /*batch_reboot=*/true, State::Done));
// Manual /api/reboot: reboot_at set but batch_reboot false => not "pending".
EXPECT_FALSE(Batch::isConfigRebootPending(123, false, State::Done));
// Not yet done, or nothing scheduled.
EXPECT_FALSE(Batch::isConfigRebootPending(123, true, State::Pending));
EXPECT_FALSE(Batch::isConfigRebootPending(0, true, State::Done));
}
// --------------------------------------------------------------------------
// Stop gating
// --------------------------------------------------------------------------
TEST(WebConfigBatch, StopFinalizesOnlyWhenNoHandlersAreInFlight) {
EXPECT_EQ(Batch::StopAction::Finalize,
Batch::stopStep(/*refs=*/0, /*warned=*/false, /*warn_at=*/50000, /*now=*/60000));
}
TEST(WebConfigBatch, StopWarnsOnceAfterTheDeadlineThenKeepsWaiting) {
const uint32_t warn_at = 50000;
// Before the warn deadline with handlers in flight: just wait.
EXPECT_EQ(Batch::StopAction::Wait, Batch::stopStep(2, false, warn_at, warn_at - 1));
// At the deadline, not yet warned: warn once.
EXPECT_EQ(Batch::StopAction::Warn, Batch::stopStep(2, false, warn_at, warn_at));
// Already warned: keep waiting (never warns again, never forces teardown).
EXPECT_EQ(Batch::StopAction::Wait, Batch::stopStep(2, true, warn_at, warn_at + 100000));
// An unscheduled warn timer never warns.
EXPECT_EQ(Batch::StopAction::Wait, Batch::stopStep(2, false, 0, 999999));
}
// --------------------------------------------------------------------------
// Wrap-around guard shared with the production _reboot_at assignments
// --------------------------------------------------------------------------
TEST(WebConfigBatch, ScheduleAtNeverReturnsTheUnscheduledSentinel) {
EXPECT_EQ(1000u + 3000u, Batch::scheduleAt(1000, 3000));
// A deadline that lands exactly on 0 is bumped to 1 so it still means "set".
const uint32_t just_below_wrap = std::numeric_limits<uint32_t>::max(); // +1 wraps to 0
EXPECT_EQ(1u, Batch::scheduleAt(just_below_wrap, 1));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,140 @@
// Host tests for the WebConfig key allowlist / secret / slot-prefix helpers
// (src/helpers/WebConfigKeys.h). These parse attacker-supplied POST keys, so
// coverage of the length-guard and boundary cases matters for safety.
#include <gtest/gtest.h>
#include "helpers/WebConfigKeys.h"
// ---- allowlist ------------------------------------------------------------
TEST(WebConfigKeys, AllowsKnownScalarKeys) {
EXPECT_TRUE(wcIsAllowedSetKey("name"));
EXPECT_TRUE(wcIsAllowedSetKey("radio"));
EXPECT_TRUE(wcIsAllowedSetKey("repeat"));
EXPECT_TRUE(wcIsAllowedSetKey("wifi.ssid"));
EXPECT_TRUE(wcIsAllowedSetKey("mqtt.iata"));
EXPECT_TRUE(wcIsAllowedSetKey("mqtt.neighbors"));
EXPECT_TRUE(wcIsAllowedSetKey("mqtt.neighbors.interval"));
EXPECT_TRUE(wcIsAllowedSetKey("snmp.community"));
EXPECT_TRUE(wcIsAllowedSetKey("timezone.offset"));
}
TEST(WebConfigKeys, AllowsPerSlotKeys) {
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.preset"));
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.server"));
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.token"));
EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.audience")); // MAX_MQTT_SLOTS == 6
}
TEST(WebConfigKeys, RejectsDangerousOrUnknownKeys) {
EXPECT_FALSE(wcIsAllowedSetKey("erase"));
EXPECT_FALSE(wcIsAllowedSetKey("password"));
EXPECT_FALSE(wcIsAllowedSetKey("reboot"));
EXPECT_FALSE(wcIsAllowedSetKey("bogus"));
EXPECT_FALSE(wcIsAllowedSetKey("mqtt1.bogus")); // unknown slot field
EXPECT_FALSE(wcIsAllowedSetKey(""));
}
TEST(WebConfigKeys, AdminPasswordIsNotAnAllowlistedSetKey) {
EXPECT_TRUE(wcIsAdminPasswordKey("password"));
EXPECT_FALSE(wcIsAdminPasswordKey("admin.password"));
EXPECT_FALSE(wcIsAdminPasswordKey(""));
EXPECT_FALSE(wcIsAllowedSetKey("password")); // never reachable as `set password`
}
TEST(WebConfigKeys, AdminPasswordFitsNodePrefsAndRejectsLineBreaks) {
EXPECT_FALSE(wcIsValidAdminPassword(NULL));
EXPECT_FALSE(wcIsValidAdminPassword(""));
EXPECT_TRUE(wcIsValidAdminPassword("new-password"));
EXPECT_TRUE(wcIsValidAdminPassword("123456789012345"));
EXPECT_FALSE(wcIsValidAdminPassword("1234567890123456"));
EXPECT_FALSE(wcIsValidAdminPassword("line\nbreak"));
EXPECT_FALSE(wcIsValidAdminPassword("line\rbreak"));
}
TEST(WebConfigKeys, SlotIndexBoundsMatchMaxSlots) {
EXPECT_FALSE(wcIsAllowedSetKey("mqtt0.preset")); // slot 0 invalid
EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.preset")); // last valid slot
EXPECT_FALSE(wcIsAllowedSetKey("mqtt7.preset")); // beyond MAX_MQTT_SLOTS
EXPECT_FALSE(wcIsAllowedSetKey("mqtt9.preset"));
}
TEST(WebConfigKeys, IsCaseSensitive) {
EXPECT_FALSE(wcIsAllowedSetKey("Name"));
EXPECT_FALSE(wcIsAllowedSetKey("MQTT1.preset"));
}
// ---- short-key OOB guard --------------------------------------------------
// The slot-prefix probe indexes key[4..6]; these short strings must be rejected
// without ever reading past the terminator.
TEST(WebConfigKeys, ShortKeysRejectedSafely) {
EXPECT_FALSE(wcIsSlotKeyPrefix(""));
EXPECT_FALSE(wcIsSlotKeyPrefix("m"));
EXPECT_FALSE(wcIsSlotKeyPrefix("mq"));
EXPECT_FALSE(wcIsSlotKeyPrefix("mqt"));
EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt")); // 4 chars -- no digit/dot
EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt1")); // 5 chars -- no dot
EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt1.")); // 6 chars -- no field char
EXPECT_TRUE(wcIsSlotKeyPrefix("mqtt1.x")); // 7 chars -- minimum valid
// Same guard via the public allowlist/secret entry points:
EXPECT_FALSE(wcIsAllowedSetKey("mqtt"));
EXPECT_FALSE(wcIsSecretKey("m"));
EXPECT_FALSE(wcIsSecretKey("mqtt"));
}
TEST(WebConfigKeys, SlotPrefixDigitRange) {
EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt0.x"));
EXPECT_TRUE(wcIsSlotKeyPrefix("mqtt6.x"));
EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt7.x"));
EXPECT_FALSE(wcIsSlotKeyPrefix("mqttA.x")); // non-digit
}
// ---- secret classification ------------------------------------------------
TEST(WebConfigKeys, SecretKeysDetected) {
EXPECT_TRUE(wcIsSecretKey("wifi.pwd"));
EXPECT_TRUE(wcIsSecretKey("mqtt1.password"));
EXPECT_TRUE(wcIsSecretKey("mqtt3.token"));
EXPECT_TRUE(wcIsSecretKey("mqtt6.password"));
}
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("mqtt.origin"));
EXPECT_FALSE(wcIsSecretKey("name"));
EXPECT_FALSE(wcIsSecretKey(""));
}
TEST(WebConfigKeys, EverySecretKeyIsAlsoAllowed) {
// A secret key must be one the portal can actually set, or the masking is moot.
const char* secrets[] = {"wifi.pwd", "mqtt1.password", "mqtt1.token",
"mqtt6.password", "mqtt6.token"};
for (const char* k : secrets) {
EXPECT_TRUE(wcIsSecretKey(k)) << k;
EXPECT_TRUE(wcIsAllowedSetKey(k)) << k;
}
}
// ---- request correlation -------------------------------------------------
TEST(WebConfigKeys, AcceptsExactHexRequestIds) {
EXPECT_TRUE(wcIsValidReqId("0123456789abcdef"));
EXPECT_TRUE(wcIsValidReqId("ABCDEF0123456789"));
}
TEST(WebConfigKeys, RejectsMissingMalformedOrWrongLengthRequestIds) {
EXPECT_FALSE(wcIsValidReqId(NULL));
EXPECT_FALSE(wcIsValidReqId(""));
EXPECT_FALSE(wcIsValidReqId("0123456789abcde"));
EXPECT_FALSE(wcIsValidReqId("0123456789abcdef0"));
EXPECT_FALSE(wcIsValidReqId("0123456789abcdeg"));
EXPECT_FALSE(wcIsValidReqId("01234567-9abcdef"));
EXPECT_FALSE(wcIsValidReqId("01234567 9abcdef"));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+3 -4
View File
@@ -112,7 +112,6 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -128,7 +127,7 @@ lib_deps =
${Heltec_T190_base.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -205,7 +204,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -221,7 +220,7 @@ lib_deps =
${Heltec_T190_base.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -36,9 +36,14 @@ void HeltecTrackerV2Board::begin() {
}
void HeltecTrackerV2Board::powerOff() {
// Turn off PA
// Turn off PA. Guarded because this board file is also compiled for the
// heltec_tracker_v1_1 envs, which do not define P_LORA_PA_POWER (it is set
// only in variants/heltec_tracker_v2/platformio.ini). Same guard idiom
// LoRaFEMControl.cpp already uses for this macro.
#if defined(P_LORA_PA_POWER)
digitalWrite(P_LORA_PA_POWER, LOW);
rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER);
#endif
ESP32Board::powerOff();
}
@@ -59,7 +64,14 @@ void HeltecTrackerV2Board::begin() {
}
const char* HeltecTrackerV2Board::getManufacturerName() const {
// The v1.1 environment reuses this V2 board implementation (same variant
// dir), so report the correct identity per build flag -- this string feeds
// WebConfig and MQTT status/board metadata.
#ifdef HELTEC_TRACKER_V1_1
return "Heltec Tracker V1.1";
#else
return "Heltec Tracker V2";
#endif
}
bool HeltecTrackerV2Board::setLoRaFemLnaEnabled(bool enable) {
@@ -5,6 +5,7 @@
void LoRaFEMControl::init(void)
{
#if defined(P_LORA_PA_POWER) && defined(P_LORA_KCT8103L_PA_CSD) && defined(P_LORA_KCT8103L_PA_CTX)
pinMode(P_LORA_PA_POWER, OUTPUT);
digitalWrite(P_LORA_PA_POWER, HIGH);
rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER);
@@ -16,32 +17,40 @@ void LoRaFEMControl::init(void)
pinMode(P_LORA_KCT8103L_PA_CTX, OUTPUT);
digitalWrite(P_LORA_KCT8103L_PA_CTX, lna_enabled ? LOW : HIGH);
setLnaCanControl(true);
#endif
}
void LoRaFEMControl::setSleepModeEnable(void)
{
#if defined(P_LORA_KCT8103L_PA_CSD)
// shutdown the PA
digitalWrite(P_LORA_KCT8103L_PA_CSD, LOW);
#endif
}
void LoRaFEMControl::setTxModeEnable(void)
{
#if defined(P_LORA_KCT8103L_PA_CSD) && defined(P_LORA_KCT8103L_PA_CTX)
digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH);
digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH);
#endif
}
void LoRaFEMControl::setRxModeEnable(void)
{
#if defined(P_LORA_KCT8103L_PA_CSD) && defined(P_LORA_KCT8103L_PA_CTX)
digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH);
if (lna_enabled) {
digitalWrite(P_LORA_KCT8103L_PA_CTX, LOW);
} else {
digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH);
}
#endif
}
void LoRaFEMControl::setRxModeEnableWhenMCUSleep(void)
{
#if defined(P_LORA_KCT8103L_PA_CSD) && defined(P_LORA_KCT8103L_PA_CTX)
digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH);
rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CSD);
if (lna_enabled) {
@@ -50,6 +59,7 @@ void LoRaFEMControl::setRxModeEnableWhenMCUSleep(void)
digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH);
}
rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CTX);
#endif
}
void LoRaFEMControl::setLNAEnable(bool enabled)
+196
View File
@@ -58,6 +58,56 @@ lib_deps =
${sensor_base.lib_deps}
bodmer/TFT_eSPI @ ^2.4.31
[Heltec_tracker_v1_1]
extends = Heltec_tracker_v2
board = heltec_tracker_v1_1
build_flags =
${esp32_base.build_flags}
${sensor_base.build_flags}
-I variants/heltec_tracker_v2
-D HELTEC_TRACKER_V1_1
-D ESP32_CPU_FREQ=240
-D USE_SX1262
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D P_LORA_TX_LED=18
-D P_LORA_DIO_1=14
-D P_LORA_NSS=8
-D P_LORA_RESET=12
-D P_LORA_BUSY=13
-D P_LORA_SCLK=9
-D P_LORA_MISO=11
-D P_LORA_MOSI=10
-D LORA_TX_POWER=22
-D SX126X_DIO2_AS_RF_SWITCH=true
-D SX126X_DIO3_TCXO_VOLTAGE=1.8
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D SX126X_REGISTER_PATCH=1
-D PIN_BOARD_SDA=6
-D PIN_BOARD_SCL=17
-D PIN_USER_BTN=0
-D PIN_TFT_SDA=42 ; SDIN
-D PIN_TFT_SCL=41 ; SCLK
-D PIN_TFT_DC=40 ; RS (register select)
-D PIN_TFT_RST=39 ; RES
-D PIN_TFT_CS=38
-D USE_PIN_TFT=1
-D PIN_VEXT_EN=3 ; Vext is connected to VDD which is also connected to OLED & GPS
-D PIN_VEXT_EN_ACTIVE=HIGH
-D PIN_TFT_LEDA_CTL=21 ; LEDK (switches on/off via mosfet to create the ground)
-D DISPLAY_ROTATION=1
-D PIN_GPS_RX=34
-D PIN_GPS_TX=33
-D PIN_GPS_RESET=35
-D PIN_GPS_RESET_ACTIVE=LOW
-D GPS_BAUD_RATE=115200
-D ENV_INCLUDE_GPS=1
-D PIN_ADC_CTRL=2
-D PIN_VBAT_READ=1
build_src_filter = ${Heltec_tracker_v2.build_src_filter}
lib_deps = ${Heltec_tracker_v2.lib_deps}
[env:heltec_tracker_v2_repeater]
extends = Heltec_tracker_v2
build_flags =
@@ -119,6 +169,152 @@ lib_deps =
${Heltec_tracker_v2.lib_deps}
${esp32_ota.lib_deps}
[env:heltec_tracker_v1_1_repeater_observer_mqtt]
extends = Heltec_tracker_v1_1
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 =
${Heltec_tracker_v1_1.build_flags}
-D DISPLAY_CLASS=ST7735Display
-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 MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
build_src_filter = ${Heltec_tracker_v1_1.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/ui/ST7735Display.cpp>
+<../examples/simple_repeater>
lib_deps =
${Heltec_tracker_v1_1.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
[env:heltec_tracker_v2_repeater_observer_mqtt]
extends = Heltec_tracker_v2
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 =
${Heltec_tracker_v2.build_flags}
-D DISPLAY_CLASS=ST7735Display
-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 MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
build_src_filter = ${Heltec_tracker_v2.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/ui/ST7735Display.cpp>
+<../examples/simple_repeater>
lib_deps =
${Heltec_tracker_v2.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
[env:heltec_tracker_v1_1_room_server_observer_mqtt]
extends = Heltec_tracker_v1_1
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 =
${Heltec_tracker_v1_1.build_flags}
-D DISPLAY_CLASS=ST7735Display
-D ADVERT_NAME='"Heltec Tracker 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 MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
build_src_filter = ${Heltec_tracker_v1_1.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/ui/ST7735Display.cpp>
+<../examples/simple_room_server>
lib_deps =
${Heltec_tracker_v1_1.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
[env:heltec_tracker_v2_room_server_observer_mqtt]
extends = Heltec_tracker_v2
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 =
${Heltec_tracker_v2.build_flags}
-D DISPLAY_CLASS=ST7735Display
-D ADVERT_NAME='"Heltec Tracker 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 MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
build_src_filter = ${Heltec_tracker_v2.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/ui/ST7735Display.cpp>
+<../examples/simple_room_server>
lib_deps =
${Heltec_tracker_v2.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
[env:heltec_tracker_v2_terminal_chat]
extends = Heltec_tracker_v2
build_flags =
+27 -11
View File
@@ -129,11 +129,11 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-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 MQTT_MEMORY_DEBUG=1
; Periodic 30s heap/pub-stats serial log -- enable only for debugging (use `get mqtt.stats` on demand instead).
; -D MQTT_MEMORY_DEBUG=1
; Keep default observer profile less verbose to reduce runtime contention.
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
@@ -158,12 +158,22 @@ lib_deps =
${Heltec_lora32_v3.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
0neblock/SNMP_Agent
paulstoffregen/Time@1.6.1
; Emulator build (Wokwi): identical to the observer above but with the LoRa radio
; stubbed (SIM_BUILD -> SimRadio) and WiFi pre-seeded to the Wokwi network so it
; boots straight into WiFi/MQTT/CLI without hardware or flashing. See wokwi/.
[env:Heltec_v3_repeater_observer_mqtt_sim]
extends = env:Heltec_v3_repeater_observer_mqtt
build_flags =
${env:Heltec_v3_repeater_observer_mqtt.build_flags}
-D SIM_BUILD=1
-D SIM_WIFI_SSID='"Wokwi-GUEST"'
[env:Heltec_v3_room_server]
extends = Heltec_lora32_v3
build_flags =
@@ -199,7 +209,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -208,20 +218,23 @@ build_flags =
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
-D WITH_SNMP=1
build_src_filter = ${Heltec_lora32_v3.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/SNMPAgent.cpp>
+<../examples/simple_room_server>
lib_deps =
${Heltec_lora32_v3.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
0neblock/SNMP_Agent
[env:Heltec_v3_terminal_chat]
extends = Heltec_lora32_v3
@@ -329,7 +342,7 @@ lib_deps =
${env:Heltec_v3_companion_radio_wifi.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -521,11 +534,11 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-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 MQTT_MEMORY_DEBUG=1
; Periodic 30s heap/pub-stats serial log -- enable only for debugging (use `get mqtt.stats` on demand instead).
; -D MQTT_MEMORY_DEBUG=1
; Keep default observer profile less verbose to reduce runtime contention.
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
@@ -549,7 +562,7 @@ lib_deps =
${Heltec_lora32_v3.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
0neblock/SNMP_Agent
@@ -570,7 +583,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -580,19 +593,22 @@ build_flags =
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
-D WITH_SNMP=1
build_src_filter = ${Heltec_lora32_v3.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/SNMPAgent.cpp>
+<../examples/simple_room_server>
lib_deps =
${Heltec_lora32_v3.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
0neblock/SNMP_Agent
[env:Heltec_v3_kiss_modem]
extends = Heltec_lora32_v3
+13 -2
View File
@@ -3,6 +3,9 @@
HeltecV3Board board;
#ifdef SIM_BUILD
SimRadio radio_driver(board); // no-op radio for emulator builds
#else
#if defined(P_LORA_SCLK)
static SPIClass spi;
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);
@@ -11,6 +14,7 @@ HeltecV3Board board;
#endif
WRAPPER_CLASS radio_driver(radio, board);
#endif
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
@@ -31,8 +35,10 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
bool radio_init() {
fallback_clock.begin();
rtc_clock.begin(Wire);
#if defined(P_LORA_SCLK)
#ifdef SIM_BUILD
return true; // no SPI radio to bring up
#elif defined(P_LORA_SCLK)
return radio.std_init(&spi);
#else
return radio.std_init();
@@ -40,6 +46,11 @@ bool radio_init() {
}
mesh::LocalIdentity radio_new_identity() {
#ifdef SIM_BUILD
SimRNG rng;
return mesh::LocalIdentity(&rng);
#else
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
#endif
}
+17 -6
View File
@@ -1,10 +1,17 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <HeltecV3Board.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#ifdef SIM_BUILD
// Emulator build (e.g. Wokwi): no SX1262 hardware -- use the no-op SimRadio so
// the firmware boots and runs WiFi/MQTT/CLI/display. See src/helpers/sim/.
#include <helpers/sim/SimRadio.h>
#include <HeltecV3Board.h>
#else
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <HeltecV3Board.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#endif
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#include <helpers/sensors/EnvironmentSensorManager.h>
@@ -14,7 +21,11 @@
#endif
extern HeltecV3Board board;
extern WRAPPER_CLASS radio_driver;
#ifdef SIM_BUILD
extern SimRadio radio_driver;
#else
extern WRAPPER_CLASS radio_driver;
#endif
extern AutoDiscoverRTCClock rtc_clock;
extern EnvironmentSensorManager sensors;
+13 -9
View File
@@ -163,7 +163,6 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -193,7 +192,7 @@ lib_deps =
${heltec_v4_oled.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -215,7 +214,6 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -247,7 +245,7 @@ lib_deps =
${heltec_v4_oled.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -318,7 +316,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -327,20 +325,23 @@ build_flags =
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
-D WITH_SNMP=1
build_src_filter = ${heltec_v4_oled.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/SNMPAgent.cpp>
+<../examples/simple_room_server>
lib_deps =
${heltec_v4_oled.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
0neblock/SNMP_Agent
[env:heltec_v4_expansionkit_room_server_observer_mqtt]
extends = heltec_v4_oled
@@ -358,7 +359,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -369,20 +370,23 @@ build_flags =
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
-D ENV_PIN_SDA=4
-D ENV_PIN_SCL=3
-D WITH_SNMP=1
build_src_filter = ${heltec_v4_oled.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/SNMPAgent.cpp>
+<../examples/simple_room_server>
lib_deps =
${heltec_v4_oled.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
0neblock/SNMP_Agent
[env:heltec_v4_room_server]
extends = heltec_v4_oled
@@ -523,7 +527,7 @@ lib_deps =
${env:heltec_v4_companion_radio_wifi_femon.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
+3 -4
View File
@@ -118,7 +118,6 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
; -D MQTT_DEBUG=1
@@ -142,7 +141,7 @@ lib_deps =
${LilyGo_T3S3_sx1262.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -164,7 +163,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -182,7 +181,7 @@ lib_deps =
${LilyGo_T3S3_sx1262.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
+3 -4
View File
@@ -213,7 +213,6 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D PERSISTANT_GPS=1
@@ -239,7 +238,7 @@ lib_deps =
${LilyGo_TBeam_1W.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -260,7 +259,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D PERSISTANT_GPS=1
@@ -280,7 +279,7 @@ lib_deps =
${LilyGo_TBeam_1W.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
+3 -3
View File
@@ -193,7 +193,7 @@ lib_deps =
${LilyGo_TBeam_SX1262.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -213,7 +213,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -230,7 +230,7 @@ lib_deps =
${LilyGo_TBeam_SX1262.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
+3 -3
View File
@@ -189,7 +189,7 @@ lib_deps =
${LilyGo_TBeam_SX1276.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -209,7 +209,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -226,7 +226,7 @@ lib_deps =
${LilyGo_TBeam_SX1276.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -121,7 +121,6 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -144,7 +143,7 @@ lib_deps =
${T_Beam_S3_Supreme_SX1262.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
@@ -164,7 +163,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -181,7 +180,7 @@ lib_deps =
${T_Beam_S3_Supreme_SX1262.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
+14 -4
View File
@@ -155,7 +155,16 @@ lib_deps =
; Use ONE active WSS broker preset at a time. Two concurrent TLS sessions usually exhaust
; contiguous internal heap; the second slot fails (mbedtls_ssl_setup / esp-tls 0x8017).
; Set extra slots to "none" (e.g. set mqtt2.preset none). See MQTT_IMPLEMENTATION.md.
[env:LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt]
; TEMPORARILY EXCLUDED FROM AUTOMATIC BUILDS (trailing underscore, same
; convention as the nibble_screen_connect envs). This board does not fit:
; flex baseline (no webconfig, no upstream merge): 2,069,397 / 1,966,080 = 105.3%
; It has been silently failing on production for some time -- the observer release
; ships 30 envs, not 32, and build.sh swallowing pio's exit code hid it. Dropping
; webconfig would recover only ~46 KB of a ~101 KB (flex) / ~242 KB (dev) deficit,
; so a NO_WEBCONFIG flag cannot rescue it. ESP32 (not S3), 4 MB flash, already on
; min_spiffs.csv. Real options: single-app partition (fits, but loses OTA), or
; trim ~250 KB for this board. See .scratch/tlora-v2-oversize.md.
[env:LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_]
extends = LilyGo_TLora_V2_1_1_6_core
; Preserve two OTA app slots while reclaiming coredump/half of SPIFFS for the MQTT repeater image.
board_build.partitions = variants/lilygo_tlora_v2_1/dual_ota_1984k.csv
@@ -200,13 +209,14 @@ build_src_filter = ${LilyGo_TLora_V2_1_1_6_core.build_src_filter}
lib_deps =
${LilyGo_TLora_V2_1_1_6_core.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
; Same single-broker guidance as LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt (see comment block above).
[env:LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt]
; TEMPORARILY EXCLUDED -- see the repeater env above.
[env:LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt_]
extends = LilyGo_TLora_V2_1_1_6_core
extra_scripts =
${esp32_base.extra_scripts}
@@ -242,7 +252,7 @@ build_src_filter = ${LilyGo_TLora_V2_1_1_6_core.build_src_filter}
lib_deps =
${LilyGo_TLora_V2_1_1_6_core.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
+4 -1
View File
@@ -25,6 +25,9 @@ void RAK11310Board::begin() {
delay(10); // give sx1262 some time to power up
}
bool RAK11310Board::startOTAUpdate(const char *id, char reply[]) {
bool RAK11310Board::startOTAUpdate(const char *id, char reply[], bool force_ap) {
(void)id;
(void)reply;
(void)force_ap;
return false;
}
+1 -1
View File
@@ -45,5 +45,5 @@ public:
void reboot() override { rp2040.reboot(); }
bool startOTAUpdate(const char *id, char reply[]) override;
bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override;
};
+6 -4
View File
@@ -113,7 +113,6 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -139,7 +138,7 @@ lib_deps =
${rak3112.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
0neblock/SNMP_Agent
@@ -177,7 +176,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_NEIGHBOURS=50
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
@@ -186,19 +185,22 @@ build_flags =
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
-D WITH_SNMP=1
build_src_filter = ${rak3112.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/SNMPAgent.cpp>
+<../examples/simple_room_server>
lib_deps =
${rak3112.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
0neblock/SNMP_Agent
[env:RAK_3112_terminal_chat]
extends = rak3112
+4 -1
View File
@@ -37,6 +37,9 @@ void PicoWBoard::begin() {
delay(10); // give sx1262 some time to power up
}
bool PicoWBoard::startOTAUpdate(const char* id, char reply[]) {
bool PicoWBoard::startOTAUpdate(const char* id, char reply[], bool force_ap) {
(void)id;
(void)reply;
(void)force_ap;
return false;
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more