merge: observer-firmware-dev into observer-firmware at v1.17.0
The planned wholesale dev -> prod merge at upstream MeshCore 1.17.0. Production
now adopts the dev lineage in full; the two release channels stay separated by
workflow, not by branch content.
Resolution: prod's tree is now identical to observer-firmware-dev. Prod's 23
observer commits were sibling hand-ports of features dev implements more fully
(neighbors, max-active-slots, retain policy, MeshRank routing, SNMP, deferred
OTA), so dev's implementation wins throughout. Verified no prod-only content was
lost: MQTT broker presets are at parity (34 both sides), and the sensor_base
build flags that prod added for Heltec/RAK3112/Xiao S3 WIO are all present.
Three files auto-merged into an inconsistent mix and were forced to dev:
- examples/simple_repeater/MyMesh.h: kept BOTH lineages' WITH_MQTT_NEIGHBORS
blocks, redeclaring neighbor_discover[] and declaring sendAnonRegionsReq()
twice with different return types. Would not have compiled.
- src/helpers/MQTTDefaults.h: duplicate neighbors default assignments (dev
already sets them further down).
- .wt-station-g3-prod: stray tracked build worktree, untracked on dev and
covered by dev's .gitignore '.wt-*/' rule.
Prod's station-g3 OTA hotfix (5000391c) hardcoded the production manifest URL
in build_flags; dropped in favour of dev's env, since build.sh already defaults
OTA_MANIFEST_BASE to the production URL when the beta workflow does not override
it. Verified on the built binary.
Channels after this merge:
- observer-firmware -> v1.17.0, tag observer-mqtt-latest, manifest /v
- observer-firmware-dev -> v1.17.0, tag observer-mqtt-beta-latest, manifest /beta/v
The beta workflow now also lives on prod but only triggers on the dev branch.
This commit is contained in:
commit
2668ed87d6
429 files changed
+30549
-3436
No files matched your search
@@ -0,0 +1 @@
|
||||
github: meshcore-dev
|
||||
@@ -4,7 +4,7 @@ runs:
|
||||
steps:
|
||||
|
||||
- name: Init Cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
@@ -12,9 +12,9 @@ runs:
|
||||
key: ${{ runner.os }}-pio
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install PlatformIO
|
||||
shell: bash
|
||||
@@ -25,5 +25,14 @@ runs:
|
||||
- name: Extract Version from Git Tag
|
||||
shell: bash
|
||||
run: |
|
||||
GIT_TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
echo "GIT_TAG_VERSION=${GIT_TAG_NAME##*-}" >> $GITHUB_ENV
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
# triggered by a tag push (e.g: refs/tags/companion-v1.2.3)
|
||||
GIT_TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
VERSION_STRING="${GIT_TAG_NAME##*-}"
|
||||
else
|
||||
# triggered by a workflow dispatch (e.g: refs/heads/main)
|
||||
# strip "refs/heads/" prefix and replace any remaining "/" with "-" to protect file paths
|
||||
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
|
||||
VERSION_STRING=$(echo "$BRANCH_NAME" | tr '/' '-')
|
||||
fi
|
||||
echo "GIT_TAG_VERSION=${VERSION_STRING}" >> $GITHUB_ENV
|
||||
@@ -10,33 +10,8 @@ on:
|
||||
- 'companion-*'
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Build Environment
|
||||
uses: ./.github/actions/setup-build-environment
|
||||
|
||||
- name: Build Firmwares
|
||||
env:
|
||||
FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }}
|
||||
run: /usr/bin/env bash build.sh build-companion-firmwares
|
||||
|
||||
- name: Upload Workflow Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: companion-firmwares
|
||||
path: out
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
name: Companion Firmware ${{ env.GIT_TAG_VERSION }}
|
||||
body: ""
|
||||
draft: true
|
||||
files: out/*
|
||||
build-companion-firmwares:
|
||||
uses: ./.github/workflows/firmware-builder.yml
|
||||
with:
|
||||
firmware_type: 'companion'
|
||||
release_title_prefix: 'Companion Firmware'
|
||||
@@ -0,0 +1,377 @@
|
||||
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/**'
|
||||
# Throwaway build worktrees never affect the binaries.
|
||||
- '.build-wt-*'
|
||||
- '.build-wt-*/**'
|
||||
|
||||
# Own group: serialize beta builds against each other only. Concurrency groups are
|
||||
# repo-wide, not per-branch, so sharing one with the production workflows let an
|
||||
# in-flight beta build force a production build into the pending state, where the
|
||||
# next run queued into the group cancelled it outright. Concurrent flasher pushes
|
||||
# are instead made safe by the rebase-retry in "Commit & Push Beta Artifacts".
|
||||
concurrency:
|
||||
group: observer-firmware-beta-build
|
||||
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.17.0
|
||||
|
||||
# Beta-only rolling release. A separate tag is required, not cosmetic: the
|
||||
# publish step prunes all but the KEEP_BUILDS most recent build hashes within
|
||||
# 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>⚠ 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 }})"
|
||||
# The production workflows can be pushing to the flasher repo right now
|
||||
# (no shared concurrency group any more), so a non-fast-forward is expected
|
||||
# rather than fatal: rebase onto their commit and retry. The scoped add
|
||||
# above keeps this commit inside the beta paths, so a conflict with
|
||||
# production's files is not possible.
|
||||
br=$(git rev-parse --abbrev-ref HEAD)
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin "HEAD:$br"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "push rejected (attempt $attempt); rebasing onto origin/$br"
|
||||
git fetch origin "$br"
|
||||
# --autostash: the scoped add above leaves unrelated edits in the
|
||||
# checkout unstaged on purpose, and plain rebase refuses to run with a
|
||||
# dirty tree. They stay unstaged, so they still never get published.
|
||||
git rebase --autostash "origin/$br" || {
|
||||
git rebase --abort || true
|
||||
echo "::error::flasher rebase conflicted; re-run this workflow to republish"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "::error::could not push beta changes after 3 attempts"
|
||||
exit 1
|
||||
@@ -41,7 +41,7 @@ concurrency:
|
||||
env:
|
||||
# Version embedded in firmware filenames; must match the version key in the
|
||||
# flasher's config.json. Bump here when the observer version changes.
|
||||
FIRMWARE_VERSION: v1.16.0
|
||||
FIRMWARE_VERSION: v1.17.0
|
||||
# Rolling release tag that hosts the latest observer binaries.
|
||||
RELEASE_TAG: observer-mqtt-latest
|
||||
# Download host serving RELEASE_TAG's assets (cloudflare-worker in the flasher
|
||||
|
||||
@@ -10,33 +10,8 @@ on:
|
||||
- 'repeater-*'
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Build Environment
|
||||
uses: ./.github/actions/setup-build-environment
|
||||
|
||||
- name: Build Firmwares
|
||||
env:
|
||||
FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }}
|
||||
run: /usr/bin/env bash build.sh build-repeater-firmwares
|
||||
|
||||
- name: Upload Workflow Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: repeater-firmwares
|
||||
path: out
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
name: Repeater Firmware ${{ env.GIT_TAG_VERSION }}
|
||||
body: ""
|
||||
draft: true
|
||||
files: out/*
|
||||
build-repeater-firmwares:
|
||||
uses: ./.github/workflows/firmware-builder.yml
|
||||
with:
|
||||
firmware_type: 'repeater'
|
||||
release_title_prefix: 'Repeater Firmware'
|
||||
@@ -10,33 +10,8 @@ on:
|
||||
- 'room-server-*'
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Build Environment
|
||||
uses: ./.github/actions/setup-build-environment
|
||||
|
||||
- name: Build Firmwares
|
||||
env:
|
||||
FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }}
|
||||
run: /usr/bin/env bash build.sh build-room-server-firmwares
|
||||
|
||||
- name: Upload Workflow Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: room-server-firmwares
|
||||
path: out
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
name: Room Server Firmware ${{ env.GIT_TAG_VERSION }}
|
||||
body: ""
|
||||
draft: true
|
||||
files: out/*
|
||||
build-room-server-firmwares:
|
||||
uses: ./.github/workflows/firmware-builder.yml
|
||||
with:
|
||||
firmware_type: 'room-server'
|
||||
release_title_prefix: 'Room Server Firmware'
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Firmware Builder
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
firmware_type:
|
||||
required: true
|
||||
type: string
|
||||
release_title_prefix:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
|
||||
generate-build-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
targets: ${{ steps.get-build-targets.outputs.targets }}
|
||||
steps:
|
||||
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Build Environment
|
||||
uses: ./.github/actions/setup-build-environment
|
||||
|
||||
- name: Get Build Targets
|
||||
id: get-build-targets
|
||||
run: |
|
||||
# get list of firmwares to build
|
||||
TARGET_LIST=$(/usr/bin/env bash build.sh get-${{ inputs.firmware_type }}-firmwares-to-build)
|
||||
|
||||
# convert targets separated by new line into a json array string
|
||||
JSON_ARRAY=$(echo "$TARGET_LIST" | jq -R -s -c 'split("\n") | map(select(length > 0))')
|
||||
|
||||
# use json array as targets result
|
||||
echo "targets=$JSON_ARRAY" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
needs: generate-build-matrix
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # don't fail entire build if one board fails to build
|
||||
strategy:
|
||||
matrix:
|
||||
target: ${{ fromJson(needs.generate-build-matrix.outputs.targets) }}
|
||||
fail-fast: false # don't cancel other builds if one board fails to build
|
||||
steps:
|
||||
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Build Environment
|
||||
uses: ./.github/actions/setup-build-environment
|
||||
|
||||
- name: Build Firmware
|
||||
env:
|
||||
FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }}
|
||||
run: /usr/bin/env bash build.sh build-firmware ${{ matrix.target }}
|
||||
|
||||
- name: Upload Workflow Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: "${{ matrix.target }}"
|
||||
path: out
|
||||
|
||||
create-release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/') # only create release for tagged builds
|
||||
steps:
|
||||
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Build Environment
|
||||
uses: ./.github/actions/setup-build-environment
|
||||
|
||||
- name: Download All Artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: out
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: "${{ inputs.release_title_prefix }} ${{ env.GIT_TAG_VERSION }}"
|
||||
body: ""
|
||||
draft: true
|
||||
files: out/*
|
||||
@@ -15,12 +15,12 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
ruby-version: 3.x
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
mkdocs build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
uses: peaceiris/actions-gh-pages@v4.1.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cname: docs.meshcore.io
|
||||
|
||||
@@ -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,10 +47,16 @@ 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
|
||||
- RAK_4631_repeater
|
||||
- RAK_4631_repeater_ethernet
|
||||
- RAK_4631_room_server
|
||||
- RAK_4631_room_server_ethernet
|
||||
# RP2040
|
||||
- PicoW_repeater
|
||||
# STM32
|
||||
@@ -46,7 +70,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Build Environment
|
||||
uses: ./.github/actions/setup-build-environment
|
||||
|
||||
@@ -19,8 +19,13 @@ 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 -vv
|
||||
run: pio test -e native -e native_kiss_modem -vv
|
||||
|
||||
- name: Upload Test Results
|
||||
# Upload test results even if the test step failed.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: 'Run Stale Bot'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *' # daily at 1:30am
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
# only run on main repo, not forks
|
||||
if: github.repository == 'meshcore-dev/MeshCore'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close Stale Issues
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# auto close issues
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 7
|
||||
exempt-issue-labels: "keep-open"
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 60 days with no activity. Remove the stale label or add a comment if this issue is still relevant, otherwise this issue will automatically close in 7 days."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale."
|
||||
# don't auto close prs
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
|
||||
@@ -19,6 +19,9 @@ on:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- 'scripts/gen_changelog.py'
|
||||
# /webconfig frames the real portal page against a browser simulator, so
|
||||
# the page itself is a synced doc asset like the .md files above.
|
||||
- 'webui/index.html'
|
||||
|
||||
# Own group: serialize doc syncs against each other only. Deliberately NOT shared
|
||||
# with build-observer-firmwares.yml — a shared group let one workflow cancel the
|
||||
@@ -59,6 +62,21 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Sync the WebConfig Portal Page into Flasher
|
||||
run: |
|
||||
# flasher/webconfig.html embeds the REAL portal page in a device
|
||||
# surround and runs it against lib/webconfig-sim.js, which intercepts
|
||||
# fetch() so no device is needed. Syncing the page is what keeps that
|
||||
# demo honest — the alternative is screenshots, which go stale without
|
||||
# anyone noticing. The build script only injects a <script> tag; it
|
||||
# refuses a page that already carries the simulator.
|
||||
if [ -f webui/index.html ]; then
|
||||
python3 flasher/scripts/build-webconfig-demo.py webui/index.html
|
||||
echo "rebuilt flasher/webconfig-demo.html from webui/index.html"
|
||||
else
|
||||
echo "WARNING: webui/index.html not found; leaving the demo as-is" >&2
|
||||
fi
|
||||
|
||||
- name: Generate Changelog
|
||||
run: |
|
||||
# Append-only and idempotent; preserves the hand-curated history and
|
||||
|
||||
+7
-3
@@ -1,5 +1,6 @@
|
||||
.direnv
|
||||
.pio
|
||||
.build-wt-*
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
@@ -18,13 +19,16 @@ 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/*
|
||||
.claude/*
|
||||
.cursorrules
|
||||
src/helpers/esp32/WebConfigHtml.h
|
||||
# Throwaway build worktrees; committing one adds a stray gitlink that makes every
|
||||
# CI checkout warn "No url found for submodule path ... in .gitmodules".
|
||||
# Worktrees checked out inside the repo. Committing one adds a stray gitlink
|
||||
# that makes every CI checkout warn "No url found for submodule path ... in
|
||||
# .gitmodules", and leaves `git status` permanently dirty so the next `git add
|
||||
# -A` re-commits it. .wt-* covers the hand-made ones; .build-wt-* the CI ones.
|
||||
.build-wt-*/
|
||||
.wt-*/
|
||||
scripts/__pycache__/*
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"platformio.platformio-ide"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"ms-vscode.cpptools-extension-pack"
|
||||
]
|
||||
}
|
||||
Submodule .wt-station-g3-prod deleted from bfc43e94f8.
@@ -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)
|
||||
|
||||
+221
-23
@@ -177,6 +177,16 @@ Fresh installs default to slot 1 `analyzer-us`, slot 2 `analyzer-eu`, and slots
|
||||
- Slots configured beyond what the device supports show as `(inactive)` in `get mqtt.status`
|
||||
- Slot configuration is preserved in preferences — moving the firmware to a PSRAM device activates the rest
|
||||
|
||||
**Neighbors publication without PSRAM.** PSRAM boards get `WITH_MQTT_NEIGHBORS`
|
||||
automatically. Non-PSRAM boards opt in per variant with
|
||||
`-D MQTT_NEIGHBORS_WITHOUT_PSRAM=1`, which is set on the ESP32-S3 observer envs
|
||||
(Heltec V3/WSL3, RAK3112, Heltec Tracker v1.1/v2). It costs ~7.4 KB of static DRAM
|
||||
(~9.6 KB on room servers) and up to ~13 KB transiently per publish, and caps the
|
||||
table at 20 entries — so the feature is deliberately **not** enabled on the classic
|
||||
ESP32 T-LoRa V2.1–1.6 observer builds, which are already down to one active TLS
|
||||
slot. Publishing peaks while the table is built, so keep the slot guidance above
|
||||
in mind: the peak lands on the same internal heap the TLS stack draws from.
|
||||
|
||||
## Build Configuration
|
||||
|
||||
To build the MQTT bridge firmware:
|
||||
@@ -204,8 +214,18 @@ pio run -e Station_G3_ESP32_room_server_observer_mqtt
|
||||
# LilyGo T-LoRa V2.1-1.6 (TTGO LoRa32 V1.0)
|
||||
pio run -e LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt
|
||||
pio run -e LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt
|
||||
|
||||
# Elecrow ThinkNode M7
|
||||
pio run -e ThinkNode_M7_repeater_observer_mqtt
|
||||
pio run -e ThinkNode_M7_room_server_observer_mqtt
|
||||
```
|
||||
|
||||
**ThinkNode M7 — WiFi only:** the M7 has an onboard CH390 Ethernet controller, and
|
||||
`ThinkNode_M7_companion_radio_ethernet` uses it, but the MQTT bridge's link
|
||||
management is bound to the WiFi station API, so the observer envs uplink over WiFi.
|
||||
See `UPSTREAM_BUGS.md` for the Ethernet gap. The board has PSRAM, so these builds
|
||||
get neighbors publication (`WITH_MQTT_NEIGHBORS`) automatically.
|
||||
|
||||
**TLora naming:** The env prefix `LilyGo_TLora_V2_1_1_6` is LilyGo’s **T-LoRa V2.1–1.6** board (SX1276); PlatformIO selects **`ttgo-lora32-v1`** (TTGO LoRa32 V1.0). **MQTT observer** envs extend a slim base **without** `sensor_base` so the image fits `min_spiffs`; **all other** `LilyGo_TLora_V2_1_1_6_*` targets still use optional I2C environmental sensors as before. The **`lilygo_tlora_c6`** variant is separate hardware (ESP32-C6).
|
||||
|
||||
**T-LoRa V2.1–1.6 MQTT observer — one WSS broker:** This hardware is **classic ESP32 without PSRAM**. Each WSS preset uses a full TLS stack and large contiguous heap allocations; **two active broker presets at once** typically fails the second connection (`mbedtls_ssl_setup` / `esp-tls` `0x8017`, low `IntMax` in `memory`). **Treat these observer builds as supporting one active cloud preset:** configure the broker you need in `mqtt1` or `mqtt2`, and set the other slot to `none` (e.g. `set mqtt2.preset none`). Use PSRAM-capable boards if you need multiple simultaneous MQTT uplinks.
|
||||
@@ -227,6 +247,9 @@ Some MQTT observer builds use a non-default partition table to accommodate the l
|
||||
| `LilyGo_TBeam_1W_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | Set in `boards/t_beam_1w.json`; required vs implicit `default.csv` |
|
||||
| `LilyGo_TBeam_1W_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | same |
|
||||
|
||||
Boards absent from this table need no special first flash: their observer envs use the
|
||||
same partition table as the board's other firmwares.
|
||||
|
||||
**Settings loss when the layout changes**
|
||||
|
||||
A merged image writes a new bootloader **and** partition table at offset `0x0`. If that
|
||||
@@ -300,6 +323,7 @@ The MQTT bridge comes with the following defaults for fresh installs (unless ove
|
||||
- **Slot 1**: `analyzer-us`
|
||||
- **Slot 2**: `analyzer-eu`
|
||||
- **Slots 3-6**: `none` (disabled)
|
||||
- **Per-slot packet filters**: `all` (every payload type is uploaded)
|
||||
- **WiFi SSID**: (blank — must be configured)
|
||||
- **WiFi Password**: (blank — optional for open networks)
|
||||
- **WiFi Power Save**: `none` (no power save)
|
||||
@@ -324,6 +348,7 @@ Each slot (1-6) supports the following commands:
|
||||
- `get mqttN.token` - Get per-slot token (e.g., MeshRank account token)
|
||||
- `get mqttN.topic` - Get custom topic template for slot N
|
||||
- `get mqttN.audience` - Get JWT audience for slot N (custom slots only)
|
||||
- `get mqttN.filter` - Get the slot's packet-type allowlist (`all`, `none`, or numeric CSV)
|
||||
|
||||
#### Set Commands
|
||||
- `set mqttN.preset <name>` - Set slot N to a built-in preset. Use any `name` from [Broker Presets](#broker-presets), which also lists the few presets needing extra setup.
|
||||
@@ -337,9 +362,72 @@ Each slot (1-6) supports the following commands:
|
||||
- `set mqttN.topic <template>` - Set custom topic template (custom preset only, see below)
|
||||
- `set mqttN.audience <audience>` - Set JWT audience for custom slot (enables Ed25519 JWT auth)
|
||||
- `set mqttN.audience` - Clear JWT audience (reverts to username/password auth)
|
||||
- `set mqttN.filter <all|none|list>` - Select payload types uploaded to this slot
|
||||
|
||||
**Note:** Custom server/port settings only apply when the slot's preset is `custom`. Username/password also apply to built-in presets that use per-slot credentials (e.g. `inwmesh`); other userpass presets (`tennmesh`, `nashmesh`, `ctmesh`) ship fixed credentials in firmware.
|
||||
|
||||
#### Per-broker packet filters
|
||||
|
||||
Each slot has an independent allowlist. List entries may be payload-type names
|
||||
or numbers, and the two can be mixed. These are all equivalent, sending only
|
||||
text messages and adverts to slot 1:
|
||||
|
||||
```bash
|
||||
set mqtt1.filter txt_msg,advert
|
||||
set mqtt1.filter 2,4
|
||||
set mqtt1.filter advert, 2
|
||||
```
|
||||
|
||||
Use `none` when a broker should remain connected for status/neighbors but
|
||||
receive no packet traffic. A bare `set mqttN.filter` resets the slot to `all`.
|
||||
`get mqttN.filter` always answers in the canonical numeric form (`all`, `none`,
|
||||
or an ascending CSV), whichever spelling was used to set it.
|
||||
|
||||
| Type | Name | Type | Name |
|
||||
|------|------|------|------|
|
||||
| 0 | `req` | 8 | `path` |
|
||||
| 1 | `response` | 9 | `trace` |
|
||||
| 2 | `txt_msg` | 10 | `multipart` |
|
||||
| 3 | `ack` | 11 | `control` |
|
||||
| 4 | `advert` | 12-14 | reserved (number only) |
|
||||
| 5 | `grp_txt` | 15 | `raw_custom` |
|
||||
| 6 | `grp_data` | | |
|
||||
| 7 | `anon_req` | | |
|
||||
|
||||
Names are lowercase and exact; types 12-14 are reserved upstream and have no
|
||||
name, so they are selectable by number only.
|
||||
|
||||
The filter applies to both structured `packets` and `raw` publications for RX
|
||||
packets and for TX packets permitted by `mqtt.tx`. It does not affect local
|
||||
packet processing, forwarding, capture logs, status, or neighbors. Changes
|
||||
apply live without reconnecting the broker.
|
||||
|
||||
A rejected packet is dropped before it is copied into the publish queue, so a
|
||||
narrow filter saves the queue slot and the per-packet work, not just the
|
||||
upload. `get mqtt.stats` reports the running count as `filt=<n>`, and a slot
|
||||
whose filter is not `all` shows it in `get mqttN.diag` and on the WebConfig
|
||||
Stats tab — a filtered slot otherwise looks identical to an idle healthy one.
|
||||
|
||||
The diag reply is capped at 160 characters. When a slot is also reporting a
|
||||
long error tail, the filter is summarised as `filter:<n>/16` rather than
|
||||
listed, because a list clipped mid-way would read as a different, valid
|
||||
allowlist. `get mqttN.filter` always gives the exact value.
|
||||
|
||||
In WebConfig the allowlist is a checkbox per type under each configured slot,
|
||||
with **All** / **None** shortcuts. Clearing every box is `none` (nothing
|
||||
uploaded).
|
||||
|
||||
**Downgrade note:** rolling back to any build from this release onward is safe —
|
||||
the older firmware reads the settings it understands and simply ignores the
|
||||
packet filters, which revert to `all` if it saves.
|
||||
|
||||
Rolling back to a build released *before* this one is the case to watch: that
|
||||
firmware rejects the longer settings file outright and falls back to defaults,
|
||||
losing the stored WiFi credentials along with the broker config. Slots left at
|
||||
the `all` default keep the file in the shorter layout those builds can read, so
|
||||
if you may need to roll a node back that far, reset every slot to `all` first.
|
||||
|
||||
|
||||
#### Example: MeshRank
|
||||
|
||||
MeshRank needs an account token, generated on the MeshRank website and tied to your account:
|
||||
@@ -348,10 +436,11 @@ set mqtt3.preset meshrank
|
||||
set mqtt3.token FE1B34242C5938C39225310081FD6718
|
||||
```
|
||||
|
||||
It receives the same message types as any other preset (`status`, `packets`, `raw`,
|
||||
`neighbors`), each under `meshrank/uplink/{token}/{device_id}/`, subject to the usual
|
||||
`mqtt.status` / `mqtt.raw` / `mqtt.neighbors` toggles. Its broker does not accept the retain
|
||||
flag, so those publishes go out unretained.
|
||||
It receives status, packets, and neighbors under `meshrank/uplink/{token}/{device}/`, using
|
||||
the same type suffixes as the MeshCore layout. Raw is **not** sent to MeshRank — it is the
|
||||
highest-volume topic and the broker does not consume it — so `set mqtt.raw on` has no effect
|
||||
on a MeshRank slot. Its broker does not accept the retain flag, so those publishes go out
|
||||
unretained.
|
||||
|
||||
### Custom Brokers
|
||||
|
||||
@@ -426,8 +515,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 publish (PSRAM builds: on/off)
|
||||
- `get mqtt.neighbors.interval` - Get neighbors publish interval in hours
|
||||
- `get mqtt.neighbors` - Get periodic neighbors publishing setting (on/off; neighbors-enabled builds)
|
||||
- `get mqtt.neighbors.interval` - Get neighbors publish interval in hours (neighbors-enabled builds)
|
||||
- `get mqtt.ntp` - Get effective NTP server hostname
|
||||
- `get mqtt.ntp.diag` - Probe every configured NTP server for connectivity (does not change the clock; serial console shows each server's reported time, LoRa shows a compact `<server> ok|fail` list)
|
||||
- `get mqtt.owner` - Get owner public key (serial console only)
|
||||
@@ -445,8 +534,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/scopes publish (PSRAM builds only)
|
||||
- `set mqtt.neighbors.interval <hours>` - Set neighbors publish interval (12-336 hours, default 24)
|
||||
- `set mqtt.neighbors on|off` - Enable/disable periodic neighbors publishing (neighbors-enabled builds; read live, no restart)
|
||||
- `set mqtt.neighbors.interval <hours>` - Set neighbors publish interval (12-336 hours, default 24; neighbors-enabled builds)
|
||||
- `set mqtt.ntp <hostname>` - Set custom NTP server (validated with immediate sync); `none` reverts to default
|
||||
- `set mqtt.owner <64-hex-char-public-key>` - Set owner public key
|
||||
- `set mqtt.email <email>` - Set owner email address
|
||||
@@ -529,6 +618,87 @@ connectivity, memory usage, and network information to standard monitoring tools
|
||||
|
||||
See [MQTT_SNMP.md](MQTT_SNMP.md) for setup and the full OID reference.
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
## MQTT Topics
|
||||
|
||||
The bridge publishes to four main topics with the following structure:
|
||||
@@ -543,7 +713,7 @@ Full packet data with RF characteristics and metadata.
|
||||
Minimal raw packet data for map integration.
|
||||
|
||||
### Neighbors Topic: `meshcore/{IATA}/{DEVICE_PUBLIC_KEY}/neighbors`
|
||||
Cached zero-hop repeater neighbors with SNR, last-heard age, and flood-allowed scopes. Published on `discover.scopes` or periodically when `mqtt.neighbors` is enabled (PSRAM observer builds only). Goes to every configured slot's `neighbors` topic at QoS 1, retained only where the preset allows it.
|
||||
Cached zero-hop repeater neighbors with SNR, last-heard age, and flood-allowed scopes. Published on `discover.scopes` or periodically when `mqtt.neighbors` is enabled (observer builds with neighbors compiled in; non-PSRAM builds cap the table at 20 entries and set `truncated`). Goes to every configured slot's `neighbors` topic at QoS 0, retained only where the preset allows it.
|
||||
|
||||
Periodic publishing first runs a 60-second zero-hop neighbor refresh equivalent to `discover.neighbors`, then queries the refreshed table for scopes and publishes when the scope-query phase completes.
|
||||
|
||||
@@ -629,30 +799,58 @@ While `mqtt.neighbors` is on, `get mqtt.status` appends `nbr: <next>/<last>` —
|
||||
}
|
||||
```
|
||||
|
||||
### Neighbors Message (PSRAM observer builds)
|
||||
### Neighbors Message (neighbors-enabled observer builds)
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-06-07T12:00:00.000000+00:00",
|
||||
"origin": "MQTT Observer",
|
||||
"origin_id": "DEVICE_PUBLIC_KEY",
|
||||
"self": {
|
||||
"scopes": "Europe,UK,France"
|
||||
},
|
||||
"timestamp": "2024-01-01T12:00:00.000000+00:00",
|
||||
"origin": "MeshCore-HOWL",
|
||||
"origin_id": "A1B2C3D4E5F67890...",
|
||||
"total_neighbors": 2,
|
||||
"queried_neighbors": 2,
|
||||
"truncated": false,
|
||||
"self": { "scopes": "DEN,APRS", "default_scope": "*" },
|
||||
"neighbors": [
|
||||
{
|
||||
"pubkey": "NEIGHBOR_PUBLIC_KEY",
|
||||
"snr": 8.5,
|
||||
"heard_secs_ago": 120,
|
||||
"scopes": "*,Europe",
|
||||
"pubkey": "0011223344556677...",
|
||||
"snr": 9.75,
|
||||
"heard_secs_ago": 42,
|
||||
"scopes": "DEN,APRS",
|
||||
"status": "responded"
|
||||
},
|
||||
{
|
||||
"pubkey": "8899AABBCCDDEEFF...",
|
||||
"snr": 12.5,
|
||||
"heard_secs_ago": null,
|
||||
"scopes": "DEN",
|
||||
"status": "responded"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Entries are ordered most- to least-useful (usable age first, then most recently
|
||||
heard, then stronger SNR); the tail is dropped if the payload would exceed the
|
||||
10 KB publish buffer. `status` is `responded`, `timeout`, or `send_failed` per
|
||||
neighbor.
|
||||
|
||||
**Notes:**
|
||||
- `status` per neighbor: `responded`, `timeout`, or `send_failed` (scope query only; `self` has no status field).
|
||||
- Trigger with `discover.scopes` or enable periodic publish via `set mqtt.neighbors on`.
|
||||
`total_neighbors` is the size of the neighbor-table snapshot the cycle started
|
||||
from, `queried_neighbors` how many scope requests were confirmed transmitted, and
|
||||
`truncated` whether the buffer filled before every entry fit. All three are
|
||||
always present. The `neighbors` array can therefore be shorter than
|
||||
`total_neighbors` — compare its length against that field rather than assuming
|
||||
the table is complete.
|
||||
|
||||
`heard_secs_ago` is `null`, as in the second entry above, when the age cannot be
|
||||
computed: the neighbor was last heard before the clock was set, so the stored
|
||||
stamp and the current clock come from different epochs and their difference is
|
||||
meaningless (see `UPSTREAM_BUGS.md` #1). **Consumers must treat `null` as
|
||||
unknown, not as zero** — the key is always present, so a missing key means an
|
||||
older firmware, and a `null` never means "heard just now". A neighbor that
|
||||
answers the scope query has its stamp refreshed, so a `null` age normally clears
|
||||
itself on the next publish cycle.
|
||||
|
||||
`self.default_scope` is the region name this node floods to by default (`region
|
||||
default`); it is `*` when no default region is set, matching the unscoped flood
|
||||
the radio actually performs in that case.
|
||||
|
||||
## Key Features
|
||||
|
||||
|
||||
+89
-3
@@ -57,6 +57,53 @@ 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. A reply is zero-hop by
|
||||
request, so `handleNeighborDiscoverResponse` also re-stamps `heard_timestamp` in both
|
||||
the snapshot and `neighbours[]` — proof of reception, and the only thing that heals a
|
||||
stamp taken before the clock was set. After all responses land or a 30 s window
|
||||
expires, `finishNeighborDiscover()` builds the JSON with
|
||||
`MQTTMessageBuilder::buildNeighborsMessage` into a transient buffer (PSRAM where
|
||||
available, internal DRAM otherwise) and hands it to the bridge. The entry table and its
|
||||
hex strings share one heap block sized to the pass — at `MAX_NEIGHBOURS` they reach
|
||||
~4.5 KB, which does not fit the mesh loop task's 8 KB stack. Ages that still span a clock epoch publish as `null` rather than a
|
||||
fabricated delta (`neighborHeardAgeUsable`, see `UPSTREAM_BUGS.md` #1).
|
||||
- **Buffer sizing**: `NEIGHBORS_JSON_BUFFER_SIZE` is 10 KB with PSRAM and 4 KB without,
|
||||
since a non-PSRAM board pays for the persistent buffer, the transient build buffer and
|
||||
the ArduinoJson pool out of the same internal DRAM each TLS slot needs ~40 KB of
|
||||
(~13 KB peak instead of ~35 KB). The pool has its own budget
|
||||
(`NEIGHBORS_DOC_POOL_BUDGET`) because ArduinoJson v7 hands out pool blocks in fixed
|
||||
4096-byte chunks, so a table that just fits the text buffer can still need well over
|
||||
it in pool — and a starved pool sets `doc.overflowed()`, which drops the entire publish
|
||||
instead of truncating it. `NEIGHBORS_MAX_PUBLISH_ENTRIES` (20 without PSRAM) keeps the
|
||||
pool inside a single block.
|
||||
- **Bridge side, handoff**: `requestPublishNeighbors(json, len)` (Core 1) memcpys into the
|
||||
persistent buffer (`NEIGHBORS_JSON_BUFFER_SIZE`, PSRAM where available) and sets
|
||||
`_neighbors_publish_pending` with a release store; the MQTT task (`mqttTaskLoop`, Core 0)
|
||||
consumes it with an acquire load, calls `publishNeighbors()`, and clears the flag. A
|
||||
second snapshot is dropped while one is in flight. `publishNeighbors()` sends QoS 1,
|
||||
retain = `preset->allow_retain` (custom slots non-retained). MeshRank slots are included,
|
||||
publishing to `meshrank/uplink/{token}/{device}/neighbors` (non-retained, since the
|
||||
preset sets `allow_retain = false`).
|
||||
- **Status reporting**: `MyMesh` reports the schedule each loop via
|
||||
`setNeighborsSchedule(phase, secs)`; `formatMqttStatusReply` renders it as the trailing
|
||||
`nbr: <when>/<last>` field in `get mqtt.status` while the feature is enabled.
|
||||
|
||||
The JSON builder lives in the pure, host-tested `MQTTPayloadBuilder`
|
||||
(`test/test_mqtt_payload_builder`); the topic type in `MQTTTopicRouter`
|
||||
(`test/test_mqtt_topic_router`). The mesh↔bridge orchestration above is on-target only.
|
||||
|
||||
### Runtime construction and slot memory
|
||||
|
||||
- **Deferred construction** — `MQTTBridge` is heap-allocated in each app's `begin()`
|
||||
@@ -129,11 +176,50 @@ fall back to defaults (no downgrade, no misread). `saveMQTTPrefs()` also refuses
|
||||
write while such a file is present (`_mqtt_prefs_hold`), so a `set` command after a
|
||||
firmware downgrade can't clobber the newer config — observer settings changed in that
|
||||
state simply don't persist. The frozen legacy layouts are pinned with `static_assert`s
|
||||
in `CommonCLI.h`, so every target build re-verifies the fleet's file offsets.
|
||||
in `MQTTPrefsStorage.h`, so every target build re-verifies the fleet's file offsets.
|
||||
|
||||
Adding a field to the current version stays backward compatible: append it to the end
|
||||
of `MQTTPrefs`. An older, shorter payload still loads and the missing tail keeps its
|
||||
default; a newer, longer one is truncated harmlessly.
|
||||
of `MQTTPrefs`, give the older exact payload length an explicit decoder boundary, and
|
||||
leave the missing tail at its default. The packet-filter addition follows that rule:
|
||||
the prior 2864-byte v1 payload loads with all six filters set to `all`, while the
|
||||
full payload is 2876 bytes.
|
||||
|
||||
#### The downgrade contract
|
||||
|
||||
**Within a version tag the layout is append-only, and a longer payload is always
|
||||
readable.** A file written by a later build starts with this binary's exact baseline,
|
||||
so `classify()` reads that prefix and ignores the tail. A downgraded node keeps its
|
||||
WiFi credentials, broker slots, and every other setting it understands; the only thing
|
||||
it loses is the settings the newer build added.
|
||||
|
||||
That asymmetry is the whole point. Refusing the file costs the operator the network
|
||||
itself — `/mqtt_prefs` holds `wifi_ssid`/`wifi_password` as well as the broker config,
|
||||
so a node that falls back to defaults has no WiFi, no portal, and no OTA, recoverable
|
||||
only over serial. Reading it costs a feature's settings. Losing later settings is the
|
||||
acceptable half of that trade; losing the node is not.
|
||||
|
||||
The tail survives until something actually writes. `saveMQTTPrefs()` rewrites at this
|
||||
binary's own length, so a rollback that changes no observer setting and is later rolled
|
||||
forward keeps the newer fields intact — only an explicit `set` while downgraded drops
|
||||
them. The boot log says so when it happens.
|
||||
|
||||
**A change that is not a pure append MUST bump `MQTT_PREFS_VERSION`.** The version
|
||||
check is what makes the rule above safe: a different tag is still refused outright and
|
||||
the file preserved, because the bytes may no longer mean what this binary thinks. Never
|
||||
reorder, resize, or repurpose an existing field within a version.
|
||||
|
||||
Note the rule is only as old as the build that implements it. Firmware already deployed
|
||||
carries the *previous* decoder, which rejects any longer v1 payload — so rolling back
|
||||
from this build to one shipped before it still falls back to defaults.
|
||||
`MQTTPrefsCodec::payloadLenFor()` covers that gap from the writing side: it returns the
|
||||
shortest length that still round-trips the configuration, so a node keeps writing 2864
|
||||
bytes until a packet filter actually holds something, and clearing the last non-default
|
||||
filter puts it back. That mitigation can be retired once no supported downgrade target
|
||||
predates the contract; the contract itself is the durable half.
|
||||
|
||||
Shorter payloads keep their existing, stricter treatment: a short length must match a
|
||||
boundary that really shipped (`MQTT_PREFS_V1_*_PAYLOAD_SIZE`), because raw prefs have
|
||||
no checksum and an arbitrary short size cannot be trusted to mean anything.
|
||||
|
||||
### Settings upgrade / migration
|
||||
|
||||
|
||||
@@ -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 §4 below.**
|
||||
Still **deferred** (carried to Phase 5b / Phase 6): publishing a plain-data
|
||||
snapshot and repointing the §1/§2 consumers, and replacing the §3 `volatile`
|
||||
handshakes with a command channel. This document is the plan; §1–§3 still
|
||||
describe current behavior, while §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 §1/§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 §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 §1/§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 §1/§2 consumers at it.
|
||||
- Replacing the §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.
|
||||
@@ -33,7 +33,7 @@ MeshCore provides the ability to create wireless mesh networks, similar to Mesht
|
||||
|
||||
## 🚀 How to Get Started
|
||||
|
||||
- Watch the [MeshCore Intro Video](https://www.youtube.com/watch?v=t1qne8uJBAc) by Andy Kirby.
|
||||
- Watch the [MeshCore QuickStart Playlist](https://www.youtube.com/watch?v=iaFltojJrAc&list=PLshzThxhw4O4WU_iZo3NmNZOv6KMrUuF9) by The Comms Channel
|
||||
- Watch the [MeshCore Technical Presentation](https://www.youtube.com/watch?v=OwmkVkZQTf4) by Liam Cottle.
|
||||
- Read through our [Frequently Asked Questions](./docs/faq.md) and [Documentation](https://docs.meshcore.io).
|
||||
- Flash the MeshCore firmware on a supported device.
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are applied to the latest release only. We do not backport
|
||||
fixes to older versions.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 1.15+ | ✅ |
|
||||
| <1.15 | ❌ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Use GitHub's private vulnerability reporting instead:
|
||||
1. Go to the **Security** tab of this repository
|
||||
2. Click **Report a vulnerability**
|
||||
3. Fill in the details and submit
|
||||
|
||||
### What to include
|
||||
|
||||
A useful report tells us:
|
||||
- Which component or file is affected
|
||||
- What an attacker can do (impact) and under what conditions
|
||||
- A minimal reproduction case or proof-of-concept if you have one
|
||||
- Whether you believe it is remotely exploitable
|
||||
|
||||
You do not need a working exploit to report. An incomplete report is better
|
||||
than no report.
|
||||
|
||||
## What to expect
|
||||
|
||||
This is a volunteer-maintained open-source project. We will do our best to
|
||||
respond in a reasonable timeframe, but cannot commit to specific deadlines.
|
||||
|
||||
We ask that you give us a fair opportunity to investigate and address the
|
||||
issue before any public disclosure. If you have not heard back after
|
||||
**90 days**, feel free to follow up or proceed with disclosure at your
|
||||
discretion.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
- Remote code execution, memory corruption, or denial-of-service via crafted
|
||||
radio packets
|
||||
- Authentication or encryption bypasses
|
||||
- Vulnerabilities in the packet routing or path handling logic
|
||||
|
||||
Out of scope:
|
||||
- Physical access attacks (e.g., JTAG, UART extraction of keys)
|
||||
- Regulatory compliance (duty cycle, frequency restrictions)
|
||||
- Jamming or other physical-layer radio interference
|
||||
- Issues in third-party libraries (RadioLib, Crypto, etc.) — report those
|
||||
upstream
|
||||
- "Best practice" suggestions without a demonstrated attack path
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,122 @@
|
||||
# Upstream Bug Candidates
|
||||
|
||||
Bugs in code this fork inherits from upstream (`meshcore-dev/dev`), each with the
|
||||
fix we would propose upstream. Any fork-local workaround is recorded so it can be
|
||||
dropped once upstream lands a real fix.
|
||||
|
||||
Status: **open** (not reported) · **reported** (issue/PR filed) · **landed**
|
||||
(upstream fixed, fork workaround removable).
|
||||
|
||||
---
|
||||
|
||||
## 1. Neighbour "heard" ages are derived from a steppable wall clock
|
||||
|
||||
**Status:** open
|
||||
**Files:** `examples/simple_repeater/MyMesh.cpp`, `examples/simple_room_server/MyMesh.cpp`
|
||||
**Impact:** wrong ages reported to companion apps, CLI, and (in this fork) MQTT. No crash.
|
||||
|
||||
### Symptom
|
||||
|
||||
A repeater published a neighbour age of 69681995 seconds — 806 days — for two of
|
||||
three neighbours that had just answered a live query (capture abridged; see
|
||||
`MQTT_IMPLEMENTATION.md` for the full payload shape):
|
||||
|
||||
```json
|
||||
{"timestamp":"2026-07-30T22:59:09.000000+00:00","total_neighbors":3,
|
||||
"neighbors":[
|
||||
{"pubkey":"0CE5EA7C…","snr":12.5,"heard_secs_ago":57,"status":"responded"},
|
||||
{"pubkey":"B0D17C59…","snr":12.5,"heard_secs_ago":69681995,"status":"responded"},
|
||||
{"pubkey":"D19BEE16…","snr":12.25,"heard_secs_ago":69681995,"status":"responded"}]}
|
||||
```
|
||||
|
||||
### Root cause
|
||||
|
||||
`heard_secs_ago` is a *relative* quantity computed by subtracting two samples of
|
||||
an *absolute* wall clock that can step by years mid-session.
|
||||
|
||||
`putNeighbour()` stamps `heard_timestamp = getRTCClock()->getCurrentTime()` when a
|
||||
zero-hop advert or node-discover reply arrives (`MyMesh.cpp:93`). On a cold boot
|
||||
the radio is receiving within a second or two, while the clock is still at the
|
||||
firmware's unset default — `1715770351`, 15 May 2024 (`src/helpers/ArduinoHelpers.h`,
|
||||
`src/helpers/ESP32Board.h`). NTP (or an admin over the CLI) then steps the clock
|
||||
forward by years, and every later reader subtracts the two:
|
||||
|
||||
```
|
||||
publish epoch 1785452349 (2026-07-30T22:59:09Z)
|
||||
− reported age 69681995
|
||||
= stored heard_timestamp 1715770354 = unset-clock default + 3 s of uptime
|
||||
```
|
||||
|
||||
So both stale entries were stamped 3 seconds after boot. The entries do not
|
||||
self-heal: `putNeighbour()` is the only writer of `heard_timestamp`, so an entry
|
||||
stays wrong until that specific neighbour is physically heard again. In the capture
|
||||
above the two stale neighbours missed the node-discover refresh (responder-side
|
||||
rate limit `discover_limiter`, a reply collision, or firmware without
|
||||
`CTL_TYPE_NODE_DISCOVER_RESP`) yet still answered a direct query — hence
|
||||
`"status":"responded"` next to a 2-year age.
|
||||
|
||||
Deferring the *read* until after time sync does not fix this. The bad value is
|
||||
written at packet-reception time, which on any cold boot precedes WiFi and NTP.
|
||||
|
||||
### Affected readers
|
||||
|
||||
| Path | Location |
|
||||
| --- | --- |
|
||||
| Companion `REQ_TYPE_GET_NEIGHBOURS` binary reply | `MyMesh.cpp:404` |
|
||||
| CLI `neighbors` text output | `MyMesh.cpp:1350` |
|
||||
| MQTT neighbors JSON (fork-only) | `MyMesh.cpp:2036` |
|
||||
|
||||
### Proposed upstream fix
|
||||
|
||||
Stamp neighbours from a monotonic source and derive every age from that:
|
||||
|
||||
- add `uint32_t heard_millis` to `NeighbourInfo`, set from `millis()` in
|
||||
`putNeighbour()` alongside the existing wall-clock stamp;
|
||||
- compute every "seconds ago" readout as `(now_millis - heard_millis) / 1000`
|
||||
using the unsigned-subtraction idiom already used by `millisHasNowPassed()` /
|
||||
`futureMillis()`, which is immune to clock steps and stays correct across one
|
||||
32-bit rollover;
|
||||
- clamp the result to a documented maximum so an entry untouched for longer than
|
||||
the 49.7-day `millis()` period cannot alias back to a small age;
|
||||
- keep `heard_timestamp` for anything that needs an absolute instant — sort order
|
||||
is equivalent under either field.
|
||||
|
||||
Cost: 4 bytes × `MAX_NEIGHBOURS` of RAM.
|
||||
|
||||
### Fork-local containment (in place)
|
||||
|
||||
Not a substitute for the above; it stops the fork from publishing a fabricated
|
||||
number while upstream still stamps from the wall clock.
|
||||
|
||||
- `finishNeighborDiscover()` reports the age as unknown — `"heard_secs_ago":null`
|
||||
— when the stored stamp predates `MQTTConnectionPolicy::kSyncedClockEpoch`
|
||||
(2025-01-01, above the unset-clock default) while the current clock is past it,
|
||||
or when the clock has stepped backwards. Previously a backwards clock reported
|
||||
`0`, i.e. "heard just now".
|
||||
- `handleNeighborDiscoverResponse()` re-stamps `heard_timestamp` on a zero-hop
|
||||
scope reply, in both the snapshot and the live table. The reply is proof of
|
||||
reception, so entries now heal once per discovery cycle instead of waiting for
|
||||
the neighbour's next advert. This would have made 2 of the 3 entries above
|
||||
correct.
|
||||
- Publish ordering places known ages before unknown ones, so an unusable stamp
|
||||
cannot masquerade as the freshest entry when the JSON buffer truncates.
|
||||
|
||||
---
|
||||
|
||||
## 2. Neighbour age subtraction is unclamped in the companion and CLI readouts
|
||||
|
||||
**Status:** open
|
||||
**Files:** `examples/simple_repeater/MyMesh.cpp:404`, `:1350` (and the room-server twins)
|
||||
**Impact:** age underflows to ~4.29e9 instead of a sane value.
|
||||
|
||||
Both readers subtract without checking ordering:
|
||||
|
||||
```cpp
|
||||
uint32_t heard_seconds_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp;
|
||||
```
|
||||
|
||||
Any state where `heard_timestamp > now` — clock stepped backwards by NTP or an
|
||||
admin, or a stamp taken before a backwards step — wraps the unsigned result to
|
||||
near `UINT32_MAX`. Distinct from #1 in that it is a missing guard rather than the
|
||||
choice of clock: worth fixing on its own even before the monotonic change, by
|
||||
clamping to `0` (or reporting "unknown", which is what the age actually is).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"partitions": "default_16MB.csv",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "opi",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "heltec_rc32"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "Heltec RC32 (16 MB FLASH, 8 MB PSRAM)",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://heltec.org/",
|
||||
"vendor": "Heltec"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52840_s140_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A","0x4405"],
|
||||
["0x239A","0x0029"],
|
||||
["0x239A","0x002A"],
|
||||
["0x239A","0x0071"]
|
||||
],
|
||||
"usb_product": "HT-n5262",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "heltec_tower_v2",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS140",
|
||||
"sd_name": "s140",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B6"
|
||||
},
|
||||
"bootloader": {
|
||||
"settings_addr": "0xFF000"
|
||||
}
|
||||
},
|
||||
"connectivity": [
|
||||
"bluetooth"
|
||||
],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"svd_path": "nrf52840.svd",
|
||||
"openocd_target": "nrf52.cfg"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino"
|
||||
],
|
||||
"name": "Heltec Tower V2 Board",
|
||||
"upload": {
|
||||
"maximum_ram_size": 235520,
|
||||
"maximum_size": 815104,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": [
|
||||
"jlink",
|
||||
"nrfjprog",
|
||||
"nrfutil",
|
||||
"stlink"
|
||||
],
|
||||
"use_1200bps_touch": true,
|
||||
"require_upload_port": true,
|
||||
"wait_for_upload_port": true
|
||||
},
|
||||
"url": "https://heltec.org/",
|
||||
"vendor": "Heltec"
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
@@ -40,4 +40,4 @@
|
||||
},
|
||||
"url": "https://heltec.org/",
|
||||
"vendor": "heltec"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"partitions": "default_16MB.csv",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "opi",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "heltec_v4_r8"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "heltec_wifi_lora_32 v4 r8 (16 MB FLASH, 8 MB PSRAM)",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://heltec.org/",
|
||||
"vendor": "heltec"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"partitions": "default_16MB.csv",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "opi",
|
||||
"hwids": [["0x303A", "0x0002"], ["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "meshnology_w12"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "heltec_wifi_lora_32 v5 (16 MB FLASH, 8 MB PSRAM)",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://heltec.org/",
|
||||
"vendor": "heltec"
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52840_s140_v7.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DARDUINO_WIO_WM1110 -DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A", "0x8029"],
|
||||
["0x239A", "0x0029"],
|
||||
["0x239A", "0x002A"],
|
||||
["0x239A", "0x802A"],
|
||||
["0x2886", "0x0057"]
|
||||
],
|
||||
"usb_product": "X1-BOOT",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "Seeed_Mesh-Tracker-X1",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS140",
|
||||
"sd_name": "s140",
|
||||
"sd_version": "7.3.0",
|
||||
"sd_fwid": "0x0123"
|
||||
},
|
||||
"bootloader": {
|
||||
"settings_addr": "0xFF000"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"svd_path": "nrf52840.svd",
|
||||
"openocd_target": "nrf52.cfg"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "Seeed SenseCAP MeshTracker X1",
|
||||
"upload": {
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": [
|
||||
"jlink",
|
||||
"nrfjprog",
|
||||
"nrfutil",
|
||||
"stlink",
|
||||
"cmsis-dap",
|
||||
"blackmagic"
|
||||
],
|
||||
"use_1200bps_touch": true,
|
||||
"require_upload_port": true,
|
||||
"wait_for_upload_port": true
|
||||
},
|
||||
"url": "https://www.seeedstudio.com/SenseCAP-MeshTracker-X1-for-Meshtastic-p-6793.html",
|
||||
"vendor": "Seeed Studio"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-D BOARD_HAS_PSRAM",
|
||||
"-D ARDUINO_USB_CDC_ON_BOOT=0",
|
||||
"-D ARDUINO_USB_MODE=0",
|
||||
"-D ARDUINO_RUNNING_CORE=1",
|
||||
"-D ARDUINO_EVENT_RUNNING_CORE=0"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "qio_opi",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "ELECROW-ThinkNode-M7"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "ELECROW ThinkNode M7",
|
||||
"upload": {
|
||||
"flash_size": "8MB",
|
||||
"maximum_ram_size": 524288,
|
||||
"maximum_size": 8388608,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://www.elecrow.com",
|
||||
"vendor": "ELECROW"
|
||||
}
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"memory_type": "qio_opi",
|
||||
"partitions": "default_16MB.csv"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=0",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=0"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "qio_opi",
|
||||
"hwids": [
|
||||
[
|
||||
"0x303A",
|
||||
"0x1001"
|
||||
]
|
||||
],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "ELECROW-ThinkNode-M9"
|
||||
},
|
||||
"connectivity": [
|
||||
"wifi",
|
||||
"bluetooth",
|
||||
"lora"
|
||||
],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": [
|
||||
"esp-builtin"
|
||||
],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "elecrow-thinknode-m9",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 524288,
|
||||
"maximum_size": 16777216,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://www.elecrow.com/thinknode-m1-meshtastic-lora-signal-transceiver-powered-by-nrf52840-with-154-screen-support-gps.html",
|
||||
"vendor": "ELECROW"
|
||||
}
|
||||
@@ -3,6 +3,9 @@ PIO_CONFIG_JSON=$(pio project config --json-output)
|
||||
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# exit when any command fails
|
||||
set -e
|
||||
|
||||
global_usage() {
|
||||
cat - <<EOF
|
||||
Usage:
|
||||
@@ -34,6 +37,9 @@ $ sh build.sh build-repeater-firmwares
|
||||
Build all chat room server firmwares
|
||||
$ sh build.sh build-room-server-firmwares
|
||||
|
||||
Build all kiss radio firmwares
|
||||
$ sh build.sh build-kiss-radio-firmwares
|
||||
|
||||
Environment Variables:
|
||||
DISABLE_DEBUG=1: Disables all debug logging flags (MESH_DEBUG, MESH_PACKET_LOGGING, etc.)
|
||||
If not set, debug flags from variant platformio.ini files are used.
|
||||
@@ -143,14 +149,15 @@ build_firmware() {
|
||||
fi
|
||||
|
||||
# set firmware version string (used for the output filename)
|
||||
# e.g: v1.0.0-abcdef — or v1.16.0.5-abcdef with a build number. The build
|
||||
# number is now IN the filename so the web flasher's Version dropdown (parsed
|
||||
# from the asset name by the /releases Worker) shows the true published build,
|
||||
# matching the embedded version that `ver` reports. Every filename parser
|
||||
# (flasher gen-slim-manifests ASSET_RE, the /releases Worker VERSION_RE,
|
||||
# flasher.js stale-URL recovery) accepts an optional 4th ".<n>" component
|
||||
# between version and hash.
|
||||
FIRMWARE_VERSION_STRING="${FIRMWARE_VERSION}${BUILD_NUMBER_SUFFIX}-${COMMIT_HASH}"
|
||||
# e.g: v1.0.0-abcdef — or v1.16.0.5-dev-abcdef with a build number and the
|
||||
# dev channel's FILENAME_CHANNEL_TAG. The build number is now IN the filename
|
||||
# so the web flasher's Version dropdown (parsed from the asset name by the
|
||||
# /releases Worker) shows the true published build, matching the embedded
|
||||
# version that `ver` reports. Every filename parser (flasher gen-slim-manifests
|
||||
# ASSET_RE, the /releases Worker VERSION_RE, flasher.js stale-URL recovery)
|
||||
# accepts an optional 4th ".<n>" component followed by the lowercase
|
||||
# (?:-[a-z]+)? channel tag between version and hash.
|
||||
FIRMWARE_VERSION_STRING="${FIRMWARE_VERSION}${BUILD_NUMBER_SUFFIX}${FILENAME_CHANNEL_TAG:-}-${COMMIT_HASH}"
|
||||
|
||||
# craft filename
|
||||
# e.g: RAK_4631_Repeater-v1.0.0-SHA
|
||||
@@ -159,22 +166,47 @@ build_firmware() {
|
||||
# Tag the *embedded* version for observer builds, e.g. v1.0.0-observer-abcdef,
|
||||
# so `ver`, the MQTT firmware_version/client_version, and SNMP all identify the
|
||||
# fork. The filename above carries the same version + build number but no
|
||||
# variant tag: the env name already contains "observer", and the web flasher
|
||||
# keys off that existing pattern.
|
||||
# variant/channel tag: the env name already contains "observer", and the web
|
||||
# flasher keys off that existing pattern.
|
||||
VARIANT_TAG=""
|
||||
case "$1" in
|
||||
*observer*) VARIANT_TAG="-observer" ;;
|
||||
esac
|
||||
|
||||
# Embedded version: base + build number (4th component) + variant tag + hash,
|
||||
# e.g. v1.16.0.5-observer-abcdef, so the node reports its build and `ota check`
|
||||
# can show how many builds behind it is.
|
||||
# Optional release-channel marker (e.g. OTA_CHANNEL_TAG=beta -> "-observer-beta"),
|
||||
# so `ver` / MQTT firmware_version / SNMP identify which channel a node runs
|
||||
# without having to infer it from log behavior. Safe for the OTA version logic:
|
||||
# ota_parseVersion() reads only up to the first '-' and ota_extractHash() takes
|
||||
# the token after the LAST '-', so extra tags in between change neither.
|
||||
if [ -n "$OTA_CHANNEL_TAG" ]; then
|
||||
VARIANT_TAG="${VARIANT_TAG}-${OTA_CHANNEL_TAG}"
|
||||
fi
|
||||
|
||||
# Embedded version: base + build number (4th component) + variant/channel tag
|
||||
# + hash, e.g. v1.16.0.5-observer-abcdef, so the node reports its build and
|
||||
# `ota check` can show how many builds behind it is.
|
||||
EMBEDDED_VERSION_STRING="${FIRMWARE_VERSION}${BUILD_NUMBER_SUFFIX}${VARIANT_TAG}-${COMMIT_HASH}"
|
||||
|
||||
# Release channel. The observer pull-OTA fetches its slim per-variant manifest
|
||||
# from <OTA_MANIFEST_BASE>/<OTA_VARIANT>.json, so this URL IS the channel: a
|
||||
# device only ever sees updates published under the base it was built with.
|
||||
# Override OTA_MANIFEST_BASE_URL to publish a parallel channel (e.g. beta);
|
||||
# unset gives the production channel.
|
||||
#
|
||||
# Deliberately injected here rather than declared in variants/*/platformio.ini
|
||||
# (where it used to be duplicated 28 times), for symmetry with OTA_VARIANT and
|
||||
# so a plain `pio run` leaves BOTH macros undefined — which is what makes
|
||||
# ESP32Board.cpp's "ERR: OTA not configured (build via build.sh)" guard fire on
|
||||
# dev builds. Do not add a default in a header: that would silently arm OTA on
|
||||
# locally built firmware. Note that PLATFORMIO_BUILD_FLAGS cannot reliably
|
||||
# override a -D coming from build_flags (SCons reorders -U/-D), which is why
|
||||
# the .ini declarations were removed rather than overridden.
|
||||
OTA_MANIFEST_BASE_URL="${OTA_MANIFEST_BASE_URL:-https://observer.gessaman.com/v}"
|
||||
|
||||
# add firmware version info to end of existing platformio build flags in environment vars.
|
||||
# OTA_VARIANT is the env name ($1) — it selects this build's slim per-variant manifest
|
||||
# (<OTA_MANIFEST_BASE>/<OTA_VARIANT>.json) that the observer pull-OTA fetches.
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"' -DOTA_VARIANT='\"$1\"'"
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"' -DOTA_VARIANT='\"$1\"' -DOTA_MANIFEST_BASE='\"${OTA_MANIFEST_BASE_URL}\"'"
|
||||
|
||||
# disable debug flags if requested
|
||||
disable_debug_flags
|
||||
@@ -268,6 +300,17 @@ build_room_server_firmwares() {
|
||||
|
||||
}
|
||||
|
||||
build_kiss_modem_firmwares() {
|
||||
|
||||
# # build specific kiss radio firmwares
|
||||
# build_firmware "Heltec_v3_kiss_modem"
|
||||
# build_firmware "RAK_4631_kiss_modem"
|
||||
|
||||
# build all room server firmwares
|
||||
build_all_firmwares_by_suffix "_kiss_modem"
|
||||
|
||||
}
|
||||
|
||||
build_firmwares() {
|
||||
build_companion_firmwares
|
||||
build_repeater_firmwares
|
||||
@@ -304,4 +347,13 @@ elif [[ $1 == "build-repeater-firmwares" ]]; then
|
||||
build_repeater_firmwares
|
||||
elif [[ $1 == "build-room-server-firmwares" ]]; then
|
||||
build_room_server_firmwares
|
||||
elif [[ $1 == "build-kiss-radio-firmwares" ]]; then
|
||||
build_kiss_modem_firmwares
|
||||
elif [[ $1 == "get-companion-firmwares-to-build" ]]; then
|
||||
get_pio_envs_ending_with_string "_companion_radio_usb"
|
||||
get_pio_envs_ending_with_string "_companion_radio_ble"
|
||||
elif [[ $1 == "get-repeater-firmwares-to-build" ]]; then
|
||||
get_pio_envs_ending_with_string "_repeater"
|
||||
elif [[ $1 == "get-room-server-firmwares-to-build" ]]; then
|
||||
get_pio_envs_ending_with_string "_room_server"
|
||||
fi
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
let
|
||||
{pkgs ? import <nixpkgs> {}}: let
|
||||
in
|
||||
pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.platformio
|
||||
pkgs.python3
|
||||
pkgs.gcc
|
||||
pkgs.gtest
|
||||
# optional: needed as a programmer i.e. for esp32
|
||||
pkgs.avrdude
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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", "", [] ]
|
||||
]
|
||||
}
|
||||
+127
-30
@@ -19,6 +19,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore
|
||||
- [GPS](#gps-when-gps-support-is-compiled-in)
|
||||
- [Sensors](#sensors-when-sensor-support-is-compiled-in)
|
||||
- [Bridge](#bridge-when-bridge-support-is-compiled-in)
|
||||
- [Ethernet](#ethernet-when-ethernet-support-is-compiled-in)
|
||||
|
||||
---
|
||||
|
||||
@@ -28,12 +29,25 @@ This document provides an overview of CLI commands that can be sent to MeshCore
|
||||
**Usage:**
|
||||
- `reboot`
|
||||
|
||||
**Note:** No reply is sent.
|
||||
|
||||
---
|
||||
|
||||
### Power-off the node
|
||||
**Usage:**
|
||||
- `poweroff`, or
|
||||
- `shutdown`
|
||||
|
||||
**Note:** No reply is sent.
|
||||
|
||||
---
|
||||
|
||||
### Reset the clock and reboot
|
||||
**Usage:**
|
||||
- `clkreboot`
|
||||
|
||||
**Note:** No reply is sent.
|
||||
|
||||
---
|
||||
|
||||
### Sync the clock with the remote device
|
||||
@@ -114,16 +128,18 @@ This document provides an overview of CLI commands that can be sent to MeshCore
|
||||
|
||||
---
|
||||
|
||||
### Discover neighbor scopes and publish to MQTT (PSRAM observer builds)
|
||||
### 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`
|
||||
|
||||
Queries cached zero-hop repeater neighbors for flood-allowed region scopes (`ANON_REQ_TYPE_REGIONS`), then publishes a JSON snapshot to each configured MQTT slot's `neighbors` topic. Includes this node's own scopes in the `self` object.
|
||||
|
||||
If a zero-hop neighbor discovery is already collecting responses — from `discover.neighbors` or from the periodic `mqtt.neighbors` refresh — the scope queries are queued until its 60-second response window ends, so they run against the refreshed table rather than the stale cache. The reply reports the wait, e.g. `OK - scopes queued (47s discovery remaining)`. A queued request is not cancelled by `set mqtt.neighbors off`.
|
||||
|
||||
**Note:** Requires `WITH_MQTT_BRIDGE`, `MAX_NEIGHBOURS`, and PSRAM (`BOARD_HAS_PSRAM`). Returns `Err - not supported (requires PSRAM)` on other builds.
|
||||
**Note:** Requires an MQTT observer build with the neighbors feature compiled in
|
||||
(all PSRAM boards, plus non-PSRAM boards built with `MQTT_NEIGHBORS_WITHOUT_PSRAM`).
|
||||
Elsewhere it replies `Err - neighbors not enabled in this build`. If a
|
||||
`discover.neighbors` refresh is already in flight, the scope pass is queued behind it.
|
||||
|
||||
---
|
||||
|
||||
@@ -275,6 +291,20 @@ If a zero-hop neighbor discovery is already collecting responses — from `disco
|
||||
|
||||
---
|
||||
|
||||
#### View or change the LoRa FEM receive-path gain state on supported boards
|
||||
**Usage:**
|
||||
- `get radio.fem.rxgain`
|
||||
- `set radio.fem.rxgain <state>`
|
||||
|
||||
**Parameters:**
|
||||
- `state`: `on`|`off`
|
||||
|
||||
**Notes:**
|
||||
- This controls the external LoRa FEM receive-path LNA where the board supports it.
|
||||
- This is separate from `radio.rxgain`, which controls the radio chip receive gain mode.
|
||||
|
||||
---
|
||||
|
||||
### System
|
||||
|
||||
#### View or change this node's name
|
||||
@@ -289,7 +319,7 @@ If a zero-hop neighbor discovery is already collecting responses — from `disco
|
||||
|
||||
**Default:** Varies by board
|
||||
|
||||
**Note:** Max length varies. If a location is set, the max length is 24 bytes; 32 otherwise. Emoji and unicode characters may take more than one byte.
|
||||
**Note:** Advertised names can use up to 23 bytes when location is included and 31 bytes otherwise. Emoji and Unicode characters may take more than one byte. Names that exceed the available advert space are truncated at a valid UTF-8 code point boundary.
|
||||
|
||||
---
|
||||
|
||||
@@ -403,6 +433,11 @@ If a zero-hop neighbor discovery is already collecting responses — from `disco
|
||||
|
||||
---
|
||||
|
||||
#### View this node's firmware version
|
||||
**Usage:** `ver`
|
||||
|
||||
---
|
||||
|
||||
#### View this node's configured role
|
||||
**Usage:** `get role`
|
||||
|
||||
@@ -571,6 +606,20 @@ If a zero-hop neighbor discovery is already collecting responses — from `disco
|
||||
|
||||
---
|
||||
|
||||
#### Enable or disable hardware Channel Activity Detection (CAD)
|
||||
**Usage:**
|
||||
- `get cad`
|
||||
- `set cad <on|off>`
|
||||
|
||||
**Description:** When enabled, the radio performs a hardware Channel Activity Detection scan before transmitting and defers if the channel is busy. Runs independently of `int.thresh` — either, both, or none may be active.
|
||||
|
||||
**Parameters:**
|
||||
- `on|off`: Enable or disable hardware CAD
|
||||
|
||||
**Default:** `off`
|
||||
|
||||
---
|
||||
|
||||
#### View or change the AGC Reset Interval
|
||||
**Usage:**
|
||||
- `get agc.reset.interval`
|
||||
@@ -653,10 +702,21 @@ If a zero-hop neighbor discovery is already collecting responses — from `disco
|
||||
**Parameters:**
|
||||
- `value`: Maximum flood hop count (0-64) for a packet without a scope (no region set)
|
||||
|
||||
**Default:** `0xFF` - indicates it hasn't been set, will track flood.max until it is.
|
||||
**Default:** `64` - (`0xFF` indicates it hasn't been set, will track flood.max until it is.)
|
||||
|
||||
**Note:** An alternative to `region denyf *`, setting `flood.max.unscoped` to a lower value such as `3` would allow for local unscoped messages to propagate, while preventing noisy neighbors from flooding a local region.
|
||||
|
||||
---
|
||||
|
||||
#### Limit the number of hops for an advert flood message
|
||||
**Usage:**
|
||||
- `get flood.max.advert`
|
||||
- `set flood.max.advert <value>`
|
||||
|
||||
**Parameters:**
|
||||
- `value`: Maximum flood hop count (0-64) for an advert packet
|
||||
|
||||
**Default:** `8`
|
||||
|
||||
---
|
||||
|
||||
@@ -1090,6 +1150,43 @@ region save
|
||||
|
||||
---
|
||||
|
||||
#### 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 build with the neighbors feature compiled in (all PSRAM
|
||||
> boards, plus non-PSRAM boards built with `MQTT_NEIGHBORS_WITHOUT_PSRAM`);
|
||||
> elsewhere this replies `Err - neighbors not enabled in this build`. Non-PSRAM
|
||||
> builds publish at most 20 neighbours per pass to bound internal-DRAM use, and
|
||||
> set `truncated` with the true `total_neighbors` when the table is larger.
|
||||
> The setting is read live by the mesh
|
||||
> loop — no restart required; enabling it triggers a discovery on the next pass.
|
||||
> While enabled, `get mqtt.status` gains a trailing `nbr: <next>/<last>` field
|
||||
> (time to next publish, and how the last publish went).
|
||||
|
||||
---
|
||||
|
||||
#### 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`
|
||||
@@ -1117,28 +1214,6 @@ Requires WiFi connected and the MQTT bridge running.
|
||||
|
||||
---
|
||||
|
||||
#### Periodic neighbors/scopes MQTT publish (PSRAM observer builds)
|
||||
|
||||
**Usage:**
|
||||
- `get mqtt.neighbors`
|
||||
- `set mqtt.neighbors on|off`
|
||||
- `get mqtt.neighbors.interval`
|
||||
- `set mqtt.neighbors.interval <hours>`
|
||||
|
||||
**Parameters:**
|
||||
- `hours`: Interval between automatic `discover.scopes` runs (**12-336**, default **24**)
|
||||
|
||||
**Note:** Requires PSRAM. Publishes to each configured MQTT slot's `neighbors` topic at QoS 1 (retained only when that preset allows it). Use `discover.scopes` for a one-shot publish.
|
||||
|
||||
Automatic publishes run in two stages: first the repeater performs the same 60-second zero-hop refresh as `discover.neighbors`, then it queries the refreshed neighbors for scopes and publishes after those queries complete.
|
||||
|
||||
While enabled, `get mqtt.status` reports the schedule as a trailing `nbr: <next>/<last>` field, e.g. `nbr: 3h12m/ok`:
|
||||
|
||||
- `<next>` — time until the next automatic publish (`3h12m`, `12m`, `45s`), or `active` while a refresh or scope query is in flight, or `due` when a publish is waiting on the bridge or WiFi to come up.
|
||||
- `<last>` — result of the most recent publish attempt: `ok`, `failed`, or `none` if none has been attempted since boot.
|
||||
|
||||
---
|
||||
|
||||
#### View or change the speed of the bridge (RS-232 only)
|
||||
**Usage:**
|
||||
- `get bridge.baud`
|
||||
@@ -1203,3 +1278,25 @@ While enabled, `get mqtt.status` reports the schedule as a trailing `nbr: <next>
|
||||
**Note:** Returns an error on boards without power management support.
|
||||
|
||||
---
|
||||
|
||||
### Ethernet (when Ethernet support is compiled in)
|
||||
|
||||
Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Ethernet module. Use the `_ethernet` firmware variants (e.g. `RAK_4631_repeater_ethernet`) to enable this feature.
|
||||
|
||||
---
|
||||
|
||||
#### View Ethernet connection status
|
||||
**Usage:**
|
||||
- `eth.status`
|
||||
|
||||
**Output:**
|
||||
- `ETH: <ip>:<port>` when connected (e.g. `ETH: 192.168.1.50:23`)
|
||||
- `ETH: not connected` when Ethernet is not active
|
||||
|
||||
**Notes:**
|
||||
- Available on repeater and room server firmware only. Companion radio ethernet firmware does not expose a CLI.
|
||||
- The Ethernet interface obtains an IP address via DHCP automatically on boot.
|
||||
- A TCP server listens on port 23 (default) for CLI connections.
|
||||
- Connect with any TCP client (e.g. `nc`, PuTTY) to access the same CLI available over serial.
|
||||
|
||||
---
|
||||
@@ -440,6 +440,8 @@ Byte 0: 0x14
|
||||
- Uses a secret key derived from the channel name
|
||||
- It is the first 16 bytes of `sha256("#test")`
|
||||
- For example hashtag channel `#test` has the key: `9cd8fcf22a47333b591d96a2b848b73f`
|
||||
- Traffic is encrypted on air, but anyone who knows or guesses the channel
|
||||
name can derive the key. Hashtag channels should not be treated as private.
|
||||
- Used as a topic based public group chat, separate from the default public channel
|
||||
3. **Private Channels**
|
||||
- Uses a randomly generated 16-byte secret key
|
||||
|
||||
+28
-9
@@ -83,6 +83,7 @@ A list of frequently-asked questions and answers for MeshCore
|
||||
- [7.5. Q: What is the format of a contact or channel QR code?](#75-q-what-is-the-format-of-a-contact-or-channel-qr-code)
|
||||
- [7.6. Q: How do I connect to the companion via Wi-Fi, e.g. using a Heltec V3?](#76-q-how-do-i-connect-to-the-companion-via-wi-fi-eg-using-a-heltec-v3)
|
||||
- [7.7. Q: I have a Station G2, or a Heltec V4, or an Ikoka Stick, or a radio with an EByte E22-900M30S or an EByte E22-900M33S module, what should their transmit power be set to?](#77-q-i-have-a-station-g2-or-a-heltec-v4-or-an-ikoka-stick-or-a-radio-with-an-ebyte-e22-900m30s-or-an-ebyte-e22-900m33s-module-what-should-their-transmit-power-be-set-to)
|
||||
- [7.8. Q: How do I use Ethernet with a RAK4631?](#78-q-how-do-i-use-ethernet-with-a-rak4631)
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
@@ -111,7 +112,6 @@ Anyone is able to build anything they like on top of MeshCore without paying any
|
||||
- MeshCore Firmware on GitHub: [https://github.com/meshcore-dev/MeshCore](https://github.com/meshcore-dev/MeshCore)
|
||||
- MeshCore Companion Web App: [https://app.meshcore.nz](https://app.meshcore.nz)
|
||||
- MeshCore Map: [https://map.meshcore.io](https://map.meshcore.io)
|
||||
- Andy Kirby's [MeshCore Intro Video](https://www.youtube.com/watch?v=t1qne8uJBAc)
|
||||
- Liam Cottle's [MeshCore Technical Presentation](https://www.youtube.com/watch?v=OwmkVkZQTf4)
|
||||
|
||||
You need LoRa hardware devices to run MeshCore firmware as clients or server (repeater and room server).
|
||||
@@ -392,10 +392,7 @@ Another way to download map tiles is to use this Python script to get the tiles
|
||||
<https://github.com/fistulareffigy/MTD-Script>
|
||||
|
||||
There is also a modified script that adds additional error handling and parallel downloads:
|
||||
<https://discord.com/channels/826570251612323860/1330643963501351004/1338775811548905572>
|
||||
|
||||
UK map tiles are available separately from Andy Kirby on his discord server:
|
||||
<https://discord.com/channels/826570251612323860/1330643963501351004/1331346597367386224>
|
||||
<https://github.com/TheBestJohn/MTD-Script>
|
||||
|
||||
### 4.8. Q: Where do the map tiles go?
|
||||
Once you have the tiles downloaded, copy the `\tiles` folder to the root of your T-Deck's SD card.
|
||||
@@ -553,10 +550,6 @@ pio run -e RAK_4631_Repeater
|
||||
```
|
||||
then you'll find `firmware.zip` in `.pio/build/RAK_4631_Repeater`
|
||||
|
||||
Andy also has a video on how to build using VS Code:
|
||||
*How to build and flash Meshcore repeater firmware | Heltec V3*
|
||||
<https://www.youtube.com/watch?v=WJvg6dt13hk> *(Link referenced in the Discord post)*
|
||||
|
||||
### 5.10. Q: Are there other MeshCore related open source projects?
|
||||
|
||||
**A:** [Liam Cottle](https://liamcottle.net)'s MeshCore web client and MeshCore JavaScript library are open source under MIT license.
|
||||
@@ -828,3 +821,29 @@ For companion radios, you can set these radios' transmit power in the smartphone
|
||||
| **Ikoka Stick E22-900M33S** | 2W Model | 9 dBm | 2W | **DO NOT EXCEED** (Risk of burn out) [data sheet](https://www.cdebyte.com/pdf-down.aspx?id=4216) Refer to your local government's requirements |
|
||||
| **Heltec V4** | Standard Output | 10 dBm | 22 dBm (~0.15W) | |
|
||||
| | High Output | 22 dBm | 28 dBm (~0.5W to 0.6W) | |
|
||||
|
||||
---
|
||||
|
||||
### 7.8. Q: How do I use Ethernet with a RAK4631?
|
||||
**A:**
|
||||
MeshCore supports Ethernet on RAK4631 boards using the [RAK13800](https://docs.rakwireless.com/product-categories/wisblock/rak13800/datasheet/) WisBlock Ethernet module (based on the W5100S chip).
|
||||
|
||||
**Hardware required:**
|
||||
- RAK4631 WisBlock Core
|
||||
- RAK19007 or RAK19018 WisBlock Base Board (with an available IO slot)
|
||||
- RAK13800 WisBlock Ethernet module
|
||||
- Ethernet cable connected to a network with a DHCP server
|
||||
|
||||
**Firmware:**
|
||||
Flash one of the Ethernet-enabled firmware variants:
|
||||
- `RAK_4631_repeater_ethernet` - Repeater with Ethernet CLI access
|
||||
- `RAK_4631_room_server_ethernet` - Room server with Ethernet CLI access
|
||||
- `RAK_4631_companion_radio_ethernet` - Companion radio over Ethernet (replaces BLE)
|
||||
|
||||
**Connecting:**
|
||||
- The device obtains an IP address via DHCP automatically on boot.
|
||||
- For repeaters and room servers, connect to the device on TCP port 23 using any TCP client (e.g. `nc <ip> 23` or PuTTY in raw mode). This gives you the same CLI available over serial/USB.
|
||||
- For companion radio firmware, the Ethernet interface replaces BLE as the transport to companion apps. Connect on TCP port 5000 (same as the WiFi companion radio).
|
||||
- Use the `eth.status` CLI command to check connection status and see the assigned IP address.
|
||||
|
||||
---
|
||||
@@ -41,7 +41,7 @@ Maximum unescaped frame size: 512 bytes.
|
||||
|
||||
| Command | Value | Data | Description |
|
||||
|-------------|--------|--------------------|-------------------------------------------------------------|
|
||||
| Data | `0x00` | Raw packet | Queue packet for transmission |
|
||||
| Data | `0x00` | Raw packet | Queue packet for transmission (one pending at a time) |
|
||||
| TXDELAY | `0x01` | Delay (1 byte) | Transmitter keyup delay in 10ms units (default: 50 = 500ms) |
|
||||
| Persistence | `0x02` | P (1 byte) | CSMA persistence parameter 0-255 (default: 63) |
|
||||
| SlotTime | `0x03` | Interval (1 byte) | CSMA slot interval in 10ms units (default: 10 = 100ms) |
|
||||
@@ -58,6 +58,12 @@ Maximum unescaped frame size: 512 bytes.
|
||||
|
||||
Data frames carry raw packet data only, with no metadata prepended. The Data command payload is limited to 255 bytes to match the MeshCore maximum transmission unit (MAX_TRANS_UNIT); frames larger than 255 bytes are silently dropped. The KISS specification recommends at least 1024 bytes for general-purpose TNCs; this modem is intended for MeshCore packets only, whose protocol MTU is 255 bytes.
|
||||
|
||||
Only one packet may be pending for radio transmission at a time. If the host sends a second Data frame before the first has completed, the modem responds with Error (0xF1) and TxBusy (0x07).
|
||||
|
||||
### Host Output Backpressure
|
||||
|
||||
Outbound frames are encoded into a 2-slot queue and flushed when serial output space is available; `loop()` never blocks on writes. Radio TX state advances independently of host read speed. TxDone is retained until it can be queued. If the outbound queue is full, the modem responds with Error (0xF1) and TxBusy (0x07). Hosts should read serial promptly to avoid delayed responses.
|
||||
|
||||
### CSMA Behavior
|
||||
|
||||
The TNC implements p-persistent CSMA for half-duplex operation:
|
||||
@@ -156,15 +162,15 @@ Response codes use the high-bit convention: `response = command | 0x80`. Generic
|
||||
| MacFailed | `0x04` | MAC verification failed |
|
||||
| UnknownCmd | `0x05` | Unknown sub-command |
|
||||
| EncryptFailed | `0x06` | Encryption failed |
|
||||
| TxBusy | `0x07` | Transmit busy |
|
||||
| TxBusy | `0x07` | Radio TX busy, or host output queue full |
|
||||
|
||||
### Unsolicited Events
|
||||
|
||||
The TNC sends these SetHardware frames without a preceding request:
|
||||
|
||||
**TxDone (0xF8)**: Sent after a packet has been transmitted. Contains a single byte: 0x01 for success, 0x00 for failure.
|
||||
**TxDone (0xF8)**: Sent after radio transmission completes. Contains a single byte: 0x01 for success, 0x00 for failure. Delivery to the host may be delayed under serial backpressure but is not dropped.
|
||||
|
||||
**RxMeta (0xF9)**: Sent immediately after each standard data frame (type 0x00) with metadata for the received packet. Contains SNR (1 byte, signed, value x4 for 0.25 dB precision) followed by RSSI (1 byte, signed, dBm). Enabled by default; can be toggled with SetSignalReport. Standard KISS clients ignore this frame.
|
||||
**RxMeta (0xF9)**: Sent after each standard data frame (type 0x00) with SNR (1 byte, signed, value x4) and RSSI (1 byte, signed, dBm). Queued with the data frame; omitted if the data frame cannot be queued. Enabled by default; toggle with SetSignalReport. Standard KISS clients ignore this frame.
|
||||
|
||||
## Data Formats
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ Once you have a working app/project, you need to be able to demonstrate it exist
|
||||
| 0000 - 00FF | -reserved for internal use- | |
|
||||
| 0100 | MeshCore Open | zsylvester@monitormx.com — https://github.com/zjs81/meshcore-open |
|
||||
| 0110 - 011F | Ripple | ripple_biz@protonmail.com — https://buymeacoffee.com/ripplebiz |
|
||||
| 0120 | MCO Advanced | most.original.address@gmail.com — https://hdden.ru/MCOa/ |
|
||||
| 0130 - 013F | StreamSensor | william@housedillon.com - https://housedillon.com/blog/lora-e5-with-seeed-fusion |
|
||||
| FF00 - FFFF | -reserved for testing/dev- | |
|
||||
|
||||
(add rows, inside the range 0100 - FEFF for custom apps)
|
||||
+62
-27
@@ -12,6 +12,7 @@ Inside each [MeshCore Packet](./packet_format.md) is a payload, identified by th
|
||||
* Group text message (unverified).
|
||||
* Group datagram (unverified).
|
||||
* Multi-part packet
|
||||
* Control data packet
|
||||
* Custom packet (raw bytes, custom encryption).
|
||||
|
||||
This document defines the structure of each of these payload types.
|
||||
@@ -22,7 +23,7 @@ NOTE: all 16 and 32-bit integer fields are Little Endian.
|
||||
|
||||
* Node hash: the first byte of the node's public key
|
||||
|
||||
# Node advertisement
|
||||
## Node advertisement
|
||||
This kind of payload notifies receivers that a node exists, and gives information about the node
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
@@ -56,7 +57,7 @@ Appdata Flags
|
||||
| `0x40` | has feature 2 | Reserved for future use. |
|
||||
| `0x80` | has name | appdata contains a node name |
|
||||
|
||||
# Acknowledgement
|
||||
## Acknowledgement
|
||||
|
||||
An acknowledgement that a message was received. Note that for returned path messages, an acknowledgement can be sent in the "extra" payload (see [Returned Path](#returned-path)) instead of as a separate acknowledgement packet. CLI commands do not cause acknowledgement responses, neither discrete nor extra.
|
||||
|
||||
@@ -65,7 +66,7 @@ An acknowledgement that a message was received. Note that for returned path mess
|
||||
| checksum | 4 | CRC checksum of message timestamp, text, and sender pubkey |
|
||||
|
||||
|
||||
# Returned path, request, response, and plain text message
|
||||
## Returned path, request, response, and plain text message
|
||||
|
||||
Returned path, request, response, and plain text messages are all formatted in the same way. See the subsection for more details about the ciphertext's associated plaintext representation.
|
||||
|
||||
@@ -76,7 +77,7 @@ Returned path, request, response, and plain text messages are all formatted in t
|
||||
| cipher MAC | 2 | MAC for encrypted data in next field |
|
||||
| ciphertext | rest of payload | encrypted message, see subsections below for details |
|
||||
|
||||
## Returned path
|
||||
### Returned path
|
||||
|
||||
Returned path messages provide a description of the route a packet took from the original author. Receivers will send returned path messages to the author of the original message.
|
||||
|
||||
@@ -87,7 +88,7 @@ Returned path messages provide a description of the route a packet took from the
|
||||
| extra type | 1 | extra, bundled payload type, eg., acknowledgement or response. Same values as in [Packet Format](./packet_format.md) |
|
||||
| extra | rest of data | extra, bundled payload content, follows same format as main content defined by this document |
|
||||
|
||||
## Request
|
||||
### Request
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|--------------|-----------------|------------------------------------------|
|
||||
@@ -101,7 +102,7 @@ For the common chat/server helpers in `BaseChatMesh`, the current request type v
|
||||
| `0x01` | get stats | get stats of repeater or room server |
|
||||
| `0x02` | keepalive | keep-alive request used for maintained connections |
|
||||
|
||||
### Get stats
|
||||
#### Get stats
|
||||
|
||||
Gets information about the node, possibly including the following:
|
||||
|
||||
@@ -124,32 +125,32 @@ Gets information about the node, possibly including the following:
|
||||
* Number posted (?)
|
||||
* Number of post pushes (?)
|
||||
|
||||
### Get telemetry data
|
||||
#### Get telemetry data
|
||||
|
||||
Not defined in `BaseChatMesh`. Sensor- and application-specific request payloads may be implemented by higher-level firmware.
|
||||
|
||||
### Get Telemetry
|
||||
#### Get Telemetry
|
||||
|
||||
Not defined in `BaseChatMesh`.
|
||||
|
||||
### Get Min/Max/Ave (Sensor nodes)
|
||||
#### Get Min/Max/Ave (Sensor nodes)
|
||||
|
||||
Not defined in `BaseChatMesh`.
|
||||
|
||||
### Get Access List
|
||||
#### Get Access List
|
||||
|
||||
Not defined in `BaseChatMesh`.
|
||||
|
||||
### Get Neighbors
|
||||
#### Get Neighbors
|
||||
|
||||
Not defined in `BaseChatMesh`.
|
||||
|
||||
### Get Owner Info
|
||||
#### Get Owner Info
|
||||
|
||||
Not defined in `BaseChatMesh`.
|
||||
|
||||
|
||||
## Response
|
||||
### Response
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|---------|-----------------|-----------------------------------|
|
||||
@@ -157,7 +158,7 @@ Not defined in `BaseChatMesh`.
|
||||
|
||||
Response contents are opaque application data. There is no single generic response envelope beyond the encrypted payload wrapper shown above.
|
||||
|
||||
## Plain text message
|
||||
### Plain text message
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|--------------------|-----------------|-----------------------------------------------------------------------------------|
|
||||
@@ -165,7 +166,7 @@ Response contents are opaque application data. There is no single generic respon
|
||||
| txt_type + attempt | 1 | upper six bits are txt_type (see below), lower two bits are attempt number (0..3) |
|
||||
| message | rest of payload | the message content, see next table |
|
||||
|
||||
Flags
|
||||
txt_type
|
||||
|
||||
| Value | Description | Message content |
|
||||
|--------|---------------------------|--------------------------------------------------------------------------|
|
||||
@@ -173,7 +174,7 @@ Flags
|
||||
| `0x01` | CLI command | the command text of the message |
|
||||
| `0x02` | signed plain text message | first four bytes is sender pubkey prefix, followed by plain text message |
|
||||
|
||||
# Anonymous request
|
||||
## Anonymous request
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|------------------|-----------------|-------------------------------------------|
|
||||
@@ -182,15 +183,22 @@ Flags
|
||||
| cipher MAC | 2 | MAC for encrypted data in next field |
|
||||
| ciphertext | rest of payload | encrypted message, see below for details |
|
||||
|
||||
Plaintext message
|
||||
### Room server login
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|----------------|-----------------|-------------------------------------------------------------------------------|
|
||||
| timestamp | 4 | send time (unix timestamp) |
|
||||
| sync timestamp | 4 | NOTE: room server only! - sender's "sync messages SINCE x" timestamp |
|
||||
| password | rest of message | password for repeater/room |
|
||||
| timestamp | 4 | sender time (unix timestamp) |
|
||||
| sync timestamp | 4 | sender's "sync messages SINCE x" timestamp |
|
||||
| password | rest of message | password for room |
|
||||
|
||||
## Repeater - Regions request
|
||||
### Repeater/Sensor login
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|----------------|-----------------|-------------------------------------------------------------------------------|
|
||||
| timestamp | 4 | sender time (unix timestamp) |
|
||||
| password | rest of message | password for repeater/sensor |
|
||||
|
||||
### Repeater - Regions request
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|----------------|--------------|------------------------------|
|
||||
@@ -199,7 +207,7 @@ Plaintext message
|
||||
| reply path len | 1 | path len for reply |
|
||||
| reply path | (variable) | reply path |
|
||||
|
||||
## Repeater - Owner info request
|
||||
### Repeater - Owner info request
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|----------------|--------------|------------------------------|
|
||||
@@ -208,7 +216,7 @@ Plaintext message
|
||||
| reply path len | 1 | path len for reply |
|
||||
| reply path | (variable) | reply path |
|
||||
|
||||
## Repeater - Clock and status request
|
||||
### Repeater - Clock and status request
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|----------------|--------------|------------------------------|
|
||||
@@ -218,7 +226,7 @@ Plaintext message
|
||||
| reply path | (variable) | reply path |
|
||||
|
||||
|
||||
# Group text message
|
||||
## Group text message
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|--------------|-----------------|----------------------------------------------|
|
||||
@@ -228,7 +236,10 @@ Plaintext message
|
||||
|
||||
The plaintext contained in the ciphertext matches the format described in [plain text message](#plain-text-message). Specifically, it consists of a four byte timestamp, a flags byte, and the message. The flags byte will generally be `0x00` because it is a "plain text message". The message will be of the form `<sender name>: <message body>` (eg., `user123: I'm on my way`).
|
||||
|
||||
# Group datagram
|
||||
The sender name is unverified message text. Group messages contain no sender
|
||||
signature, so any channel-key holder can choose any sender name.
|
||||
|
||||
## Group datagram
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|--------------|-----------------|----------------------------------------------|
|
||||
@@ -245,8 +256,32 @@ The data contained in the ciphertext uses the format below:
|
||||
| data | rest of payload | (depends on data type) |
|
||||
|
||||
|
||||
TODO: describe what datagram looks like
|
||||
## Control data
|
||||
|
||||
# Custom packet
|
||||
| Field | Size (bytes) | Description |
|
||||
|--------------|-----------------|--------------------------------------------|
|
||||
| flags | 1 | upper 4 bits is sub_type |
|
||||
| data | rest of payload | typically unencrypted data |
|
||||
|
||||
### DISCOVER_REQ (sub_type)
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|--------------|-----------------|----------------------------------------------|
|
||||
| flags | 1 | 0x8 (upper 4 bits), prefix_only (lowest bit) |
|
||||
| type_filter | 1 | bit for each ADV_TYPE_* |
|
||||
| tag | 4 | randomly generate by sender |
|
||||
| since | 4 | (optional) epoch timestamp (0 by default) |
|
||||
|
||||
### DISCOVER_RESP (sub_type)
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|--------------|-----------------|--------------------------------------------|
|
||||
| flags | 1 | 0x9 (upper 4 bits), node_type (lower 4) |
|
||||
| snr | 1 | signed, SNR*4 |
|
||||
| tag | 4 | reflected back from DISCOVER_REQ |
|
||||
| pubkey | 8 or 32 | node's ID (or prefix) |
|
||||
|
||||
|
||||
## Custom packet
|
||||
|
||||
Custom packets have no defined format.
|
||||
+3
-1
@@ -12,8 +12,10 @@ meshcore://channel/add?name=Public&secret=8b3387e9c5cdea6ac9e5edbaa115cd72
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `name`: Channel name (URL-encoded if needed)
|
||||
- `name`: Channel name (URL-encoded)
|
||||
- `secret`: 16-byte secret represented as 32 hex characters
|
||||
- `region_scope`: Region Scope (optional, URL-encoded if provided)
|
||||
- Supported by MeshCore App v1.47.0+
|
||||
|
||||
## Add Contact
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/BaseSerialInterface.h>
|
||||
#include <helpers/MultiSerialInterface.h>
|
||||
#include <Arduino.h>
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
@@ -25,10 +25,10 @@ enum class UIEventType {
|
||||
class AbstractUITask {
|
||||
protected:
|
||||
mesh::MainBoard* _board;
|
||||
BaseSerialInterface* _serial;
|
||||
MultiSerialInterface* _interfaceManager;
|
||||
bool _connected;
|
||||
|
||||
AbstractUITask(mesh::MainBoard* board, BaseSerialInterface* serial) : _board(board), _serial(serial) {
|
||||
AbstractUITask(mesh::MainBoard* board, MultiSerialInterface* interfaceManager) : _board(board), _interfaceManager(interfaceManager) {
|
||||
_connected = false;
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ public:
|
||||
void setHasConnection(bool connected) { _connected = connected; }
|
||||
bool hasConnection() const { return _connected; }
|
||||
uint16_t getBattMilliVolts() const { return _board->getBattMilliVolts(); }
|
||||
bool isSerialEnabled() const { return _serial->isEnabled(); }
|
||||
void enableSerial() { _serial->enable(); }
|
||||
void disableSerial() { _serial->disable(); }
|
||||
bool isBluetoothEnabled() const { return _interfaceManager->isBluetoothEnabled(); }
|
||||
void enableBluetooth() { _interfaceManager->enableBluetooth(); }
|
||||
void disableBluetooth() { _interfaceManager->disableBluetooth(); }
|
||||
virtual void msgRead(int msgcount) = 0;
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0;
|
||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||
|
||||
@@ -189,17 +189,22 @@ bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) {
|
||||
return identity_store.save("_main", identity);
|
||||
}
|
||||
|
||||
void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) {
|
||||
if (_fs->exists("/new_prefs")) {
|
||||
loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); // new filename
|
||||
} else if (_fs->exists("/node_prefs")) {
|
||||
loadPrefsInt("/node_prefs", prefs, node_lat, node_lon);
|
||||
savePrefs(prefs, node_lat, node_lon); // save to new filename
|
||||
_fs->remove("/node_prefs"); // remove old
|
||||
void DataStore::loadPrefs(NodePrefs& prefs) {
|
||||
if (_fs->exists("/prefs.json")) {
|
||||
File file = openRead(_fs, "/prefs.json");
|
||||
if (file) {
|
||||
prefs.loadSerial(file); // new Serial prefs
|
||||
file.close();
|
||||
}
|
||||
} else if (_fs->exists("/new_prefs")) {
|
||||
loadPrefsInt("/new_prefs", prefs);
|
||||
if (savePrefs(prefs) ) { // save to new format
|
||||
//_fs->remove("/new_prefs"); // remove old
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& node_lat, double& node_lon) {
|
||||
void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs) {
|
||||
File file = openRead(_fs, filename);
|
||||
if (file) {
|
||||
uint8_t pad[8];
|
||||
@@ -207,12 +212,12 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
file.read((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0
|
||||
file.read((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4
|
||||
file.read(pad, 4); // 36
|
||||
file.read((uint8_t *)&node_lat, sizeof(node_lat)); // 40
|
||||
file.read((uint8_t *)&node_lon, sizeof(node_lon)); // 48
|
||||
file.read((uint8_t *)&_prefs.node_lat, sizeof(_prefs.node_lat)); // 40
|
||||
file.read((uint8_t *)&_prefs.node_lon, sizeof(_prefs.node_lon)); // 48
|
||||
file.read((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56
|
||||
file.read((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60
|
||||
file.read((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61
|
||||
file.read((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62
|
||||
file.read((uint8_t *)&_prefs._client_repeat, sizeof(_prefs._client_repeat)); // 62
|
||||
file.read((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63
|
||||
file.read((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64
|
||||
file.read((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68
|
||||
@@ -234,48 +239,21 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90
|
||||
file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121
|
||||
|
||||
// migrate old fields
|
||||
_prefs.setRepeatEn(_prefs._client_repeat != 0);
|
||||
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
|
||||
File file = openWrite(_fs, "/new_prefs");
|
||||
bool DataStore::savePrefs(NodePrefs& _prefs) {
|
||||
File file = openWrite(_fs, "/prefs.json");
|
||||
if (file) {
|
||||
uint8_t pad[8];
|
||||
memset(pad, 0, sizeof(pad));
|
||||
|
||||
file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0
|
||||
file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4
|
||||
file.write(pad, 4); // 36
|
||||
file.write((uint8_t *)&node_lat, sizeof(node_lat)); // 40
|
||||
file.write((uint8_t *)&node_lon, sizeof(node_lon)); // 48
|
||||
file.write((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56
|
||||
file.write((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60
|
||||
file.write((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61
|
||||
file.write((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62
|
||||
file.write((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63
|
||||
file.write((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64
|
||||
file.write((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68
|
||||
file.write((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); // 69
|
||||
file.write((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); // 70
|
||||
file.write((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); // 71
|
||||
file.write((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); // 72
|
||||
file.write((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); // 76
|
||||
file.write((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); // 77
|
||||
file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 78
|
||||
file.write(pad, 1); // 79
|
||||
file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80
|
||||
file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84
|
||||
file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85
|
||||
file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
|
||||
file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 88
|
||||
file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89
|
||||
file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90
|
||||
file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121
|
||||
|
||||
bool success = _prefs.saveSerial(file);
|
||||
file.close();
|
||||
return success;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void DataStore::loadContacts(DataStoreHost* host) {
|
||||
@@ -545,7 +523,7 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src
|
||||
uint32_t pos = 0, found_pos = 0;
|
||||
uint32_t min_timestamp = 0xFFFFFFFF;
|
||||
|
||||
// search for matching key OR evict by oldest timestmap
|
||||
// search for matching key OR evict by oldest timestamp
|
||||
BlobRec tmp;
|
||||
file.seek(0);
|
||||
while (file.read((uint8_t *) &tmp, sizeof(tmp)) == sizeof(tmp)) {
|
||||
|
||||
@@ -19,7 +19,7 @@ class DataStore {
|
||||
mesh::RTCClock* _clock;
|
||||
IdentityStore identity_store;
|
||||
|
||||
void loadPrefsInt(const char *filename, NodePrefs& prefs, double& node_lat, double& node_lon);
|
||||
void loadPrefsInt(const char *filename, NodePrefs& prefs);
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
void checkAdvBlobFile();
|
||||
#endif
|
||||
@@ -33,8 +33,8 @@ public:
|
||||
FILESYSTEM* getSecondaryFS() const { return _fsExtra; }
|
||||
bool loadMainIdentity(mesh::LocalIdentity &identity);
|
||||
bool saveMainIdentity(const mesh::LocalIdentity &identity);
|
||||
void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon);
|
||||
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
|
||||
void loadPrefs(NodePrefs& prefs);
|
||||
bool savePrefs(NodePrefs& prefs);
|
||||
void loadContacts(DataStoreHost* host);
|
||||
void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL);
|
||||
void loadChannels(DataStoreHost* host);
|
||||
|
||||
@@ -259,7 +259,7 @@ float MyMesh::getAirtimeBudgetFactor() const {
|
||||
}
|
||||
|
||||
bool MyMesh::getCADEnabled() const {
|
||||
return true; // hardware CAD before TX (no CLI toggle on companion; enabled by default)
|
||||
return false; // hardware CAD before TX (disabled by default, until configurable)
|
||||
}
|
||||
|
||||
int MyMesh::getInterferenceThreshold() const {
|
||||
@@ -484,7 +484,7 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
|
||||
}
|
||||
|
||||
bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
|
||||
return _prefs.client_repeat != 0;
|
||||
return _prefs.isRepeatEn();
|
||||
}
|
||||
|
||||
void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
@@ -658,6 +658,11 @@ uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_tim
|
||||
// query other sensors -- target specific
|
||||
sensors.querySensors(permissions, telemetry);
|
||||
|
||||
float temperature = board.getMCUTemperature();
|
||||
if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN
|
||||
telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); // Built-in MCU Temperature
|
||||
}
|
||||
|
||||
memcpy(reply, &sender_timestamp,
|
||||
4); // reflect sender_timestamp back in response packet (kind of like a 'tag')
|
||||
|
||||
@@ -858,7 +863,7 @@ void MyMesh::onSendTimeout() {}
|
||||
|
||||
MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui)
|
||||
: BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables),
|
||||
_serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui) {
|
||||
_serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) {
|
||||
_iter_started = false;
|
||||
_cli_rescue = false;
|
||||
offline_queue_len = 0;
|
||||
@@ -872,7 +877,6 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
send_unscoped = false;
|
||||
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
_prefs.airtime_factor = 1.0; // one half
|
||||
strcpy(_prefs.node_name, "NONAME");
|
||||
_prefs.freq = LORA_FREQ;
|
||||
@@ -883,6 +887,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
_prefs.gps_enabled = 0; // GPS disabled by default
|
||||
_prefs.gps_interval = 0; // No automatic GPS updates by default
|
||||
//_prefs.rx_delay_base = 10.0f; enable once new algo fixed
|
||||
_prefs.setRepeatEn(false);
|
||||
#if defined(USE_SX1262) || defined(USE_SX1268)
|
||||
#ifdef SX126X_RX_BOOSTED_GAIN
|
||||
_prefs.rx_boosted_gain = SX126X_RX_BOOSTED_GAIN;
|
||||
@@ -927,7 +932,9 @@ void MyMesh::begin(bool has_display) {
|
||||
#endif
|
||||
|
||||
// load persisted prefs
|
||||
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
|
||||
_store->loadPrefs(_prefs);
|
||||
sensors.node_lat = _prefs.node_lat;
|
||||
sensors.node_lon = _prefs.node_lon;
|
||||
|
||||
// sanitise bad pref values
|
||||
_prefs.rx_delay_base = constrain(_prefs.rx_delay_base, 0, 20.0f);
|
||||
@@ -1029,7 +1036,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
i += 40;
|
||||
StrHelper::strzcpy((char *)&out_frame[i], FIRMWARE_VERSION, 20);
|
||||
i += 20;
|
||||
out_frame[i++] = _prefs.client_repeat; // v9+
|
||||
out_frame[i++] = _prefs.isRepeatEn() ? 1 : 0; // v9+
|
||||
out_frame[i++] = _prefs.path_hash_mode; // v10+
|
||||
_serial->writeFrame(out_frame, i);
|
||||
} else if (cmd_frame[0] == CMD_APP_START &&
|
||||
@@ -1391,7 +1398,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
_prefs.cr = cr;
|
||||
_prefs.freq = (float)freq / 1000.0;
|
||||
_prefs.bw = (float)bw / 1000.0;
|
||||
_prefs.client_repeat = repeat;
|
||||
_prefs.setRepeatEn(repeat != 0);
|
||||
savePrefs();
|
||||
|
||||
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
@@ -1548,6 +1555,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
memcpy(anon.id.pub_key, pub_key, PUB_KEY_SIZE);
|
||||
anon.out_path_len = 0; // default to zero-hop direct
|
||||
anon.type = ADV_TYPE_NONE; // unknown
|
||||
anon.lastmod = getRTCClock()->getCurrentTime();
|
||||
|
||||
if (addContact(anon)) recipient = &anon;
|
||||
}
|
||||
@@ -1642,6 +1650,11 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
} else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len == 4) { // 'self' telemetry request
|
||||
telemetry.reset();
|
||||
telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
|
||||
float temperature = board.getMCUTemperature();
|
||||
if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN
|
||||
telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); // Built-in MCU Temperature
|
||||
}
|
||||
|
||||
// query other sensors -- target specific
|
||||
sensors.querySensors(0xFF, telemetry);
|
||||
|
||||
@@ -1987,6 +2000,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
sendPacket(pkt, priority, 0);
|
||||
writeOKFrame();
|
||||
} else {
|
||||
releasePacket(pkt);
|
||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
||||
}
|
||||
} else {
|
||||
@@ -2192,15 +2206,7 @@ void MyMesh::checkSerialInterface() {
|
||||
&& !_serial->isWriteBusy() // don't spam the Serial Interface too quickly!
|
||||
) {
|
||||
ContactInfo contact;
|
||||
bool found = false;
|
||||
while (_iter.hasNext(this, contact)) {
|
||||
if (contact.type != ADV_TYPE_NONE) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
if (_iter.hasNext(this, contact)) {
|
||||
if (contact.lastmod > _iter_filter_since) { // apply the 'since' filter
|
||||
writeContactRespFrame(RESP_CODE_CONTACT, contact);
|
||||
if (contact.lastmod > _most_recent_lastmod) {
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 13
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "6 Jun 2026"
|
||||
#define FIRMWARE_BUILD_DATE "9 Aug 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "v1.16.0"
|
||||
#define FIRMWARE_VERSION "v1.17.0"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
@@ -165,7 +165,11 @@ protected:
|
||||
}
|
||||
|
||||
public:
|
||||
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
|
||||
void savePrefs() {
|
||||
_prefs.node_lat = sensors.node_lat;
|
||||
_prefs.node_lon = sensors.node_lon;
|
||||
_store->savePrefs(_prefs);
|
||||
}
|
||||
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
void applyGpsPrefs() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include <cstdint> // For uint8_t, uint32_t
|
||||
#include <helpers/ConfigSerializer.h>
|
||||
|
||||
#define TELEM_MODE_DENY 0
|
||||
#define TELEM_MODE_ALLOW_FLAGS 1 // use contact.flags
|
||||
@@ -8,30 +9,130 @@
|
||||
#define ADVERT_LOC_NONE 0
|
||||
#define ADVERT_LOC_SHARE 1
|
||||
|
||||
struct NodePrefs { // persisted to file
|
||||
float airtime_factor;
|
||||
class NodePrefs : public ConfigSerializer { // persisted to file
|
||||
public:
|
||||
float airtime_factor = 0;
|
||||
char node_name[32];
|
||||
float freq;
|
||||
uint8_t sf;
|
||||
uint8_t cr;
|
||||
uint8_t multi_acks;
|
||||
uint8_t manual_add_contacts;
|
||||
float bw;
|
||||
int8_t tx_power_dbm;
|
||||
uint8_t telemetry_mode_base;
|
||||
uint8_t telemetry_mode_loc;
|
||||
uint8_t telemetry_mode_env;
|
||||
float rx_delay_base;
|
||||
uint32_t ble_pin;
|
||||
uint8_t advert_loc_policy;
|
||||
uint8_t buzzer_quiet;
|
||||
uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled)
|
||||
uint32_t gps_interval; // GPS read interval in seconds
|
||||
uint8_t autoadd_config; // bitmask for auto-add contacts config
|
||||
uint8_t rx_boosted_gain; // SX126x RX boosted gain mode (0=power saving, 1=boosted)
|
||||
uint8_t client_repeat;
|
||||
uint8_t path_hash_mode; // which path mode to use when sending
|
||||
uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64)
|
||||
double node_lat = 0, node_lon = 0;
|
||||
float freq = 0;
|
||||
uint8_t sf = 0;
|
||||
uint8_t cr = 0;
|
||||
uint8_t multi_acks = 0;
|
||||
uint8_t manual_add_contacts = 0;
|
||||
float bw = 0;
|
||||
int8_t tx_power_dbm = 0;
|
||||
uint8_t telemetry_mode_base = 0;
|
||||
uint8_t telemetry_mode_loc = 0;
|
||||
uint8_t telemetry_mode_env = 0;
|
||||
float rx_delay_base = 0;
|
||||
uint32_t ble_pin = 0;
|
||||
uint8_t advert_loc_policy = 0;
|
||||
uint8_t buzzer_quiet = 0;
|
||||
uint8_t vibe_quiet = 0;
|
||||
uint8_t gps_enabled = 0; // GPS enabled flag (0=disabled, 1=enabled)
|
||||
uint32_t gps_interval = 0; // GPS read interval in seconds
|
||||
uint8_t autoadd_config = 0; // bitmask for auto-add contacts config
|
||||
uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode (0=power saving, 1=boosted)
|
||||
uint8_t _client_repeat = 0; // DEPRECATED -> use repeat.disable_fwd
|
||||
uint8_t path_hash_mode = 0; // which path mode to use when sending
|
||||
uint8_t autoadd_max_hops = 0; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64)
|
||||
char default_scope_name[31];
|
||||
uint8_t default_scope_key[16];
|
||||
|
||||
private:
|
||||
class RadioPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now)
|
||||
NodePrefs* _parent;
|
||||
protected:
|
||||
void structure() override {
|
||||
def("freq", _parent->freq);
|
||||
def("bw", _parent->bw);
|
||||
def("sf", _parent->sf);
|
||||
def("cr", _parent->cr);
|
||||
//def("cad", _parent->cad_enabled);
|
||||
//def("int_thr", _parent->interference_threshold);
|
||||
def("rxgain", _parent->rx_boosted_gain);
|
||||
def("fem_rxgain", _parent->rx_boosted_gain);
|
||||
def("tx", _parent->tx_power_dbm);
|
||||
def("af", _parent->airtime_factor);
|
||||
def("rxdelay", _parent->rx_delay_base);
|
||||
//def("f_txdelay", _parent->tx_delay_factor); currently hard-coded
|
||||
//def("d_txdelay", _parent->direct_tx_delay_factor); currently hard-coded
|
||||
//def("agc_int", _parent->agc_reset_interval);
|
||||
def("hash_mode", _parent->path_hash_mode);
|
||||
def("multi_ack", _parent->multi_acks);
|
||||
}
|
||||
public:
|
||||
RadioPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
RadioPrefs radio;
|
||||
|
||||
class GPSPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now)
|
||||
NodePrefs* _parent;
|
||||
protected:
|
||||
void structure() override {
|
||||
def("en", _parent->gps_enabled); // boolean
|
||||
def("int", _parent->gps_interval); // interval in seconds
|
||||
def("adv_loc", _parent->advert_loc_policy);
|
||||
}
|
||||
public:
|
||||
GPSPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
GPSPrefs gps;
|
||||
|
||||
class RepeatPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now)
|
||||
public:
|
||||
uint8_t disable_fwd = 1;
|
||||
protected:
|
||||
void structure() override {
|
||||
def("disable", disable_fwd);
|
||||
//def("f_max", flood_max);
|
||||
//def("f_max_uns", flood_max_unscoped);
|
||||
//def("f_max_adv", flood_max_advert);
|
||||
//def("loop", loop_detect);
|
||||
}
|
||||
};
|
||||
RepeatPrefs repeat;
|
||||
|
||||
class CompanionPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
protected:
|
||||
void structure() override {
|
||||
def("auto_max", _parent->autoadd_max_hops); // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64)
|
||||
def("defs_nm", _parent->default_scope_name, sizeof(_parent->default_scope_name));
|
||||
def("defs_key", (void *) _parent->default_scope_key, sizeof(_parent->default_scope_key));
|
||||
def("pin", _parent->ble_pin);
|
||||
def("buzz_q", _parent->buzzer_quiet);
|
||||
def("vibe_q", _parent->vibe_quiet);
|
||||
def("auto_add", _parent->autoadd_config); // bitmask for auto-add contacts config
|
||||
def("man_add", _parent->manual_add_contacts);
|
||||
def("tel_base", _parent->telemetry_mode_base);
|
||||
def("tel_loc", _parent->telemetry_mode_loc);
|
||||
def("tel_env", _parent->telemetry_mode_env);
|
||||
}
|
||||
public:
|
||||
CompanionPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
CompanionPrefs companion;
|
||||
|
||||
protected:
|
||||
void structure() override {
|
||||
def("name", node_name, sizeof(node_name));
|
||||
//def("adv_int", advert_interval);
|
||||
//def("f_adv_int", flood_advert_interval);
|
||||
def("lat", node_lat);
|
||||
def("lon", node_lon);
|
||||
def("radio", radio);
|
||||
def("gps", gps);
|
||||
def("repeat", repeat);
|
||||
def("comp", companion);
|
||||
}
|
||||
public:
|
||||
NodePrefs() : radio(this), gps(this), companion(this) {
|
||||
node_name[0] = 0;
|
||||
default_scope_name[0] = 0;
|
||||
memset(default_scope_key, 0, sizeof(default_scope_key));
|
||||
}
|
||||
// new accessor methods
|
||||
bool isRepeatEn() const { return repeat.disable_fwd == 0; }
|
||||
void setRepeatEn(bool en) { repeat.disable_fwd = en ? 0 : 1; }
|
||||
};
|
||||
@@ -12,19 +12,72 @@ static uint32_t _atoi(const char* sp) {
|
||||
return n;
|
||||
}
|
||||
|
||||
// interface manager
|
||||
#include <helpers/MultiSerialInterface.h>
|
||||
MultiSerialInterface interface_manager;
|
||||
|
||||
// include bluetooth interface
|
||||
#if defined(BLE_PIN_CODE)
|
||||
#ifdef ESP32
|
||||
// include esp32 bluetooth interface
|
||||
#include <helpers/esp32/SerialBLEInterface.h>
|
||||
SerialBLEInterface bluetooth_interface;
|
||||
#elif defined(NRF52_PLATFORM)
|
||||
// include nrf52 bluetooth interface
|
||||
#include <helpers/nrf52/SerialBLEInterface.h>
|
||||
SerialBLEInterface bluetooth_interface;
|
||||
#else
|
||||
#error "SerialBLEInterface is not defined for this platform"
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// include wifi interface
|
||||
#ifdef WIFI_SSID
|
||||
#ifndef TCP_PORT
|
||||
#define TCP_PORT 5000
|
||||
#endif
|
||||
#ifdef ESP32
|
||||
// include esp32 wifi interface
|
||||
#include <helpers/esp32/SerialWifiInterface.h>
|
||||
SerialWifiInterface wifi_interface;
|
||||
#else
|
||||
#error "SerialWifiInterface is not defined for this platform"
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// include usb interface
|
||||
#if defined(ENABLE_USB_INTERFACE)
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface usb_serial_interface;
|
||||
#endif
|
||||
|
||||
// include ethernet interface
|
||||
#if defined(ETHERNET_ENABLED)
|
||||
#include <helpers/ethernet/EthernetInterface.h>
|
||||
ETHERNET_CLASS ethernet_interface;
|
||||
#endif
|
||||
|
||||
// include hardware serial interface
|
||||
#if defined(SERIAL_RX)
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface hardware_serial_interface;
|
||||
HardwareSerial companion_serial(1);
|
||||
#endif
|
||||
|
||||
// platform file system
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
#include <InternalFileSystem.h>
|
||||
#if defined(QSPIFLASH)
|
||||
#include <CustomLFS_QSPIFlash.h>
|
||||
DataStore store(InternalFS, QSPIFlash, rtc_clock);
|
||||
#else
|
||||
#if defined(EXTRAFS)
|
||||
#include <CustomLFS.h>
|
||||
CustomLFS ExtraFS(0xD4000, 0x19000, 128);
|
||||
DataStore store(InternalFS, ExtraFS, rtc_clock);
|
||||
#else
|
||||
DataStore store(InternalFS, rtc_clock);
|
||||
#endif
|
||||
#if defined(EXTRAFS)
|
||||
#include <CustomLFS.h>
|
||||
CustomLFS ExtraFS(0xD4000, 0x19000, 128);
|
||||
DataStore store(InternalFS, ExtraFS, rtc_clock);
|
||||
#else
|
||||
DataStore store(InternalFS, rtc_clock);
|
||||
#endif
|
||||
#endif
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
#include <LittleFS.h>
|
||||
@@ -34,61 +87,10 @@ static uint32_t _atoi(const char* sp) {
|
||||
DataStore store(SPIFFS, rtc_clock);
|
||||
#endif
|
||||
|
||||
#ifdef ESP32
|
||||
#ifdef WIFI_SSID
|
||||
#include <helpers/esp32/SerialWifiInterface.h>
|
||||
SerialWifiInterface serial_interface;
|
||||
#ifndef TCP_PORT
|
||||
#define TCP_PORT 5000
|
||||
#endif
|
||||
#elif defined(BLE_PIN_CODE)
|
||||
#include <helpers/esp32/SerialBLEInterface.h>
|
||||
SerialBLEInterface serial_interface;
|
||||
#elif defined(SERIAL_RX)
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
HardwareSerial companion_serial(1);
|
||||
#else
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
#endif
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
//#ifdef WIFI_SSID
|
||||
// #include <helpers/rp2040/SerialWifiInterface.h>
|
||||
// SerialWifiInterface serial_interface;
|
||||
// #ifndef TCP_PORT
|
||||
// #define TCP_PORT 5000
|
||||
// #endif
|
||||
// #elif defined(BLE_PIN_CODE)
|
||||
// #include <helpers/rp2040/SerialBLEInterface.h>
|
||||
// SerialBLEInterface serial_interface;
|
||||
#if defined(SERIAL_RX)
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
HardwareSerial companion_serial(1);
|
||||
#else
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
#endif
|
||||
#elif defined(NRF52_PLATFORM)
|
||||
#ifdef BLE_PIN_CODE
|
||||
#include <helpers/nrf52/SerialBLEInterface.h>
|
||||
SerialBLEInterface serial_interface;
|
||||
#else
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
#endif
|
||||
#elif defined(STM32_PLATFORM)
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
#else
|
||||
#error "need to define a serial interface"
|
||||
#endif
|
||||
|
||||
/* GLOBAL OBJECTS */
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include "UITask.h"
|
||||
UITask ui_task(&board, &serial_interface);
|
||||
UITask ui_task(&board, &interface_manager);
|
||||
#endif
|
||||
|
||||
StdRNG fast_rng;
|
||||
@@ -113,9 +115,12 @@ void halt() {
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
board.begin();
|
||||
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.begin();
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
DisplayDriver* disp = NULL;
|
||||
if (display.begin()) {
|
||||
@@ -155,13 +160,6 @@ void setup() {
|
||||
false
|
||||
#endif
|
||||
);
|
||||
|
||||
#ifdef BLE_PIN_CODE
|
||||
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
||||
#else
|
||||
serial_interface.begin(Serial);
|
||||
#endif
|
||||
the_mesh.startInterface(serial_interface);
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
LittleFS.begin();
|
||||
store.begin();
|
||||
@@ -172,22 +170,6 @@ void setup() {
|
||||
false
|
||||
#endif
|
||||
);
|
||||
|
||||
//#ifdef WIFI_SSID
|
||||
// WiFi.begin(WIFI_SSID, WIFI_PWD);
|
||||
// serial_interface.begin(TCP_PORT);
|
||||
// #elif defined(BLE_PIN_CODE)
|
||||
// char dev_name[32+16];
|
||||
// sprintf(dev_name, "%s%s", BLE_NAME_PREFIX, the_mesh.getNodeName());
|
||||
// serial_interface.begin(dev_name, the_mesh.getBLEPin());
|
||||
#if defined(SERIAL_RX)
|
||||
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
|
||||
companion_serial.begin(115200);
|
||||
serial_interface.begin(companion_serial);
|
||||
#else
|
||||
serial_interface.begin(Serial);
|
||||
#endif
|
||||
the_mesh.startInterface(serial_interface);
|
||||
#elif defined(ESP32)
|
||||
SPIFFS.begin(true);
|
||||
store.begin();
|
||||
@@ -198,7 +180,17 @@ void setup() {
|
||||
false
|
||||
#endif
|
||||
);
|
||||
#else
|
||||
#error "need to define filesystem"
|
||||
#endif
|
||||
|
||||
// add bluetooth interface
|
||||
#if defined(BLE_PIN_CODE)
|
||||
bluetooth_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
||||
interface_manager.addInterface(InterfaceType::Bluetooth, &bluetooth_interface);
|
||||
#endif
|
||||
|
||||
// add wifi interface
|
||||
#ifdef WIFI_SSID
|
||||
board.setInhibitSleep(true); // prevent sleep when WiFi is active
|
||||
WiFi.setAutoReconnect(true);
|
||||
@@ -214,21 +206,31 @@ void setup() {
|
||||
});
|
||||
|
||||
WiFi.begin(WIFI_SSID, WIFI_PWD);
|
||||
serial_interface.begin(TCP_PORT);
|
||||
#elif defined(BLE_PIN_CODE)
|
||||
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
||||
#elif defined(SERIAL_RX)
|
||||
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
|
||||
companion_serial.begin(115200);
|
||||
serial_interface.begin(companion_serial);
|
||||
#else
|
||||
serial_interface.begin(Serial);
|
||||
#endif
|
||||
the_mesh.startInterface(serial_interface);
|
||||
#else
|
||||
#error "need to define filesystem"
|
||||
wifi_interface.begin(TCP_PORT);
|
||||
interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface);
|
||||
#endif
|
||||
|
||||
// add usb interface
|
||||
#if defined(ENABLE_USB_INTERFACE)
|
||||
usb_serial_interface.begin(Serial);
|
||||
interface_manager.addInterface(InterfaceType::USB, &usb_serial_interface);
|
||||
#endif
|
||||
|
||||
// add ethernet interface
|
||||
#if defined(ETHERNET_ENABLED)
|
||||
ethernet_interface.begin();
|
||||
interface_manager.addInterface(InterfaceType::Ethernet, ðernet_interface);
|
||||
#endif
|
||||
|
||||
// add hardware serial interface
|
||||
#if defined(SERIAL_RX)
|
||||
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
|
||||
companion_serial.begin(115200);
|
||||
hardware_serial_interface.begin(companion_serial);
|
||||
interface_manager.addInterface(InterfaceType::HardwareSerial, &hardware_serial_interface);
|
||||
#endif
|
||||
|
||||
the_mesh.startInterface(interface_manager);
|
||||
sensors.begin();
|
||||
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
@@ -244,11 +246,15 @@ void setup() {
|
||||
|
||||
void loop() {
|
||||
the_mesh.loop();
|
||||
interface_manager.loop();
|
||||
sensors.loop();
|
||||
#ifdef DISPLAY_CLASS
|
||||
ui_task.loop();
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.loop();
|
||||
#endif
|
||||
|
||||
if (!the_mesh.hasPendingWork()) {
|
||||
#if defined(NRF52_PLATFORM)
|
||||
|
||||
@@ -53,23 +53,24 @@ public:
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
// meshcore logo
|
||||
display.setColor(DisplayDriver::BLUE);
|
||||
display.setColor(UIColor::corp_blue);
|
||||
int logoWidth = 128;
|
||||
display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||||
|
||||
// meshcore website
|
||||
const char* website = "https://meshcore.io";
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setTextSize(1);
|
||||
uint16_t websiteWidth = display.getTextWidth(website);
|
||||
display.setCursor((display.width() - websiteWidth) / 2, 22);
|
||||
display.print(website);
|
||||
|
||||
// version info
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width()/2, 35, _version_info);
|
||||
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width()/2, 48, FIRMWARE_BUILD_DATE);
|
||||
|
||||
@@ -128,7 +129,7 @@ class HomeScreen : public UIScreen {
|
||||
int iconHeight = 10;
|
||||
int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner
|
||||
int iconY = 0;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::title_txt);
|
||||
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
@@ -143,7 +144,7 @@ class HomeScreen : public UIScreen {
|
||||
// show muted icon if buzzer is muted
|
||||
#ifdef PIN_BUZZER
|
||||
if (_task->isBuzzerQuiet()) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setColor(UIColor::warning_txt);
|
||||
display.drawXbm(iconX - 9, iconY + 1, muted_icon, 8, 8);
|
||||
}
|
||||
#endif
|
||||
@@ -188,34 +189,41 @@ public:
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setColor(UIColor::title_bkg);
|
||||
display.fillRect(0, 0, display.width(), 12);
|
||||
char tmp[80];
|
||||
// node name
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::title_txt);
|
||||
char filtered_name[sizeof(_node_prefs->node_name)];
|
||||
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
||||
display.setCursor(0, 0);
|
||||
display.setCursor(0, 2);
|
||||
display.print(filtered_name);
|
||||
|
||||
// battery voltage
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||||
|
||||
// curr page indicator
|
||||
if (UIColor::title_bkg == UIColor::window_bkg) {
|
||||
display.setColor(UIColor::title_txt);
|
||||
} else {
|
||||
display.setColor(UIColor::title_bkg);
|
||||
}
|
||||
int y = 14;
|
||||
int x = display.width() / 2 - 5 * (HomePage::Count-1);
|
||||
for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) {
|
||||
if (i == _page) {
|
||||
display.fillRect(x-1, y-1, 3, 3);
|
||||
display.fillRect(x-1, y-1, 4, 4);
|
||||
} else {
|
||||
display.fillRect(x, y, 1, 1);
|
||||
display.fillRect(x, y, 2, 2);
|
||||
}
|
||||
}
|
||||
|
||||
if (_page == HomePage::FIRST) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "MSG: %d", _task->getMsgCount());
|
||||
display.drawTextCentered(display.width() / 2, 20, tmp);
|
||||
display.drawTextCentered(display.width() / 2, 22, tmp);
|
||||
|
||||
#ifdef WIFI_SSID
|
||||
IPAddress ip = WiFi.localIP();
|
||||
@@ -224,19 +232,19 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, 54, tmp);
|
||||
#endif
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::warning_txt);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 43, "< Connected >");
|
||||
|
||||
} else if (the_mesh.getBLEPin() != 0) { // BT pin
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setColor(UIColor::warning_txt);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
display.drawTextCentered(display.width() / 2, 43, tmp);
|
||||
}
|
||||
} else if (_page == HomePage::RECENT) {
|
||||
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
int y = 20;
|
||||
for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) {
|
||||
auto a = &recent[i];
|
||||
@@ -260,7 +268,7 @@ public:
|
||||
display.print(tmp);
|
||||
}
|
||||
} else if (_page == HomePage::RADIO) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setTextSize(1);
|
||||
// freq / sf
|
||||
display.setCursor(0, 20);
|
||||
@@ -279,15 +287,17 @@ public:
|
||||
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
|
||||
display.print(tmp);
|
||||
} else if (_page == HomePage::BLUETOOTH) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::corp_blue);
|
||||
display.drawXbm((display.width() - 32) / 2, 18,
|
||||
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
|
||||
_task->isBluetoothEnabled() ? bluetooth_on : bluetooth_off,
|
||||
32, 32);
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::corp_blue);
|
||||
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
} else if (_page == HomePage::GPS) {
|
||||
@@ -305,24 +315,33 @@ public:
|
||||
#else
|
||||
strcpy(buf, gps_state ? "gps on" : "gps off");
|
||||
#endif
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.drawTextLeftAlign(0, y, buf);
|
||||
if (nmea == NULL) {
|
||||
y = y + 12;
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.drawTextLeftAlign(0, y, "Can't access GPS");
|
||||
} else {
|
||||
display.setColor(UIColor::primary_txt);
|
||||
strcpy(buf, nmea->isValid()?"fix":"no fix");
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.drawTextLeftAlign(0, y, "sat");
|
||||
display.setColor(UIColor::primary_txt);
|
||||
sprintf(buf, "%d", nmea->satellitesCount());
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.drawTextLeftAlign(0, y, "pos");
|
||||
display.setColor(UIColor::primary_txt);
|
||||
sprintf(buf, "%.4f %.4f",
|
||||
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.drawTextLeftAlign(0, y, "alt");
|
||||
display.setColor(UIColor::primary_txt);
|
||||
sprintf(buf, "%.2f", nmea->getAltitude()/1000.);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
@@ -390,7 +409,9 @@ public:
|
||||
strcpy(name, "unk"); sprintf(buf, "");
|
||||
}
|
||||
display.setCursor(0, y);
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.print(name);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(
|
||||
display.width()-display.getTextWidth(buf)-1, y
|
||||
);
|
||||
@@ -401,11 +422,13 @@ public:
|
||||
else sensors_scroll_offset = 0;
|
||||
#endif
|
||||
} else if (_page == HomePage::SHUTDOWN) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::corp_blue);
|
||||
display.setTextSize(1);
|
||||
if (_shutdown_init) {
|
||||
display.setColor(UIColor::warning_txt);
|
||||
display.drawTextCentered(display.width() / 2, 34, "hibernating...");
|
||||
} else {
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL);
|
||||
}
|
||||
@@ -426,10 +449,10 @@ public:
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
|
||||
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
|
||||
_task->disableSerial();
|
||||
if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off
|
||||
_task->disableBluetooth();
|
||||
} else {
|
||||
_task->enableSerial();
|
||||
_task->enableBluetooth();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -498,7 +521,7 @@ public:
|
||||
char tmp[16];
|
||||
display.setCursor(0, 0);
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::corp_blue);
|
||||
sprintf(tmp, "Unread: %d", num_unread);
|
||||
display.print(tmp);
|
||||
|
||||
@@ -518,13 +541,13 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1); // horiz line
|
||||
|
||||
display.setCursor(0, 14);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
char filtered_origin[sizeof(p->origin)];
|
||||
display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin));
|
||||
display.print(filtered_origin);
|
||||
|
||||
display.setCursor(0, 25);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
char filtered_msg[sizeof(p->msg)];
|
||||
display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg));
|
||||
display.printWordWrap(filtered_msg, display.width());
|
||||
@@ -697,8 +720,7 @@ void UITask::shutdown(bool restart){
|
||||
if (restart) {
|
||||
_board->reboot();
|
||||
} else {
|
||||
_display->turnOff();
|
||||
radio_driver.powerOff();
|
||||
// Power off board including radio, display, GPS and components
|
||||
_board->powerOff();
|
||||
}
|
||||
}
|
||||
@@ -748,6 +770,16 @@ void UITask::loop() {
|
||||
c = handleTripleClick(KEY_SELECT);
|
||||
}
|
||||
#endif
|
||||
#if defined(UI_HAS_ROTARY_INPUT)
|
||||
RotaryInputEvent rotaryEv = rotary_input.poll();
|
||||
if (c == 0 && _display != NULL && _display->isOn()) {
|
||||
if (rotaryEv == RotaryInputEvent::Next) {
|
||||
c = KEY_NEXT;
|
||||
} else if (rotaryEv == RotaryInputEvent::Prev) {
|
||||
c = KEY_PREV;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if defined(PIN_USER_BTN_ANA)
|
||||
if (abs(millis() - _analogue_pin_read_millis) > 10) {
|
||||
int ev = analog_btn.check();
|
||||
@@ -797,9 +829,9 @@ void UITask::loop() {
|
||||
_display->setTextSize(1);
|
||||
int y = _display->height() / 3;
|
||||
int p = _display->height() / 32;
|
||||
_display->setColor(DisplayDriver::DARK);
|
||||
_display->setColor(UIColor::popup_bkg);
|
||||
_display->fillRect(p, y, _display->width() - p*2, y);
|
||||
_display->setColor(DisplayDriver::LIGHT); // draw box border
|
||||
_display->setColor(UIColor::popup_txt); // draw box border
|
||||
_display->drawRect(p, y, _display->width() - p*2, y);
|
||||
_display->drawTextCentered(_display->width() / 2, y + p*3, _alert);
|
||||
_next_refresh = _alert_expiry; // will need refresh when alert is dismissed
|
||||
@@ -836,7 +868,7 @@ void UITask::loop() {
|
||||
if (_display != NULL) {
|
||||
_display->startFrame();
|
||||
_display->setTextSize(2);
|
||||
_display->setColor(DisplayDriver::RED);
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->drawTextCentered(_display->width() / 2, 20, "Low Battery.");
|
||||
_display->drawTextCentered(_display->width() / 2, 40, "Shutting Down!");
|
||||
_display->endFrame();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/BaseSerialInterface.h>
|
||||
#include <helpers/MultiSerialInterface.h>
|
||||
#include <Arduino.h>
|
||||
#include <helpers/sensors/LPPDataHelpers.h>
|
||||
|
||||
@@ -65,7 +65,7 @@ class UITask : public AbstractUITask {
|
||||
|
||||
public:
|
||||
|
||||
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
UITask(mesh::MainBoard* board, MultiSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
next_batt_chck = _next_refresh = 0;
|
||||
ui_started_at = 0;
|
||||
curr = NULL;
|
||||
|
||||
@@ -6,12 +6,19 @@
|
||||
#define AUTO_OFF_MILLIS 15000 // 15 seconds
|
||||
#define BOOT_SCREEN_MILLIS 3000 // 3 seconds
|
||||
|
||||
#ifdef PIN_STATUS_LED
|
||||
#if defined(PIN_STATUS_LED) || defined(PIN_STATUS_LED_R)
|
||||
#define LED_ON_MILLIS 20
|
||||
#define LED_ON_MSG_MILLIS 200
|
||||
#define LED_CYCLE_MILLIS 4000
|
||||
#endif
|
||||
|
||||
#if defined(PIN_STATUS_LED_R) && defined(PIN_STATUS_LED_G) && defined(PIN_STATUS_LED_B)
|
||||
#define STATUS_LED_RGB 1
|
||||
#ifndef LOW_BATT_MILLIVOLTS
|
||||
#define LOW_BATT_MILLIVOLTS 3500
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifndef USER_BTN_PRESSED
|
||||
#define USER_BTN_PRESSED LOW
|
||||
#endif
|
||||
@@ -60,6 +67,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
buzzer.startup();
|
||||
#endif
|
||||
|
||||
#ifdef HAS_DRV2605
|
||||
vibration.begin();
|
||||
vibration.quiet(_node_prefs->vibe_quiet);
|
||||
#endif
|
||||
|
||||
// Initialize digital button if available
|
||||
#ifdef PIN_USER_BTN
|
||||
_userButton = new Button(PIN_USER_BTN, USER_BTN_PRESSED);
|
||||
@@ -130,6 +142,10 @@ void UITask::clearMsgPreview() {
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
|
||||
#ifdef HAS_DRV2605
|
||||
vibration.trigger(); // vibrate even while the app is connected (honors quiet + cooldown)
|
||||
#endif
|
||||
|
||||
if (path_len == 0xFF) {
|
||||
sprintf(_origin, "(F) %s", from_name);
|
||||
} else {
|
||||
@@ -167,7 +183,7 @@ void UITask::renderBatteryIndicator(uint16_t batteryMilliVolts) {
|
||||
int iconHeight = 12;
|
||||
int iconX = _display->width() - iconWidth - 5; // Position the icon near the top-right corner
|
||||
int iconY = 0;
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
|
||||
// battery outline
|
||||
_display->drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
@@ -188,7 +204,7 @@ void UITask::renderCurrScreen() {
|
||||
_display->setTextSize(1.4);
|
||||
uint16_t textWidth = _display->getTextWidth(_alert);
|
||||
_display->setCursor((_display->width() - textWidth) / 2, 22);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->print(_alert);
|
||||
_alert[0] = 0;
|
||||
_need_refresh = true;
|
||||
@@ -197,30 +213,29 @@ void UITask::renderCurrScreen() {
|
||||
// render message preview
|
||||
_display->setCursor(0, 0);
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->print(_node_prefs->node_name);
|
||||
|
||||
_display->setCursor(0, 12);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
_display->setColor(UIColor::secondary_txt);
|
||||
_display->print(_origin);
|
||||
_display->setCursor(0, 24);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->print(_msg);
|
||||
|
||||
_display->setCursor(_display->width() - 28, 9);
|
||||
_display->setTextSize(2);
|
||||
_display->setColor(DisplayDriver::ORANGE);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
sprintf(tmp, "%d", _msgcount);
|
||||
_display->print(tmp);
|
||||
_display->setColor(DisplayDriver::YELLOW); // last color will be kept on T114
|
||||
_display->setColor(UIColor::secondary_txt); // last color will be kept on T114
|
||||
} else if ((millis() - ui_started_at) < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
// meshcore logo
|
||||
_display->setColor(DisplayDriver::BLUE);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
int logoWidth = 128;
|
||||
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||||
|
||||
// version info
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setTextSize(1);
|
||||
uint16_t textWidth = _display->getTextWidth(_version_info);
|
||||
_display->setCursor((_display->width() - textWidth) / 2, 22);
|
||||
@@ -229,7 +244,7 @@ void UITask::renderCurrScreen() {
|
||||
// node name
|
||||
_display->setCursor(0, 0);
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->print(_node_prefs->node_name);
|
||||
|
||||
// battery voltage
|
||||
@@ -237,7 +252,7 @@ void UITask::renderCurrScreen() {
|
||||
|
||||
// freq / sf
|
||||
_display->setCursor(0, 20);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
_display->setColor(UIColor::secondary_txt);
|
||||
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
||||
_display->print(tmp);
|
||||
|
||||
@@ -248,21 +263,127 @@ void UITask::renderCurrScreen() {
|
||||
|
||||
// BT pin
|
||||
if (!_connected && the_mesh.getBLEPin() != 0) {
|
||||
_display->setColor(DisplayDriver::RED);
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->setTextSize(2);
|
||||
_display->setCursor(0, 43);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
_display->print(tmp);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
} else {
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
}
|
||||
}
|
||||
_need_refresh = false;
|
||||
}
|
||||
|
||||
#ifdef STATUS_LED_RGB
|
||||
static void statusLedWrite(uint8_t r, uint8_t g, uint8_t b) {
|
||||
analogWrite(PIN_STATUS_LED_R, r);
|
||||
analogWrite(PIN_STATUS_LED_G, g);
|
||||
analogWrite(PIN_STATUS_LED_B, b);
|
||||
}
|
||||
|
||||
static void statusLedWheel(uint8_t pos) { // smooth hue sweep, 0..255
|
||||
if (pos < 85) {
|
||||
statusLedWrite(255 - pos * 3, pos * 3, 0);
|
||||
} else if (pos < 170) {
|
||||
pos -= 85;
|
||||
statusLedWrite(0, 255 - pos * 3, pos * 3);
|
||||
} else {
|
||||
pos -= 170;
|
||||
statusLedWrite(pos * 3, 0, 255 - pos * 3);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void UITask::userLedHandler() {
|
||||
#ifdef PIN_STATUS_LED
|
||||
#ifdef STATUS_LED_RGB
|
||||
static bool booted = false;
|
||||
static bool flourish_active = false;
|
||||
static unsigned long flourish_until = 0;
|
||||
static int prev_msgcount = 0;
|
||||
static int state = 0;
|
||||
static unsigned long next_change = 0;
|
||||
static int last_increment = 0;
|
||||
static unsigned long next_batt_check = 0;
|
||||
static bool low_batt = false;
|
||||
|
||||
unsigned long cur_time = millis();
|
||||
|
||||
if (!booted) { // color sweep on boot
|
||||
booted = true;
|
||||
flourish_until = cur_time + 1200;
|
||||
flourish_active = true;
|
||||
}
|
||||
if (_msgcount > prev_msgcount) { // color sweep when a new message arrives
|
||||
flourish_until = cur_time + 800;
|
||||
flourish_active = true;
|
||||
}
|
||||
prev_msgcount = _msgcount;
|
||||
|
||||
if (flourish_active) {
|
||||
if (cur_time < flourish_until) {
|
||||
statusLedWheel((cur_time % 1200) * 255 / 1200);
|
||||
return;
|
||||
}
|
||||
statusLedWrite(0, 0, 0);
|
||||
flourish_active = false;
|
||||
state = 0;
|
||||
next_change = cur_time;
|
||||
}
|
||||
|
||||
if (cur_time > next_batt_check) { // battery reads are not free, keep them rare
|
||||
low_batt = _board->getBattMilliVolts() < LOW_BATT_MILLIVOLTS;
|
||||
next_batt_check = cur_time + 60000;
|
||||
}
|
||||
|
||||
#ifdef EXT_CHRG_DETECT
|
||||
// charge status display while docked (skipped when messages are waiting,
|
||||
// so the unread indication is never masked)
|
||||
static unsigned long next_pwr_check = 0;
|
||||
static bool ext_powered = false, ext_charging = false;
|
||||
if (cur_time > next_pwr_check) {
|
||||
ext_powered = _board->isExternalPowered();
|
||||
ext_charging = ext_powered && digitalRead(EXT_CHRG_DETECT) == LOW;
|
||||
next_pwr_check = cur_time + 1000;
|
||||
}
|
||||
if (ext_powered && _msgcount == 0) {
|
||||
if (ext_charging) {
|
||||
// amber breathing while charging
|
||||
int ph = cur_time % 2000;
|
||||
int v = ph < 1000 ? ph : 2000 - ph;
|
||||
uint8_t lvl = (uint8_t)(v * 255 / 1000);
|
||||
statusLedWrite(lvl, (uint8_t)(lvl * 35 / 100), 0);
|
||||
} else {
|
||||
statusLedWrite(0, 40, 0); // dim solid green: charge complete
|
||||
}
|
||||
state = 0;
|
||||
next_change = cur_time;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (cur_time > next_change) {
|
||||
if (state == 0) {
|
||||
state = 1;
|
||||
last_increment = (_msgcount > 0) ? LED_ON_MSG_MILLIS : LED_ON_MILLIS;
|
||||
next_change = cur_time + last_increment;
|
||||
if (low_batt) {
|
||||
statusLedWrite(255, 0, 0); // red: battery low
|
||||
} else if (_msgcount > 0) {
|
||||
statusLedWrite(255, 90, 0); // amber: unread messages
|
||||
} else if (_connected) {
|
||||
statusLedWrite(0, 0, 255); // blue: app connected
|
||||
} else {
|
||||
statusLedWrite(0, 255, 0); // green: heartbeat
|
||||
}
|
||||
} else {
|
||||
state = 0;
|
||||
next_change = cur_time + LED_CYCLE_MILLIS - last_increment;
|
||||
statusLedWrite(0, 0, 0);
|
||||
}
|
||||
}
|
||||
#elif defined(PIN_STATUS_LED)
|
||||
static int state = 0;
|
||||
static int next_change = 0;
|
||||
static int last_increment = 0;
|
||||
@@ -307,7 +428,7 @@ void UITask::shutdown(bool restart){
|
||||
if (restart) {
|
||||
_board->reboot();
|
||||
} else {
|
||||
radio_driver.powerOff();
|
||||
// Power off board including radio, display, GPS and components
|
||||
_board->powerOff();
|
||||
}
|
||||
}
|
||||
@@ -407,8 +528,26 @@ void UITask::handleButtonDoublePress() {
|
||||
|
||||
void UITask::handleButtonTriplePress() {
|
||||
MESH_DEBUG_PRINTLN("UITask: triple press triggered");
|
||||
// Toggle buzzer quiet mode
|
||||
#ifdef PIN_BUZZER
|
||||
#if defined(PIN_BUZZER) && defined(HAS_DRV2605)
|
||||
// cycle alert modes: Buzz+Vibe -> Buzz only -> Vibe only -> Silent
|
||||
int mode = (_node_prefs->buzzer_quiet ? 2 : 0) | (_node_prefs->vibe_quiet ? 1 : 0);
|
||||
mode = (mode + 1) & 3;
|
||||
_node_prefs->buzzer_quiet = (mode & 2) ? 1 : 0;
|
||||
_node_prefs->vibe_quiet = (mode & 1) ? 1 : 0;
|
||||
buzzer.quiet(_node_prefs->buzzer_quiet);
|
||||
vibration.quiet(_node_prefs->vibe_quiet);
|
||||
// audible/tactile confirmation of the new mode (no screen on some boards)
|
||||
if (!_node_prefs->buzzer_quiet) notify(UIEventType::ack);
|
||||
if (!_node_prefs->vibe_quiet) vibration.trigger(true);
|
||||
switch (mode) {
|
||||
case 0: sprintf(_alert, "Alerts: Buzz+Vibe"); break;
|
||||
case 1: sprintf(_alert, "Alerts: Buzz only"); break;
|
||||
case 2: sprintf(_alert, "Alerts: Vibe only"); break;
|
||||
default: sprintf(_alert, "Alerts: Silent"); break;
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
_need_refresh = true;
|
||||
#elif defined(PIN_BUZZER)
|
||||
if (buzzer.isQuiet()) {
|
||||
buzzer.quiet(false);
|
||||
notify(UIEventType::ack);
|
||||
@@ -420,7 +559,7 @@ void UITask::handleButtonTriplePress() {
|
||||
_node_prefs->buzzer_quiet = buzzer.isQuiet();
|
||||
the_mesh.savePrefs();
|
||||
_need_refresh = true;
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::handleButtonQuadruplePress() {
|
||||
|
||||
@@ -14,11 +14,18 @@
|
||||
|
||||
#include "Button.h"
|
||||
|
||||
#ifdef HAS_DRV2605
|
||||
#include <helpers/ui/DRV2605Vibration.h>
|
||||
#endif
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
DisplayDriver* _display;
|
||||
SensorManager* _sensors;
|
||||
#ifdef PIN_BUZZER
|
||||
genericBuzzer buzzer;
|
||||
#endif
|
||||
#ifdef HAS_DRV2605
|
||||
DRV2605Vibration vibration;
|
||||
#endif
|
||||
unsigned long _next_refresh, _auto_off;
|
||||
NodePrefs* _node_prefs;
|
||||
@@ -54,7 +61,7 @@ class UITask : public AbstractUITask {
|
||||
|
||||
public:
|
||||
|
||||
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
UITask(mesh::MainBoard* board, MultiSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
_next_refresh = 0;
|
||||
ui_started_at = 0;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public:
|
||||
if (_status[0] == 0) return;
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
|
||||
// if (_needs_redraw) {
|
||||
// _text_width = display.getTextWidth(_status);
|
||||
|
||||
@@ -56,24 +56,24 @@ public:
|
||||
int render(DisplayDriver& display) override {
|
||||
if (millis() < version_after) {
|
||||
// meshcore logo
|
||||
display.setColor(DisplayDriver::BLUE);
|
||||
display.setColor(UIColor::corp_blue);
|
||||
int logoWidth = 72;
|
||||
display.drawXbm(0, 0, meshcore_logo, 72, 36);
|
||||
} else {
|
||||
|
||||
// meshcore website
|
||||
const char* website = "meshcore.io";
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setTextSize(1);
|
||||
uint16_t websiteWidth = display.getTextWidth(website);
|
||||
display.setCursor((display.width() - websiteWidth) / 2, 9);
|
||||
display.print(website);
|
||||
|
||||
// version info
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width()/2, 18, _version_info);
|
||||
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width()/2, 27, FIRMWARE_BUILD_DATE);
|
||||
}
|
||||
@@ -163,7 +163,7 @@ public:
|
||||
// display.print(filtered_name);
|
||||
|
||||
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "MSG: %d", _task->getMsgCount());
|
||||
display.setCursor(0, 10);
|
||||
@@ -180,19 +180,19 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, 54, tmp);
|
||||
#endif
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::warning_txt);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, display.height()-8, "< Connected >");
|
||||
|
||||
} else if (the_mesh.getBLEPin() != 0) { // BT pin
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setColor(UIColor::warning_txt);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
display.drawTextCentered(display.width() / 2, display.height()-8, tmp);
|
||||
}
|
||||
} else if (_page == HomePage::RECENT) {
|
||||
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
int y = 8;
|
||||
for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) {
|
||||
auto a = &recent[i];
|
||||
@@ -216,7 +216,7 @@ public:
|
||||
display.print(tmp);
|
||||
}
|
||||
} else if (_page == HomePage::RADIO) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setTextSize(1);
|
||||
// frequency and spreading factor
|
||||
display.setCursor(0, 8);
|
||||
@@ -238,14 +238,14 @@ public:
|
||||
display.drawTextRightAlign(display.width(), 26, tmp);
|
||||
|
||||
} else if (_page == HomePage::BLUETOOTH) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::corp_blue);
|
||||
display.drawXbm((display.width() - 32) / 2, 8,
|
||||
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
|
||||
_task->isBluetoothEnabled() ? bluetooth_on : bluetooth_off,
|
||||
32, 32);
|
||||
display.setTextSize(1);
|
||||
// display.drawTextCentered(display.width() / 2, 40 - 11, "toggle: " PRESS_LABEL);
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::corp_blue);
|
||||
display.drawXbm((display.width() - 32) / 2, 8, advert_icon, 32, 32);
|
||||
// display.drawTextCentered(display.width() / 2, 40 - 11, "advert: " PRESS_LABEL);
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
@@ -264,9 +264,11 @@ public:
|
||||
#else
|
||||
strcpy(buf, gps_state ? "gps on" : "gps off");
|
||||
#endif
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.drawTextLeftAlign(0, y, buf);
|
||||
if (nmea == NULL) {
|
||||
// y = y + 8;
|
||||
display.setColor(UIColor::warning_txt);
|
||||
display.drawTextLeftAlign(0, y, "Can't access GPS");
|
||||
} else {
|
||||
if (!gps_state || !nmea->isValid()) {
|
||||
@@ -274,6 +276,7 @@ public:
|
||||
} else {
|
||||
sprintf(buf, "%d sat", nmea->satellitesCount());
|
||||
}
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 8;
|
||||
sprintf(buf, "lat %.4f",
|
||||
@@ -349,8 +352,10 @@ public:
|
||||
r.skipData(type);
|
||||
strcpy(name, "unk"); sprintf(buf, "");
|
||||
}
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(name);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(
|
||||
display.width()-display.getTextWidth(buf)-1, y
|
||||
);
|
||||
@@ -361,7 +366,7 @@ public:
|
||||
else sensors_scroll_offset = 0;
|
||||
#endif
|
||||
} else if (_page == HomePage::SHUTDOWN) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.setTextSize(1);
|
||||
if (_shutdown_init) {
|
||||
display.drawTextCentered(display.width() / 2, 20, "hibernating...");
|
||||
@@ -386,10 +391,10 @@ public:
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
|
||||
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
|
||||
_task->disableSerial();
|
||||
if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off
|
||||
_task->disableBluetooth();
|
||||
} else {
|
||||
_task->enableSerial();
|
||||
_task->enableBluetooth();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -566,8 +571,7 @@ void UITask::shutdown(bool restart){
|
||||
if (restart) {
|
||||
_board->reboot();
|
||||
} else {
|
||||
_display->turnOff();
|
||||
radio_driver.powerOff();
|
||||
// Power off board including radio, display, GPS and components
|
||||
_board->powerOff();
|
||||
}
|
||||
}
|
||||
@@ -673,7 +677,7 @@ void UITask::loop() {
|
||||
_cached_batt_mv,
|
||||
isBuzzerQuiet(),
|
||||
getGPSState(),
|
||||
isSerialEnabled());
|
||||
isBluetoothEnabled());
|
||||
|
||||
bool status_dirty = _statusBar.needsRedraw();
|
||||
bool content_dirty = (millis() >= _next_refresh && curr);
|
||||
@@ -693,9 +697,9 @@ void UITask::loop() {
|
||||
_display->setTextSize(1);
|
||||
int y = _display->height() / 3;
|
||||
int p = _display->height() / 32;
|
||||
_display->setColor(DisplayDriver::DARK);
|
||||
_display->setColor(UIColor::popup_bkg);
|
||||
_display->fillRect(p, y, _display->width() - p*2, y);
|
||||
_display->setColor(DisplayDriver::LIGHT); // draw box border
|
||||
_display->setColor(UIColor::popup_txt); // draw box border
|
||||
_display->drawRect(p, y, _display->width() - p*2, y);
|
||||
_display->drawTextCentered(_display->width() / 2, y + p*3, _alert);
|
||||
_next_refresh = _alert_expiry; // will need refresh when alert is dismissed
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/BaseSerialInterface.h>
|
||||
#include <helpers/MultiSerialInterface.h>
|
||||
#include <Arduino.h>
|
||||
#include <helpers/sensors/LPPDataHelpers.h>
|
||||
|
||||
@@ -71,7 +71,7 @@ class UITask : public AbstractUITask {
|
||||
|
||||
public:
|
||||
|
||||
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
UITask(mesh::MainBoard* board, MultiSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
next_batt_chck = _next_refresh = 0;
|
||||
_cached_batt_mv = 0;
|
||||
ui_started_at = 0;
|
||||
|
||||
@@ -22,6 +22,7 @@ KissModem::KissModem(Stream& serial, mesh::LocalIdentity& identity, mesh::RNG& r
|
||||
_getStatsCallback = nullptr;
|
||||
_config = {0, 0, 0, 0, 0};
|
||||
_signal_report_enabled = true;
|
||||
resetOutputQueue();
|
||||
}
|
||||
|
||||
void KissModem::begin() {
|
||||
@@ -30,37 +31,168 @@ void KissModem::begin() {
|
||||
_rx_active = false;
|
||||
_has_pending_tx = false;
|
||||
_tx_state = TX_IDLE;
|
||||
resetOutputQueue();
|
||||
}
|
||||
|
||||
void KissModem::writeByte(uint8_t b) {
|
||||
if (b == KISS_FEND) {
|
||||
_serial.write(KISS_FESC);
|
||||
_serial.write(KISS_TFEND);
|
||||
} else if (b == KISS_FESC) {
|
||||
_serial.write(KISS_FESC);
|
||||
_serial.write(KISS_TFESC);
|
||||
} else {
|
||||
_serial.write(b);
|
||||
void KissModem::resetOutputQueue() {
|
||||
_tx_frame_head = 0;
|
||||
_tx_frame_tail = 0;
|
||||
_tx_frame_count = 0;
|
||||
_tx_busy_error_pending = false;
|
||||
_tx_done_pending = false;
|
||||
_tx_done_result = 0;
|
||||
}
|
||||
|
||||
void KissModem::popTxFrame() {
|
||||
_tx_frame_head = (uint8_t)((_tx_frame_head + 1) % KISS_TX_FRAME_QUEUE_DEPTH);
|
||||
_tx_frame_count--;
|
||||
}
|
||||
|
||||
uint16_t KissModem::appendEscapedByte(uint8_t* dest, uint16_t idx, uint16_t max_len, uint8_t b) {
|
||||
if (b == KISS_FEND || b == KISS_FESC) {
|
||||
if (idx + 2 > max_len) {
|
||||
return 0;
|
||||
}
|
||||
dest[idx++] = KISS_FESC;
|
||||
dest[idx++] = (b == KISS_FEND) ? KISS_TFEND : KISS_TFESC;
|
||||
return idx;
|
||||
}
|
||||
if (idx + 1 > max_len) {
|
||||
return 0;
|
||||
}
|
||||
dest[idx++] = b;
|
||||
return idx;
|
||||
}
|
||||
|
||||
void KissModem::writeFrame(uint8_t type, const uint8_t* data, uint16_t len) {
|
||||
_serial.write(KISS_FEND);
|
||||
writeByte(type);
|
||||
uint16_t KissModem::encodeFrame(uint8_t type, const uint8_t* data, uint16_t len, uint8_t* dest, uint16_t max_len) {
|
||||
if (max_len < KISS_FRAME_BOUNDARY_BYTES) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint16_t idx = 0;
|
||||
dest[idx++] = KISS_FEND;
|
||||
|
||||
idx = appendEscapedByte(dest, idx, max_len, type);
|
||||
if (idx == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (uint16_t i = 0; i < len; i++) {
|
||||
writeByte(data[i]);
|
||||
idx = appendEscapedByte(dest, idx, max_len, data[i]);
|
||||
if (idx == 0) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
_serial.write(KISS_FEND);
|
||||
|
||||
if (idx + 1 > max_len) {
|
||||
return 0;
|
||||
}
|
||||
dest[idx++] = KISS_FEND;
|
||||
return idx;
|
||||
}
|
||||
|
||||
void KissModem::writeHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len) {
|
||||
_serial.write(KISS_FEND);
|
||||
writeByte(KISS_CMD_SETHARDWARE);
|
||||
writeByte(sub_cmd);
|
||||
for (uint16_t i = 0; i < len; i++) {
|
||||
writeByte(data[i]);
|
||||
bool KissModem::tryFlushFrames() {
|
||||
while (_tx_frame_count > 0) {
|
||||
const uint8_t idx = _tx_frame_head;
|
||||
const uint16_t frame_len = _tx_frame_len[idx];
|
||||
uint16_t written_len = _tx_frame_written[idx];
|
||||
|
||||
if (written_len >= frame_len) {
|
||||
popTxFrame();
|
||||
continue;
|
||||
}
|
||||
|
||||
const int available = _serial.availableForWrite();
|
||||
if (available <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint16_t remaining = frame_len - written_len;
|
||||
const uint16_t chunk_len = (available < (int)remaining) ? (uint16_t)available : remaining;
|
||||
if (chunk_len == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t chunk_written = _serial.write(_tx_frame_buf[idx] + written_len, chunk_len);
|
||||
if (chunk_written == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
written_len += (uint16_t)chunk_written;
|
||||
_tx_frame_written[idx] = written_len;
|
||||
|
||||
if (written_len < frame_len) {
|
||||
return false;
|
||||
}
|
||||
|
||||
popTxFrame();
|
||||
}
|
||||
_serial.write(KISS_FEND);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool KissModem::queueFrame(uint8_t type, const uint8_t* data, uint16_t len, bool mark_busy_error) {
|
||||
if (_tx_frame_count >= KISS_TX_FRAME_QUEUE_DEPTH && !tryFlushFrames()) {
|
||||
if (mark_busy_error) {
|
||||
_tx_busy_error_pending = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const uint8_t idx = _tx_frame_tail;
|
||||
uint16_t frame_len = encodeFrame(type, data, len, _tx_frame_buf[idx], sizeof(_tx_frame_buf[idx]));
|
||||
if (frame_len == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_tx_frame_len[idx] = frame_len;
|
||||
_tx_frame_written[idx] = 0;
|
||||
_tx_frame_tail = (uint8_t)((_tx_frame_tail + 1) % KISS_TX_FRAME_QUEUE_DEPTH);
|
||||
_tx_frame_count++;
|
||||
tryFlushFrames();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool KissModem::queuePendingBusyError() {
|
||||
if (!_tx_busy_error_pending) {
|
||||
return true;
|
||||
}
|
||||
const uint8_t err = HW_ERR_TX_BUSY;
|
||||
if (!queueHardwareFrame(HW_RESP_ERROR, &err, 1, false)) {
|
||||
return false;
|
||||
}
|
||||
_tx_busy_error_pending = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool KissModem::queueHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len, bool mark_busy_error) {
|
||||
if (len > KISS_MAX_FRAME_SIZE) {
|
||||
return false;
|
||||
}
|
||||
_tx_hw_payload[0] = sub_cmd;
|
||||
if (len > 0) {
|
||||
memcpy(_tx_hw_payload + 1, data, len);
|
||||
}
|
||||
return queueFrame(KISS_CMD_SETHARDWARE, _tx_hw_payload, len + 1, mark_busy_error);
|
||||
}
|
||||
|
||||
bool KissModem::queuePendingTxDone() {
|
||||
if (!_tx_done_pending) {
|
||||
return true;
|
||||
}
|
||||
if (!queueHardwareFrame(HW_RESP_TX_DONE, &_tx_done_result, 1, false)) {
|
||||
return false;
|
||||
}
|
||||
_tx_done_pending = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void KissModem::setTxDonePending(uint8_t result) {
|
||||
_tx_done_result = result;
|
||||
_tx_done_pending = true;
|
||||
_tx_state = TX_DONE_PENDING;
|
||||
}
|
||||
|
||||
bool KissModem::writeHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len) {
|
||||
return queueHardwareFrame(sub_cmd, data, len, true);
|
||||
}
|
||||
|
||||
void KissModem::writeHardwareError(uint8_t error_code) {
|
||||
@@ -68,6 +200,8 @@ void KissModem::writeHardwareError(uint8_t error_code) {
|
||||
}
|
||||
|
||||
void KissModem::loop() {
|
||||
tryFlushFrames();
|
||||
|
||||
while (_serial.available()) {
|
||||
uint8_t b = _serial.read();
|
||||
|
||||
@@ -106,6 +240,8 @@ void KissModem::loop() {
|
||||
}
|
||||
|
||||
processTx();
|
||||
tryFlushFrames();
|
||||
queuePendingBusyError();
|
||||
}
|
||||
|
||||
void KissModem::processFrame() {
|
||||
@@ -295,10 +431,7 @@ void KissModem::processTx() {
|
||||
_tx_timer = millis();
|
||||
_tx_state = TX_SENDING;
|
||||
} else {
|
||||
uint8_t result = 0x00;
|
||||
writeHardwareFrame(HW_RESP_TX_DONE, &result, 1);
|
||||
_has_pending_tx = false;
|
||||
_tx_state = TX_IDLE;
|
||||
setTxDonePending(0x00);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -306,14 +439,15 @@ void KissModem::processTx() {
|
||||
case TX_SENDING:
|
||||
if (_radio.isSendComplete()) {
|
||||
_radio.onSendFinished();
|
||||
uint8_t result = 0x01;
|
||||
writeHardwareFrame(HW_RESP_TX_DONE, &result, 1);
|
||||
_has_pending_tx = false;
|
||||
_tx_state = TX_IDLE;
|
||||
setTxDonePending(0x01);
|
||||
} else if (millis() - _tx_timer >= _radio.getEstAirtimeFor(_pending_tx_len) * KISS_TX_TIMEOUT_FACTOR) {
|
||||
_radio.onSendFinished();
|
||||
uint8_t result = 0x00;
|
||||
writeHardwareFrame(HW_RESP_TX_DONE, &result, 1);
|
||||
setTxDonePending(0x00);
|
||||
}
|
||||
break;
|
||||
|
||||
case TX_DONE_PENDING:
|
||||
if (queuePendingTxDone()) {
|
||||
_has_pending_tx = false;
|
||||
_tx_state = TX_IDLE;
|
||||
}
|
||||
@@ -322,8 +456,7 @@ void KissModem::processTx() {
|
||||
}
|
||||
|
||||
void KissModem::onPacketReceived(int8_t snr, int8_t rssi, const uint8_t* packet, uint16_t len) {
|
||||
writeFrame(KISS_CMD_DATA, packet, len);
|
||||
if (_signal_report_enabled) {
|
||||
if (queueFrame(KISS_CMD_DATA, packet, len) && _signal_report_enabled) {
|
||||
uint8_t meta[2] = { (uint8_t)snr, (uint8_t)rssi };
|
||||
writeHardwareFrame(HW_RESP_RX_META, meta, 2);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
|
||||
#define KISS_MAX_FRAME_SIZE 512
|
||||
#define KISS_MAX_PACKET_SIZE 255
|
||||
#define KISS_FRAME_BOUNDARY_BYTES 2
|
||||
#define KISS_TYPE_BYTES 1
|
||||
#define KISS_HW_SUBCMD_BYTES 1
|
||||
#define KISS_MAX_ESCAPABLE_BYTES (KISS_MAX_FRAME_SIZE + KISS_TYPE_BYTES + KISS_HW_SUBCMD_BYTES)
|
||||
#define KISS_MAX_ESCAPED_PAYLOAD_SIZE (2 * KISS_MAX_ESCAPABLE_BYTES)
|
||||
#define KISS_MAX_ENCODED_FRAME_SIZE (KISS_FRAME_BOUNDARY_BYTES + KISS_MAX_ESCAPED_PAYLOAD_SIZE)
|
||||
#define KISS_TX_FRAME_QUEUE_DEPTH 2
|
||||
#define KISS_HW_MAX_PAYLOAD_SIZE (KISS_MAX_FRAME_SIZE + KISS_HW_SUBCMD_BYTES)
|
||||
|
||||
#define KISS_CMD_DATA 0x00
|
||||
#define KISS_CMD_TXDELAY 0x01
|
||||
@@ -94,7 +102,8 @@ enum TxState {
|
||||
TX_WAIT_CLEAR,
|
||||
TX_SLOT_WAIT,
|
||||
TX_DELAY,
|
||||
TX_SENDING
|
||||
TX_SENDING,
|
||||
TX_DONE_PENDING
|
||||
};
|
||||
|
||||
class KissModem {
|
||||
@@ -130,10 +139,28 @@ class KissModem {
|
||||
|
||||
RadioConfig _config;
|
||||
bool _signal_report_enabled;
|
||||
uint8_t _tx_frame_buf[KISS_TX_FRAME_QUEUE_DEPTH][KISS_MAX_ENCODED_FRAME_SIZE];
|
||||
uint16_t _tx_frame_len[KISS_TX_FRAME_QUEUE_DEPTH];
|
||||
uint16_t _tx_frame_written[KISS_TX_FRAME_QUEUE_DEPTH];
|
||||
uint8_t _tx_frame_head;
|
||||
uint8_t _tx_frame_tail;
|
||||
uint8_t _tx_frame_count;
|
||||
bool _tx_busy_error_pending;
|
||||
bool _tx_done_pending;
|
||||
uint8_t _tx_done_result;
|
||||
uint8_t _tx_hw_payload[KISS_HW_MAX_PAYLOAD_SIZE];
|
||||
|
||||
void writeByte(uint8_t b);
|
||||
void writeFrame(uint8_t type, const uint8_t* data, uint16_t len);
|
||||
void writeHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len);
|
||||
static uint16_t appendEscapedByte(uint8_t* dest, uint16_t idx, uint16_t max_len, uint8_t b);
|
||||
static uint16_t encodeFrame(uint8_t type, const uint8_t* data, uint16_t len, uint8_t* dest, uint16_t max_len);
|
||||
void resetOutputQueue();
|
||||
void popTxFrame();
|
||||
bool tryFlushFrames();
|
||||
bool queueFrame(uint8_t type, const uint8_t* data, uint16_t len, bool mark_busy_error = true);
|
||||
bool queuePendingBusyError();
|
||||
bool queueHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len, bool mark_busy_error);
|
||||
bool queuePendingTxDone();
|
||||
void setTxDonePending(uint8_t result);
|
||||
bool writeHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len);
|
||||
void writeHardwareError(uint8_t error_code);
|
||||
void processFrame();
|
||||
void handleHardwareCommand(uint8_t sub_cmd, const uint8_t* data, uint16_t len);
|
||||
@@ -182,4 +209,5 @@ public:
|
||||
bool isTxBusy() const { return _tx_state != TX_IDLE; }
|
||||
/** True only when radio is actually transmitting; use to skip recvRaw in main loop. */
|
||||
bool isActuallyTransmitting() const { return _tx_state == TX_SENDING; }
|
||||
bool isHostOutputBackedUp() const { return _tx_frame_count > 0 || _tx_busy_error_pending || _tx_done_pending; }
|
||||
};
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
#define NOISE_FLOOR_CALIB_INTERVAL_MS 2000
|
||||
#define AGC_RESET_INTERVAL_MS 30000
|
||||
#define USB_TX_TIMEOUT_MS 50
|
||||
#define USB_TX_BUFFER_SIZE 1024
|
||||
|
||||
StdRNG rng;
|
||||
mesh::LocalIdentity identity;
|
||||
@@ -111,6 +113,10 @@ void setup() {
|
||||
uint32_t start = millis();
|
||||
while (!Serial && millis() - start < 3000) delay(10);
|
||||
delay(100);
|
||||
#if defined(ESP32) && ARDUINO_USB_MODE
|
||||
Serial.setTxTimeoutMs(USB_TX_TIMEOUT_MS);
|
||||
Serial.setTxBufferSize(USB_TX_BUFFER_SIZE);
|
||||
#endif
|
||||
modem = new KissModem(Serial, identity, rng, radio_driver, board, sensors);
|
||||
#endif
|
||||
|
||||
@@ -126,7 +132,7 @@ void setup() {
|
||||
void loop() {
|
||||
modem->loop();
|
||||
|
||||
if (!modem->isActuallyTransmitting()) {
|
||||
if (!modem->isActuallyTransmitting() && !modem->isHostOutputBackedUp()) {
|
||||
if (!modem->isTxBusy()) {
|
||||
if ((uint32_t)(millis() - next_agc_reset_ms) >= AGC_RESET_INTERVAL_MS) {
|
||||
radio_driver.resetAGC();
|
||||
|
||||
+595
-159
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@
|
||||
#include <LittleFS.h>
|
||||
#elif defined(ESP32)
|
||||
#include <SPIFFS.h>
|
||||
using File = fs::File;
|
||||
#endif
|
||||
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
@@ -27,6 +28,7 @@
|
||||
#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
|
||||
@@ -77,18 +79,22 @@ struct NeighbourInfo {
|
||||
};
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "6 Jun 2026"
|
||||
#define FIRMWARE_BUILD_DATE "9 Aug 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "v1.16.0"
|
||||
#define FIRMWARE_VERSION "v1.17.0"
|
||||
#endif
|
||||
|
||||
#define FIRMWARE_ROLE "repeater"
|
||||
|
||||
#define PACKET_LOG_FILE "/packet_log"
|
||||
|
||||
class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
|
||||
class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
#ifdef WITH_WEBCONFIG
|
||||
, public WebConfigServer::Callbacks
|
||||
#endif
|
||||
{
|
||||
FILESYSTEM* _fs;
|
||||
uint32_t last_millis;
|
||||
uint64_t uptime_millis;
|
||||
@@ -99,8 +105,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
|
||||
CommonCLI _cli;
|
||||
uint8_t reply_data[MAX_PACKET_PAYLOAD];
|
||||
uint8_t reply_path[MAX_PATH_SIZE];
|
||||
int8_t reply_path_len;
|
||||
uint8_t reply_path_hash_size;
|
||||
uint8_t reply_path_len;
|
||||
TransportKeyStore key_store;
|
||||
RegionMap region_map, temp_map;
|
||||
RegionEntry* load_stack[8];
|
||||
@@ -135,38 +140,67 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
AlertReporter _alerter;
|
||||
#endif
|
||||
#ifdef WITH_WEBCONFIG
|
||||
WebConfigServer* _webconfig = NULL; // heap-allocated while running, freed on stop
|
||||
bool _wc_batch_active = false; // coalesce bridge restarts during a config batch
|
||||
bool _wc_restart_pending = false;
|
||||
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
|
||||
// anon-regions query per neighbor, published to the MQTT neighbors topic once
|
||||
// every neighbor has responded or timed out.
|
||||
enum NeighborDiscoverStatus : uint8_t {
|
||||
ND_PENDING = 1,
|
||||
ND_RESPONDED = 2,
|
||||
ND_TIMEOUT = 3,
|
||||
ND_SEND_FAILED = 4,
|
||||
ND_UNSENT = 0,
|
||||
ND_QUEUED = 1,
|
||||
ND_PENDING = 2,
|
||||
ND_RESPONDED = 3,
|
||||
ND_TIMEOUT = 4,
|
||||
ND_SEND_FAILED = 5,
|
||||
};
|
||||
struct NeighborDiscoverEntry {
|
||||
uint8_t neighbour_idx;
|
||||
uint32_t tag;
|
||||
char scopes[96];
|
||||
uint8_t status;
|
||||
mesh::Identity id; // immutable snapshot: neighbour table can change mid-pass
|
||||
uint32_t heard_timestamp;
|
||||
int8_t snr; // multiplied by 4
|
||||
uint32_t tag; // anon-regions request tag we're waiting on
|
||||
char scopes[96]; // scope names from the response
|
||||
uint8_t status; // NeighborDiscoverStatus
|
||||
};
|
||||
NeighborDiscoverEntry neighbor_discover[MAX_NEIGHBOURS];
|
||||
uint8_t neighbor_discover_count;
|
||||
bool neighbor_discover_active;
|
||||
bool neighbor_table_refresh_active;
|
||||
bool neighbor_table_refresh_periodic;
|
||||
unsigned long neighbor_discover_until;
|
||||
unsigned long next_neighbors_publish;
|
||||
uint8_t neighbor_discover_next; // newest-first entry currently being queried
|
||||
uint8_t neighbor_discover_publish_count; // completed prefix that fits the JSON buffer
|
||||
uint8_t neighbor_discover_queried_count; // requests confirmed transmitted
|
||||
size_t neighbor_discover_json_size;
|
||||
bool neighbor_discover_truncated;
|
||||
bool neighbor_discover_active; // scope-query phase in flight
|
||||
bool neighbor_table_refresh_active; // zero-hop table refresh (stage 1) in flight
|
||||
bool neighbor_table_refresh_periodic; // that refresh was kicked by the periodic timer
|
||||
unsigned long neighbor_discover_until; // current queue or response deadline
|
||||
mesh::Packet* neighbor_discover_request; // request awaiting TX completion
|
||||
unsigned long next_neighbors_publish; // periodic publish deadline (0 = fire ASAP)
|
||||
char self_scopes_buf[96];
|
||||
char self_default_scope_buf[31];
|
||||
char neighbor_discover_origin[32];
|
||||
|
||||
bool sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag);
|
||||
mesh::Packet* sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag);
|
||||
bool cancelNeighborDiscoverRequest();
|
||||
uint32_t neighborDiscoverQueryTimeoutMs() const;
|
||||
bool completeNeighborDiscoverEntry();
|
||||
void resetNeighborDiscoverJsonBudget();
|
||||
bool neighborDiscoverReady(char* reply);
|
||||
bool startNeighborDiscover(char* reply);
|
||||
void loopNeighborDiscover();
|
||||
void finishNeighborDiscover();
|
||||
bool handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len);
|
||||
void touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp);
|
||||
void getLocalScopes(char* buf, size_t len);
|
||||
// Overlay peer indices are offset by this base so onPeerDataRecv can tell a
|
||||
// discovery response apart from a normal ACL-client index.
|
||||
static const int NEIGHBOR_DISCOVER_PEER_BASE = 1000;
|
||||
static const unsigned long NEIGHBOR_DISCOVER_TIMEOUT_MS = 30000;
|
||||
static const unsigned long NEIGHBOR_DISCOVER_QUEUE_TIMEOUT_MS = 29000;
|
||||
static const int NEIGHBOR_DISCOVER_MIN_FREE_PACKETS = 5;
|
||||
#endif
|
||||
|
||||
void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr);
|
||||
@@ -222,7 +256,7 @@ protected:
|
||||
}
|
||||
#endif
|
||||
|
||||
bool filterRecvFloodPacket(mesh::Packet* pkt) override;
|
||||
mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override;
|
||||
|
||||
void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override;
|
||||
int searchPeersByHash(const uint8_t* hash) override;
|
||||
@@ -330,6 +364,12 @@ public:
|
||||
|
||||
void restartBridge() override {
|
||||
if (!bridge || !bridge->isRunning()) return;
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd()
|
||||
_wc_restart_pending = true;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
bridge->end();
|
||||
// Set device metadata before restarting bridge (same as in begin())
|
||||
char device_id[65];
|
||||
@@ -348,18 +388,64 @@ public:
|
||||
void restartBridgeSlot(int slot) override {
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
if (!bridge || !bridge->isRunning()) return;
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (_wc_batch_active && slot >= 0 && slot < 8) {
|
||||
_wc_slot_restart_mask |= (uint8_t)(1u << slot);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
bridge->setSlotPreset(slot, _cli.getObserverPrefs()->mqtt_slot_preset[slot]);
|
||||
#else
|
||||
(void)slot;
|
||||
#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;
|
||||
}
|
||||
|
||||
@@ -373,8 +459,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 {
|
||||
@@ -383,10 +472,34 @@ public:
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef WITH_WEBCONFIG
|
||||
// CommonCLICallbacks: `start webconfig [ap]` / `stop webconfig`
|
||||
bool startWebConfig(bool force_ap, char* reply) override;
|
||||
bool stopWebConfig(char* reply) override;
|
||||
|
||||
// WebConfigServer::Callbacks - all invoked from tick() on the loop task
|
||||
void execCommand(char* cmd, char* reply) override {
|
||||
handleCommand(0, cmd, reply);
|
||||
}
|
||||
void rebootNow() override {
|
||||
_cli.getBoard()->reboot();
|
||||
}
|
||||
void onConfigBatchStart() override {
|
||||
_wc_batch_active = true;
|
||||
_wc_restart_pending = false;
|
||||
_wc_slot_restart_mask = 0;
|
||||
}
|
||||
void onConfigBatchEnd() override;
|
||||
void buildStatsJson(char* buf, size_t buf_size) override;
|
||||
#endif
|
||||
|
||||
// To check if there is pending work
|
||||
bool hasPendingWork() const;
|
||||
|
||||
#if defined(USE_SX1262) || defined(USE_SX1268)
|
||||
void setRxBoostedGain(bool enable) override;
|
||||
#endif
|
||||
bool setRxBoostedGain(bool enable) override;
|
||||
|
||||
#if defined(USE_LR2021)
|
||||
virtual bool configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) override;
|
||||
#endif
|
||||
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "UITask.h"
|
||||
#include "target.h"
|
||||
#include <Arduino.h>
|
||||
#include <helpers/CommonCLI.h>
|
||||
|
||||
@@ -8,11 +9,14 @@
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
#include <WiFi.h>
|
||||
#include <helpers/esp32/WebConfigServer.h> // defines WITH_WEBCONFIG on ESP32
|
||||
#endif
|
||||
|
||||
#define AUTO_OFF_MILLIS 20000 // 20 seconds
|
||||
#define BOOT_SCREEN_MILLIS 4000 // 4 seconds
|
||||
|
||||
#define POWEROFF_DELAY 3000
|
||||
|
||||
// 'meshcore', 128x13px
|
||||
static const uint8_t meshcore_logo [] PROGMEM = {
|
||||
0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe,
|
||||
@@ -33,9 +37,14 @@ static const uint8_t meshcore_logo [] PROGMEM = {
|
||||
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
|
||||
_prevBtnState = HIGH;
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_started_at = millis();
|
||||
_node_prefs = node_prefs;
|
||||
_display->turnOn();
|
||||
|
||||
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS)
|
||||
user_btn.begin();
|
||||
#endif
|
||||
|
||||
// strip off dash and commit hash by changing dash to null terminator
|
||||
// e.g: v1.2.3-abcdef -> v1.2.3
|
||||
char *version = strdup(firmware_version);
|
||||
@@ -45,47 +54,94 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
|
||||
}
|
||||
|
||||
// v1.2.3 (1 Jan 2025)
|
||||
sprintf(_version_info, "%s (%s)", version, build_date);
|
||||
snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date);
|
||||
free(version);
|
||||
}
|
||||
|
||||
void UITask::renderCurrScreen() {
|
||||
char tmp[80];
|
||||
if (millis() < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
if (millis() < _started_at + BOOT_SCREEN_MILLIS) { // boot screen
|
||||
// meshcore logo
|
||||
_display->setColor(DisplayDriver::BLUE);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
int logoWidth = 128;
|
||||
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||||
|
||||
// meshcore website
|
||||
const char* website = "https://meshcore.io";
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setTextSize(1);
|
||||
uint16_t websiteWidth = _display->getTextWidth(website);
|
||||
_display->setCursor((_display->width() - websiteWidth) / 2, 22);
|
||||
_display->print(website);
|
||||
_display->drawTextCentered(_display->width() / 2, 22, website);
|
||||
|
||||
// version info
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(1);
|
||||
uint16_t versionWidth = _display->getTextWidth(_version_info);
|
||||
_display->setCursor((_display->width() - versionWidth) / 2, 35);
|
||||
_display->print(_version_info);
|
||||
_display->drawTextCentered(_display->width() / 2, 35, _version_info);
|
||||
|
||||
// node type
|
||||
const char* node_type = "< Repeater >";
|
||||
uint16_t typeWidth = _display->getTextWidth(node_type);
|
||||
_display->setCursor((_display->width() - typeWidth) / 2, 48);
|
||||
_display->print(node_type);
|
||||
_display->drawTextCentered(_display->width() / 2, 48, node_type);
|
||||
} else if (_powering_off_at > 0) {
|
||||
// meshcore logo
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
int logoWidth = 128;
|
||||
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||||
|
||||
// meshcore website
|
||||
const char* website = "https://meshcore.io";
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setTextSize(1);
|
||||
_display->drawTextCentered(_display->width()/ 2, 22, website);
|
||||
|
||||
// Powering off
|
||||
const char* poweroff_string = "Turning OFF";
|
||||
uint16_t poffWidth = _display->getTextWidth(poweroff_string);
|
||||
_display->setCursor((_display->width() - poffWidth) / 2, 48);
|
||||
_display->drawTextCentered(_display->width()/2, 48, poweroff_string);
|
||||
} else { // home screen
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (WebConfigServer::isRebootPending()) {
|
||||
// save confirmed on-device: show ground truth even if the browser
|
||||
// lost its connection before the confirmation reached it
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
_display->setCursor(0, 14);
|
||||
_display->print("Config saved!");
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 30);
|
||||
_display->print("Rebooting...");
|
||||
return;
|
||||
}
|
||||
char wc_ssid[33], wc_ip[16];
|
||||
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
|
||||
// setup portal active: show join instructions instead of the home screen
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
_display->setCursor(0, 0);
|
||||
_display->print("Observer WiFi Setup");
|
||||
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 14);
|
||||
_display->print("Join WiFi:");
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->setCursor(6, 24);
|
||||
_display->print(wc_ssid);
|
||||
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 40);
|
||||
_display->print("Then browse to:");
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->setCursor(6, 50);
|
||||
_display->print(wc_ip);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// node name
|
||||
_display->setCursor(0, 0);
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->print(_node_prefs->node_name);
|
||||
|
||||
// freq / sf
|
||||
_display->setCursor(0, 20);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
||||
_display->print(tmp);
|
||||
|
||||
@@ -99,7 +155,7 @@ void UITask::renderCurrScreen() {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
IPAddress ip = WiFi.localIP();
|
||||
_display->setCursor(0, 40);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
_display->print(tmp);
|
||||
}
|
||||
@@ -108,21 +164,28 @@ void UITask::renderCurrScreen() {
|
||||
}
|
||||
|
||||
void UITask::loop() {
|
||||
#ifdef PIN_USER_BTN
|
||||
if (millis() >= _next_read) {
|
||||
int btnState = digitalRead(PIN_USER_BTN);
|
||||
if (btnState != _prevBtnState) {
|
||||
if (btnState == USER_BTN_PRESSED) { // pressed?
|
||||
if (_display->isOn()) {
|
||||
// TODO: any action ?
|
||||
} else {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
}
|
||||
_prevBtnState = btnState;
|
||||
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS)
|
||||
int ev = user_btn.check();
|
||||
if (ev == BUTTON_EVENT_CLICK) {
|
||||
if (_display->isOn()) {
|
||||
// TODO: any action ?
|
||||
} else {
|
||||
_display->turnOn();
|
||||
}
|
||||
_next_read = millis() + 200; // 5 reads per second
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||
_display->turnOn();
|
||||
Serial.println("Powering Off");
|
||||
_powering_off_at = millis() + POWEROFF_DELAY;
|
||||
}
|
||||
#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
|
||||
|
||||
@@ -138,4 +201,13 @@ void UITask::loop() {
|
||||
_display->turnOff();
|
||||
}
|
||||
}
|
||||
|
||||
if (_powering_off_at > 0) { // power off timer armed
|
||||
#ifdef LED_PIN
|
||||
digitalWrite(LED_PIN, LED_STATE_ON); // switch on the led until poweroff
|
||||
#endif
|
||||
if (millis() > _powering_off_at) {
|
||||
_board->powerOff(); // should not return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,18 @@
|
||||
#include <helpers/CommonCLI.h>
|
||||
|
||||
class UITask {
|
||||
mesh::MainBoard* _board;
|
||||
DisplayDriver* _display;
|
||||
unsigned long _next_read, _next_refresh, _auto_off;
|
||||
int _prevBtnState;
|
||||
NodePrefs* _node_prefs;
|
||||
char _version_info[32];
|
||||
unsigned long _powering_off_at = 0;
|
||||
unsigned long _started_at = 0;
|
||||
|
||||
void renderCurrScreen();
|
||||
public:
|
||||
UITask(DisplayDriver& display) : _display(&display) { _next_read = _next_refresh = 0; }
|
||||
UITask(mesh::MainBoard& board, DisplayDriver& display) : _board(&board), _display(&display) { _next_read = _next_refresh = 0; }
|
||||
void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version);
|
||||
|
||||
void loop();
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include "UITask.h"
|
||||
static UITask ui_task(display);
|
||||
static UITask ui_task(board, display);
|
||||
#endif
|
||||
|
||||
#ifdef ETHERNET_ENABLED
|
||||
#define ETHERNET_CLI_BANNER "MeshCore Repeater CLI"
|
||||
#include <helpers/nrf52/EthernetCLI.h>
|
||||
#endif
|
||||
|
||||
StdRNG fast_rng;
|
||||
@@ -18,6 +23,9 @@ void halt() {
|
||||
}
|
||||
|
||||
static char command[160];
|
||||
#ifdef ETHERNET_ENABLED
|
||||
static char ethernet_command[160];
|
||||
#endif
|
||||
|
||||
// For power saving
|
||||
unsigned long POWERSAVING_FIRSTSLEEP_SECS = 120; // The first sleep (if enabled) from boot
|
||||
@@ -33,6 +41,10 @@ void setup() {
|
||||
|
||||
board.begin();
|
||||
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.begin();
|
||||
#endif
|
||||
|
||||
#if defined(MESH_DEBUG) && defined(NRF52_PLATFORM)
|
||||
// give some extra time for serial to settle so
|
||||
// boot debug messages can be seen on terminal
|
||||
@@ -91,6 +103,9 @@ void setup() {
|
||||
mesh::Utils::printHex(Serial, the_mesh.self_id.pub_key, PUB_KEY_SIZE); Serial.println();
|
||||
|
||||
command[0] = 0;
|
||||
#ifdef ETHERNET_ENABLED
|
||||
ethernet_command[0] = 0;
|
||||
#endif
|
||||
|
||||
sensors.begin();
|
||||
|
||||
@@ -100,6 +115,10 @@ void setup() {
|
||||
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
|
||||
#endif
|
||||
|
||||
#ifdef ETHERNET_ENABLED
|
||||
ethernet_start_task();
|
||||
#endif
|
||||
|
||||
// send out initial zero hop Advertisement to the mesh
|
||||
#if ENABLE_ADVERT_ON_BOOT == 1
|
||||
the_mesh.sendSelfAdvertisement(16000, false);
|
||||
@@ -109,6 +128,7 @@ void setup() {
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Handle Serial CLI
|
||||
int len = strlen(command);
|
||||
while (Serial.available() && len < sizeof(command)-1) {
|
||||
char c = Serial.read();
|
||||
@@ -127,7 +147,14 @@ void loop() {
|
||||
Serial.print('\n');
|
||||
command[len - 1] = 0; // replace newline with C string null terminator
|
||||
char reply[160];
|
||||
reply[0] = 0;
|
||||
#ifdef ETHERNET_ENABLED
|
||||
if (!ethernet_handle_command(command, reply)) {
|
||||
the_mesh.handleCommand(0, command, reply);
|
||||
}
|
||||
#else
|
||||
the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial!
|
||||
#endif
|
||||
if (reply[0]) {
|
||||
Serial.print(" -> "); Serial.println(reply);
|
||||
}
|
||||
@@ -135,7 +162,20 @@ void loop() {
|
||||
command[0] = 0; // reset command buffer
|
||||
}
|
||||
|
||||
#if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_)
|
||||
#ifdef ETHERNET_ENABLED
|
||||
ethernet_loop_maintain();
|
||||
if (ethernet_read_line(ethernet_command, sizeof(ethernet_command))) {
|
||||
char reply[160];
|
||||
reply[0] = 0;
|
||||
if (!ethernet_handle_command(ethernet_command, reply)) {
|
||||
the_mesh.handleCommand(0, ethernet_command, reply);
|
||||
}
|
||||
ethernet_send_reply(reply);
|
||||
ethernet_command[0] = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) && !defined(DISPLAY_CLASS)
|
||||
// Hold the user button to power off the SenseCAP Solar repeater.
|
||||
int btnState = digitalRead(PIN_USER_BTN);
|
||||
if (btnState == LOW) {
|
||||
@@ -157,6 +197,9 @@ void loop() {
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.loop();
|
||||
#endif
|
||||
if (the_mesh.getNodePrefs()->powersaving_enabled && !the_mesh.hasPendingWork()) {
|
||||
#if defined(NRF52_PLATFORM)
|
||||
board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -28,6 +28,7 @@
|
||||
#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
|
||||
@@ -37,11 +38,11 @@
|
||||
/* ------------------------------ Config -------------------------------- */
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "6 Jun 2026"
|
||||
#define FIRMWARE_BUILD_DATE "9 Aug 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "v1.16.0"
|
||||
#define FIRMWARE_VERSION "v1.17.0"
|
||||
#endif
|
||||
|
||||
#ifndef LORA_FREQ
|
||||
@@ -105,7 +106,11 @@ struct NeighbourInfo {
|
||||
int8_t snr; // multiplied by 4, user should divide to get float value
|
||||
};
|
||||
|
||||
class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
|
||||
class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
#ifdef WITH_WEBCONFIG
|
||||
, public WebConfigServer::Callbacks
|
||||
#endif
|
||||
{
|
||||
FILESYSTEM* _fs;
|
||||
uint32_t last_millis;
|
||||
uint64_t uptime_millis;
|
||||
@@ -140,37 +145,55 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
|
||||
uint32_t pending_discover_tag;
|
||||
unsigned long pending_discover_until;
|
||||
enum NeighborDiscoverStatus : uint8_t {
|
||||
ND_PENDING = 1,
|
||||
ND_RESPONDED = 2,
|
||||
ND_TIMEOUT = 3,
|
||||
ND_SEND_FAILED = 4,
|
||||
ND_UNSENT = 0,
|
||||
ND_QUEUED = 1,
|
||||
ND_PENDING = 2,
|
||||
ND_RESPONDED = 3,
|
||||
ND_TIMEOUT = 4,
|
||||
ND_SEND_FAILED = 5,
|
||||
};
|
||||
struct NeighborDiscoverEntry {
|
||||
uint8_t neighbour_idx;
|
||||
mesh::Identity id;
|
||||
uint32_t heard_timestamp;
|
||||
int8_t snr;
|
||||
uint32_t tag;
|
||||
char scopes[96];
|
||||
uint8_t status;
|
||||
};
|
||||
NeighborDiscoverEntry neighbor_discover[MAX_NEIGHBOURS];
|
||||
uint8_t neighbor_discover_count;
|
||||
uint8_t neighbor_discover_next;
|
||||
uint8_t neighbor_discover_publish_count;
|
||||
uint8_t neighbor_discover_queried_count; // requests confirmed transmitted
|
||||
size_t neighbor_discover_json_size;
|
||||
bool neighbor_discover_truncated;
|
||||
bool neighbor_discover_active;
|
||||
bool neighbor_table_refresh_active;
|
||||
bool neighbor_table_refresh_periodic;
|
||||
unsigned long neighbor_discover_until;
|
||||
mesh::Packet* neighbor_discover_request;
|
||||
unsigned long next_neighbors_publish;
|
||||
char self_scopes_buf[96];
|
||||
char self_default_scope_buf[31];
|
||||
char neighbor_discover_origin[32];
|
||||
|
||||
void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr);
|
||||
void sendNodeDiscoverReq();
|
||||
bool sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag);
|
||||
mesh::Packet* sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag);
|
||||
bool cancelNeighborDiscoverRequest();
|
||||
uint32_t neighborDiscoverQueryTimeoutMs() const;
|
||||
bool completeNeighborDiscoverEntry();
|
||||
void resetNeighborDiscoverJsonBudget();
|
||||
bool neighborDiscoverReady(char* reply);
|
||||
bool startNeighborDiscover(char* reply);
|
||||
void loopNeighborDiscover();
|
||||
void finishNeighborDiscover();
|
||||
bool handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len);
|
||||
void touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp);
|
||||
void getLocalScopes(char* buf, size_t len);
|
||||
static const int NEIGHBOR_DISCOVER_PEER_BASE = 1000;
|
||||
static const unsigned long NEIGHBOR_DISCOVER_TIMEOUT_MS = 30000;
|
||||
static const unsigned long NEIGHBOR_DISCOVER_QUEUE_TIMEOUT_MS = 29000;
|
||||
static const int NEIGHBOR_DISCOVER_MIN_FREE_PACKETS = 5;
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
MQTTBridge* bridge;
|
||||
@@ -181,8 +204,15 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
AlertReporter _alerter;
|
||||
#endif
|
||||
#ifdef WITH_WEBCONFIG
|
||||
WebConfigServer* _webconfig = NULL; // heap-allocated while running, freed on stop
|
||||
bool _wc_batch_active = false; // coalesce bridge restarts during a config batch
|
||||
bool _wc_restart_pending = false;
|
||||
uint8_t _wc_slot_restart_mask = 0;
|
||||
#endif
|
||||
|
||||
void addPost(ClientInfo* client, const char* postData);
|
||||
void storePost(const mesh::Identity& author, const char* postData);
|
||||
void pushPostToClient(ClientInfo* client, PostInfo& post);
|
||||
uint8_t getUnsyncedCount(ClientInfo* client);
|
||||
bool processAck(const uint8_t *data);
|
||||
@@ -219,7 +249,7 @@ protected:
|
||||
return _prefs.multi_acks;
|
||||
}
|
||||
|
||||
bool filterRecvFloodPacket(mesh::Packet* pkt) override;
|
||||
mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override;
|
||||
|
||||
bool allowPacketForward(const mesh::Packet* packet) override;
|
||||
void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override;
|
||||
@@ -245,6 +275,7 @@ public:
|
||||
MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
|
||||
|
||||
void begin(FILESYSTEM* fs);
|
||||
void addSystemPost(const char* postData);
|
||||
|
||||
const char* getFirmwareVer() override { return FIRMWARE_VERSION; }
|
||||
const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; }
|
||||
@@ -281,6 +312,7 @@ public:
|
||||
|
||||
void dumpLogFile() override;
|
||||
void setTxPower(int8_t power_dbm) override;
|
||||
bool setRxBoostedGain(bool enable) override;
|
||||
|
||||
void formatNeighborsReply(char *reply) override;
|
||||
void removeNeighbor(const uint8_t* pubkey, int key_len) override;
|
||||
@@ -345,6 +377,12 @@ public:
|
||||
|
||||
void restartBridge() override {
|
||||
if (!bridge || !bridge->isRunning()) return;
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd()
|
||||
_wc_restart_pending = true;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
bridge->end();
|
||||
char device_id[65];
|
||||
mesh::LocalIdentity self_id = getSelfId();
|
||||
@@ -362,12 +400,32 @@ public:
|
||||
void restartBridgeSlot(int slot) override {
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
if (!bridge || !bridge->isRunning()) return;
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (_wc_batch_active && slot >= 0 && slot < 8) {
|
||||
_wc_slot_restart_mask |= (uint8_t)(1u << slot);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
bridge->setSlotPreset(slot, _cli.getObserverPrefs()->mqtt_slot_preset[slot]);
|
||||
#else
|
||||
(void)slot;
|
||||
#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.
|
||||
@@ -387,8 +445,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 {
|
||||
@@ -396,4 +457,25 @@ public:
|
||||
return bridge->ntpDiag(reply, reply_size, verbose);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef WITH_WEBCONFIG
|
||||
// CommonCLICallbacks: `start webconfig [ap]` / `stop webconfig`
|
||||
bool startWebConfig(bool force_ap, char* reply) override;
|
||||
bool stopWebConfig(char* reply) override;
|
||||
|
||||
// WebConfigServer::Callbacks - all invoked from tick() on the loop task
|
||||
void execCommand(char* cmd, char* reply) override {
|
||||
handleCommand(0, cmd, reply);
|
||||
}
|
||||
void rebootNow() override {
|
||||
_cli.getBoard()->reboot();
|
||||
}
|
||||
void onConfigBatchStart() override {
|
||||
_wc_batch_active = true;
|
||||
_wc_restart_pending = false;
|
||||
_wc_slot_restart_mask = 0;
|
||||
}
|
||||
void onConfigBatchEnd() override;
|
||||
void buildStatsJson(char* buf, size_t buf_size) override;
|
||||
#endif
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
#include <WiFi.h>
|
||||
#include <helpers/esp32/WebConfigServer.h> // defines WITH_WEBCONFIG on ESP32
|
||||
#endif
|
||||
|
||||
#define AUTO_OFF_MILLIS 20000 // 20 seconds
|
||||
@@ -45,27 +46,27 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
|
||||
}
|
||||
|
||||
// v1.2.3 (1 Jan 2025)
|
||||
sprintf(_version_info, "%s (%s)", version, build_date);
|
||||
snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date);
|
||||
free(version);
|
||||
}
|
||||
|
||||
void UITask::renderCurrScreen() {
|
||||
char tmp[80];
|
||||
if (millis() < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
// meshcore logo
|
||||
_display->setColor(DisplayDriver::BLUE);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
int logoWidth = 128;
|
||||
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||||
|
||||
// meshcore website
|
||||
const char* website = "https://meshcore.io";
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setTextSize(1);
|
||||
uint16_t websiteWidth = _display->getTextWidth(website);
|
||||
_display->setCursor((_display->width() - websiteWidth) / 2, 22);
|
||||
_display->print(website);
|
||||
|
||||
// version info
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(1);
|
||||
uint16_t versionWidth = _display->getTextWidth(_version_info);
|
||||
_display->setCursor((_display->width() - versionWidth) / 2, 35);
|
||||
@@ -77,15 +78,51 @@ void UITask::renderCurrScreen() {
|
||||
_display->setCursor((_display->width() - typeWidth) / 2, 48);
|
||||
_display->print(node_type);
|
||||
} else { // home screen
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (WebConfigServer::isRebootPending()) {
|
||||
// save confirmed on-device: show ground truth even if the browser
|
||||
// lost its connection before the confirmation reached it
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
_display->setCursor(0, 14);
|
||||
_display->print("Config saved!");
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 30);
|
||||
_display->print("Rebooting...");
|
||||
return;
|
||||
}
|
||||
char wc_ssid[33], wc_ip[16];
|
||||
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
|
||||
// setup portal active: show join instructions instead of the home screen
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
_display->setCursor(0, 0);
|
||||
_display->print("Observer WiFi Setup");
|
||||
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 14);
|
||||
_display->print("Join WiFi:");
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->setCursor(6, 24);
|
||||
_display->print(wc_ssid);
|
||||
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 40);
|
||||
_display->print("Then browse to:");
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->setCursor(6, 50);
|
||||
_display->print(wc_ip);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// node name
|
||||
_display->setCursor(0, 0);
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->print(_node_prefs->node_name);
|
||||
|
||||
// freq / sf
|
||||
_display->setCursor(0, 20);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
||||
_display->print(tmp);
|
||||
|
||||
@@ -99,7 +136,7 @@ void UITask::renderCurrScreen() {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
IPAddress ip = WiFi.localIP();
|
||||
_display->setCursor(0, 40);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
_display->print(tmp);
|
||||
}
|
||||
@@ -126,6 +163,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();
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
|
||||
#include "MyMesh.h"
|
||||
|
||||
#ifdef ETHERNET_ENABLED
|
||||
#define ETHERNET_CLI_BANNER "MeshCore Room Server CLI"
|
||||
#include <helpers/nrf52/EthernetCLI.h>
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include "UITask.h"
|
||||
static UITask ui_task(display);
|
||||
@@ -17,6 +22,9 @@ void halt() {
|
||||
}
|
||||
|
||||
static char command[MAX_POST_TEXT_LEN+1];
|
||||
#ifdef ETHERNET_ENABLED
|
||||
static char ethernet_command[MAX_POST_TEXT_LEN+1];
|
||||
#endif
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
@@ -24,6 +32,10 @@ void setup() {
|
||||
|
||||
board.begin();
|
||||
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.begin();
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (display.begin()) {
|
||||
display.startFrame();
|
||||
@@ -67,6 +79,9 @@ void setup() {
|
||||
mesh::Utils::printHex(Serial, the_mesh.self_id.pub_key, PUB_KEY_SIZE); Serial.println();
|
||||
|
||||
command[0] = 0;
|
||||
#ifdef ETHERNET_ENABLED
|
||||
ethernet_command[0] = 0;
|
||||
#endif
|
||||
|
||||
sensors.begin();
|
||||
|
||||
@@ -76,6 +91,10 @@ void setup() {
|
||||
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
|
||||
#endif
|
||||
|
||||
#ifdef ETHERNET_ENABLED
|
||||
ethernet_start_task();
|
||||
#endif
|
||||
|
||||
// send out initial zero hop Advertisement to the mesh
|
||||
#if ENABLE_ADVERT_ON_BOOT == 1
|
||||
the_mesh.sendSelfAdvertisement(16000, false);
|
||||
@@ -101,7 +120,14 @@ void loop() {
|
||||
if (len > 0 && command[len - 1] == '\r') { // received complete line
|
||||
command[len - 1] = 0; // replace newline with C string null terminator
|
||||
char reply[160];
|
||||
reply[0] = 0;
|
||||
#ifdef ETHERNET_ENABLED
|
||||
if (!ethernet_handle_command(command, reply)) {
|
||||
the_mesh.handleCommand(0, command, reply);
|
||||
}
|
||||
#else
|
||||
the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial!
|
||||
#endif
|
||||
if (reply[0]) {
|
||||
Serial.print(" -> "); Serial.println(reply);
|
||||
}
|
||||
@@ -109,10 +135,26 @@ void loop() {
|
||||
command[0] = 0; // reset command buffer
|
||||
}
|
||||
|
||||
#ifdef ETHERNET_ENABLED
|
||||
ethernet_loop_maintain();
|
||||
if (ethernet_read_line(ethernet_command, sizeof(ethernet_command))) {
|
||||
char reply[160];
|
||||
reply[0] = 0;
|
||||
if (!ethernet_handle_command(ethernet_command, reply)) {
|
||||
the_mesh.handleCommand(0, ethernet_command, reply);
|
||||
}
|
||||
ethernet_send_reply(reply);
|
||||
ethernet_command[0] = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
the_mesh.loop();
|
||||
sensors.loop();
|
||||
#ifdef DISPLAY_CLASS
|
||||
ui_task.loop();
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.loop();
|
||||
#endif
|
||||
}
|
||||
@@ -135,7 +135,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor {
|
||||
File file = _fs->open("/contacts", "w", true);
|
||||
#endif
|
||||
if (file) {
|
||||
ContactsIterator iter;
|
||||
ContactsIterator iter = startContactsIterator();
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
uint32_t reserved = 0;
|
||||
@@ -560,6 +560,10 @@ void setup() {
|
||||
|
||||
board.begin();
|
||||
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.begin();
|
||||
#endif
|
||||
|
||||
if (!radio_init()) { halt(); }
|
||||
|
||||
fast_rng.begin(radio_driver.getRngSeed());
|
||||
@@ -591,4 +595,7 @@ void setup() {
|
||||
void loop() {
|
||||
the_mesh.loop();
|
||||
rtc_clock.tick();
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.loop();
|
||||
#endif
|
||||
}
|
||||
@@ -711,7 +711,6 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise
|
||||
set_radio_at = revert_radio_at = 0;
|
||||
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
_prefs.airtime_factor = 1.0; // one half
|
||||
_prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0;
|
||||
_prefs.tx_delay_factor = 0.5f; // was 0.25f
|
||||
@@ -737,6 +736,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise
|
||||
_prefs.gps_enabled = 0;
|
||||
_prefs.gps_interval = 0;
|
||||
_prefs.advert_loc_policy = ADVERT_LOC_PREFS;
|
||||
_prefs.radio_fem_rxgain = 1;
|
||||
|
||||
memset(default_scope.key, 0, sizeof(default_scope.key));
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@
|
||||
#define PERM_RECV_ALERTS_HI (1 << 7) // high priority alerts
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "6 Jun 2026"
|
||||
#define FIRMWARE_BUILD_DATE "9 Aug 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "v1.16.0"
|
||||
#define FIRMWARE_VERSION "v1.17.0"
|
||||
#endif
|
||||
|
||||
#define FIRMWARE_ROLE "sensor"
|
||||
|
||||
@@ -41,27 +41,27 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
|
||||
}
|
||||
|
||||
// v1.2.3 (1 Jan 2025)
|
||||
sprintf(_version_info, "%s (%s)", version, build_date);
|
||||
snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date);
|
||||
free(version);
|
||||
}
|
||||
|
||||
void UITask::renderCurrScreen() {
|
||||
char tmp[80];
|
||||
if (millis() < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
// meshcore logo
|
||||
_display->setColor(DisplayDriver::BLUE);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
int logoWidth = 128;
|
||||
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||||
|
||||
// meshcore website
|
||||
const char* website = "https://meshcore.io";
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setTextSize(1);
|
||||
uint16_t websiteWidth = _display->getTextWidth(website);
|
||||
_display->setCursor((_display->width() - websiteWidth) / 2, 22);
|
||||
_display->print(website);
|
||||
|
||||
// version info
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(1);
|
||||
uint16_t versionWidth = _display->getTextWidth(_version_info);
|
||||
_display->setCursor((_display->width() - versionWidth) / 2, 35);
|
||||
@@ -76,12 +76,11 @@ void UITask::renderCurrScreen() {
|
||||
// node name
|
||||
_display->setCursor(0, 0);
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->print(_node_prefs->node_name);
|
||||
|
||||
// freq / sf
|
||||
_display->setCursor(0, 20);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
||||
_display->print(tmp);
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ void setup() {
|
||||
|
||||
board.begin();
|
||||
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.begin();
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (display.begin()) {
|
||||
display.startFrame();
|
||||
@@ -147,4 +151,7 @@ void loop() {
|
||||
ui_task.loop();
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.loop();
|
||||
#endif
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
+38
-4
@@ -19,7 +19,8 @@ monitor_speed = 115200
|
||||
lib_deps =
|
||||
SPI
|
||||
Wire
|
||||
jgromes/RadioLib @ ^7.6.0
|
||||
;jgromes/RadioLib @ ^7.7.1
|
||||
https://github.com/jgromes/RadioLib.git#6d8934836678d8894e3d556550475b37dce3e2b6
|
||||
rweather/Crypto @ ^0.4.0
|
||||
adafruit/RTClib @ ^2.1.3
|
||||
melopero/Melopero RV3028 @ ^1.1.0
|
||||
@@ -48,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>
|
||||
@@ -58,7 +62,9 @@ build_src_filter =
|
||||
extends = arduino_base
|
||||
platform = platformio/espressif32@6.11.0
|
||||
monitor_filters = esp32_exception_decoder
|
||||
extra_scripts = merge-bin.py
|
||||
extra_scripts =
|
||||
pre:scripts/generate_webconfig_html.py
|
||||
merge-bin.py
|
||||
build_flags = ${arduino_base.build_flags}
|
||||
-D ESP32_PLATFORM
|
||||
; Route esp_transport_ws_init through src/helpers/ESP32WsTransportFix.cpp to
|
||||
@@ -66,6 +72,7 @@ build_flags = ${arduino_base.build_flags}
|
||||
-Wl,--wrap=esp_transport_ws_init
|
||||
; -D ESP32_CPU_FREQ=80 ; change it to your need
|
||||
build_src_filter = ${arduino_base.build_src_filter}
|
||||
+<helpers/esp32/WebConfigServer.cpp> ; empty TU unless WITH_MQTT_BRIDGE
|
||||
|
||||
[esp32_ota]
|
||||
lib_deps =
|
||||
@@ -93,16 +100,19 @@ build_flags = ${arduino_base.build_flags}
|
||||
-D NRF52_PLATFORM
|
||||
-D LFS_NO_ASSERT=1
|
||||
-D EXTRAFS=1
|
||||
-D USE_CC310_HW_CRYPTO=1
|
||||
lib_deps =
|
||||
${arduino_base.lib_deps}
|
||||
https://github.com/oltaco/CustomLFS#0.2.2
|
||||
https://github.com/oltaco/CustomLFS#0.2.3
|
||||
; Vendored ESP-IDF MQTT client; the LDF otherwise pulls it into non-ESP builds.
|
||||
lib_ignore = PsychicMqttClient
|
||||
; ----------------- RP2040 ---------------------
|
||||
|
||||
[rp2040_base]
|
||||
extends = arduino_base
|
||||
upload_protocol = picotool
|
||||
board_build.core = earlephilhower
|
||||
platform = https://github.com/maxgerhardt/platform-raspberrypi.git#4e22a0d ; framework-arduinopico @ 1.50600.0+sha.6a1d13e9
|
||||
platform = https://github.com/maxgerhardt/platform-raspberrypi.git ; framework-arduinopico @ 1.50600.0+sha.6a1d13e9
|
||||
build_flags = ${arduino_base.build_flags}
|
||||
-D RP2040_PLATFORM
|
||||
|
||||
@@ -157,15 +167,39 @@ lib_deps =
|
||||
adafruit/Adafruit BMP085 Library @ ^1.2.4
|
||||
|
||||
; ----------------- 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
|
||||
test_framework = googletest
|
||||
build_flags = -std=c++17
|
||||
-I src
|
||||
-I test/mocks
|
||||
test_build_src = yes
|
||||
test_ignore = test_kiss_modem
|
||||
build_src_filter =
|
||||
-<*>
|
||||
+<../src/Utils.cpp>
|
||||
+<../src/helpers/MQTTPayloadBuilder.cpp>
|
||||
+<../src/Packet.cpp>
|
||||
+<../src/helpers/ConfigSerializer.cpp>
|
||||
lib_deps =
|
||||
google/googletest @ 1.17.0
|
||||
bblanchon/ArduinoJson @ 7.4.3
|
||||
|
||||
[env:native_kiss_modem]
|
||||
platform = native
|
||||
test_framework = googletest
|
||||
build_flags = -std=c++17
|
||||
-I test/mocks
|
||||
-I src
|
||||
-I examples/kiss_modem
|
||||
test_build_src = yes
|
||||
test_filter = test_kiss_modem
|
||||
build_src_filter =
|
||||
-<*>
|
||||
+<../examples/kiss_modem/KissModem.cpp>
|
||||
lib_deps =
|
||||
google/googletest @ 1.17.0
|
||||
bblanchon/ArduinoJson @ 7.4.3
|
||||
@@ -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())
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Pre-build script: gzip webui/index.html into a PROGMEM C header so the
|
||||
# webconfig portal can serve the page straight from flash with
|
||||
# Content-Encoding: gzip. The generated header is .gitignored; this script
|
||||
# regenerates it whenever the source page (or this script) content changes.
|
||||
#
|
||||
# Freshness is decided by a content hash of the build inputs (not timestamps):
|
||||
# a generated header written with a skewed/future mtime would otherwise mask a
|
||||
# later source edit and embed stale UI. gzip output is deterministic (mtime=0),
|
||||
# so an unchanged source produces an unchanged header.
|
||||
#
|
||||
# The generator is idempotent and cheap: when the inputs are unchanged it only
|
||||
# hashes two small files and returns, so running it from esp32_base on every
|
||||
# ESP32 build is negligible even for targets that don't compile the portal.
|
||||
#
|
||||
# Comments and indentation are stripped before compressing (see strip_source).
|
||||
# The source page is heavily commented by house style and none of it is worth
|
||||
# flash, so the page ships smaller than it reads.
|
||||
#
|
||||
# Output: src/helpers/esp32/WebConfigHtml.h
|
||||
# WEBCONFIG_HTML_GZ[] - gzipped page (PROGMEM)
|
||||
# WEBCONFIG_HTML_GZ_LEN - byte length
|
||||
# WEBCONFIG_HTML_ETAG - quoted strong ETag (sha256 prefix of the gz body)
|
||||
#
|
||||
# Runnable outside SCons to inspect exactly what gets shipped:
|
||||
# python3 scripts/generate_webconfig_html.py --emit /tmp/shipped.html
|
||||
# or served directly by the mock backend with its --minify flag.
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
Import("env") # noqa: F821
|
||||
except NameError:
|
||||
pass # running standalone (--emit), not as a PIO extra_script
|
||||
|
||||
SOURCE = os.path.join("webui", "index.html")
|
||||
OUTPUT = os.path.join("src", "helpers", "esp32", "WebConfigHtml.h")
|
||||
# __file__ is not defined inside PIO/SCons-executed extra_scripts
|
||||
SCRIPT = os.path.join("scripts", "generate_webconfig_html.py")
|
||||
MINIFIER = os.path.join("scripts", "webconfig_minify.py")
|
||||
HASH_MARKER = "// build-inputs-sha256: "
|
||||
|
||||
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
|
||||
from webconfig_minify import check_stripped, strip_source # noqa: E402
|
||||
|
||||
|
||||
def status(msg):
|
||||
sys.stderr.write("WebConfig HTML: %s\n" % msg)
|
||||
|
||||
|
||||
def content_hash():
|
||||
# Hash the source page, this generator and the minifier so any change to
|
||||
# any of them forces a regenerate, independent of file timestamps.
|
||||
h = hashlib.sha256()
|
||||
with open(SOURCE, "rb") as f:
|
||||
h.update(f.read())
|
||||
for path in (SCRIPT, MINIFIER):
|
||||
if os.path.isfile(path):
|
||||
h.update(b"\0")
|
||||
with open(path, "rb") as f:
|
||||
h.update(f.read())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def stored_hash():
|
||||
try:
|
||||
with open(OUTPUT, "r") as f:
|
||||
for line in f:
|
||||
if line.startswith(HASH_MARKER):
|
||||
return line[len(HASH_MARKER):].strip()
|
||||
if line.startswith("#"): # reached the C preprocessor lines
|
||||
break
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def shipped_page():
|
||||
"""The exact bytes the device serves: the source page, stripped."""
|
||||
with open(SOURCE, "r", encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
stripped = strip_source(raw)
|
||||
problem = check_stripped(raw, stripped)
|
||||
if problem:
|
||||
status("ERROR: comment stripping corrupted the page (%s)" % problem)
|
||||
sys.exit(2)
|
||||
return raw, stripped
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isfile(SOURCE):
|
||||
status("ERROR: %s not found" % SOURCE)
|
||||
sys.exit(2)
|
||||
|
||||
if "--emit" in sys.argv:
|
||||
dest = sys.argv[sys.argv.index("--emit") + 1]
|
||||
src, stripped = shipped_page()
|
||||
with open(dest, "w", encoding="utf-8") as f:
|
||||
f.write(stripped)
|
||||
status("%s -> %s (%d -> %d bytes)" % (SOURCE, dest, len(src), len(stripped)))
|
||||
return
|
||||
|
||||
src_hash = content_hash()
|
||||
if os.path.isfile(OUTPUT) and stored_hash() == src_hash:
|
||||
return
|
||||
|
||||
src, stripped = shipped_page()
|
||||
raw = stripped.encode("utf-8")
|
||||
|
||||
# mtime=0 keeps the gzip output (and therefore the ETag) deterministic
|
||||
gz = gzip.compress(raw, compresslevel=9, mtime=0)
|
||||
etag = hashlib.sha256(gz).hexdigest()[:16]
|
||||
|
||||
lines = []
|
||||
lines.append("// Auto-generated by scripts/generate_webconfig_html.py from %s" % SOURCE.replace(os.sep, "/"))
|
||||
lines.append("// DO NOT EDIT - edit webui/index.html instead.")
|
||||
lines.append("%s%s" % (HASH_MARKER, src_hash))
|
||||
lines.append("#pragma once")
|
||||
lines.append("#include <stdint.h>")
|
||||
lines.append("#include <pgmspace.h>")
|
||||
lines.append("")
|
||||
lines.append("const uint32_t WEBCONFIG_HTML_GZ_LEN = %d;" % len(gz))
|
||||
lines.append('const char WEBCONFIG_HTML_ETAG[] = "\\"%s\\"";' % etag)
|
||||
lines.append("const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {")
|
||||
for i in range(0, len(gz), 16):
|
||||
chunk = gz[i:i + 16]
|
||||
lines.append(" " + "".join("0x%02x," % b for b in chunk))
|
||||
lines.append("};")
|
||||
lines.append("")
|
||||
|
||||
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
|
||||
with open(OUTPUT, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
status("%s -> %s (%d bytes source, %d stripped, %d gzipped)"
|
||||
% (SOURCE, OUTPUT, len(src.encode("utf-8")), len(raw), len(gz)))
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the portal terminal's command table against the mock backend.
|
||||
|
||||
Autocomplete in webui/index.html carries its own list of commands. Nothing ties
|
||||
that list to what a node actually answers, so it can quietly drift into offering
|
||||
commands that do not exist — or, more often here, the mock can lag the table and
|
||||
make a perfectly real command look broken.
|
||||
|
||||
This drives every command the table offers through /api/cli and reports the ones
|
||||
that come back an error, so the two stay honest about each other.
|
||||
|
||||
python3 scripts/webconfig_mock_server.py --port 8137 &
|
||||
python3 scripts/webconfig_cli_audit.py
|
||||
|
||||
Exits non-zero if anything fails that is not in EXPECTED_FAILURES. Stdlib only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ.get("WEBCONFIG_MOCK", "http://localhost:8137")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html")
|
||||
|
||||
# Errors that are the correct answer, not a gap.
|
||||
EXPECTED_FAILURES = {
|
||||
# Runtime-gated on the real device by Board::canControlLoRaFemLna(); the
|
||||
# command exists in every build and the board answers for itself. The mock
|
||||
# board is a Heltec V3, which has no front-end module.
|
||||
"get radio.fem.rxgain": "unsupported",
|
||||
"set radio.fem.rxgain on": "unsupported",
|
||||
# Guarded by the firmware the same way when no alert PSK is configured.
|
||||
"alert test": "not configured",
|
||||
}
|
||||
|
||||
# Commands that change the node out from under the audit.
|
||||
SKIP = {"reboot", "clkreboot", "poweroff", "shutdown", "erase", "start ota",
|
||||
"stop webconfig", "ota update", "start webconfig", "start webconfig ap"}
|
||||
|
||||
|
||||
def table():
|
||||
"""The commands autocomplete offers, read straight out of the page."""
|
||||
html = open(INDEX_HTML, encoding="utf-8").read()
|
||||
|
||||
def section(start, end):
|
||||
return html[html.index(start):html.index(end)]
|
||||
|
||||
verbs = re.findall(r'\["([^"]+)","', section("var CLI_VERBS=", "var CLI_KEYS="))
|
||||
keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)',
|
||||
section("var CLI_KEYS=", "var CLI_SLOT="))
|
||||
fields = re.findall(r'\["(\w+)","', section("var CLI_SLOT=", "var CLI_TYPES="))
|
||||
|
||||
gets = ["get " + k for k, mode in keys if mode != "2"]
|
||||
gets += ["get mqtt%d.%s" % (n, f) for n in (1, 3) for f in fields]
|
||||
# Verbs taking an argument need a value the node will accept; those are
|
||||
# covered by the round-trip probes below rather than guessed at here.
|
||||
plain = [v for v in verbs if not v.endswith(" ") and v not in SKIP]
|
||||
return gets + plain
|
||||
|
||||
|
||||
# Where top-level commands are implemented. MyMesh handles a few before
|
||||
# delegating to CommonCLI, which is exactly how discover.* stayed missing from
|
||||
# the table for so long: grepping CommonCLI alone does not see them.
|
||||
COMMAND_SOURCES = [
|
||||
"src/helpers/CommonCLI.cpp",
|
||||
"src/helpers/CommonCLI_Observer.cpp",
|
||||
"examples/simple_repeater/MyMesh.cpp",
|
||||
]
|
||||
|
||||
# Firmware commands the table deliberately does not offer. Everything below
|
||||
# except tls.bundletest is also rejected by /api/cli (wcCliUnavailable), so the
|
||||
# portal never pretends to run something it cannot.
|
||||
NOT_OFFERED = {
|
||||
"tls.bundletest", # TLS debugging, not an operator command
|
||||
"start ota", # binds port 80, which the portal is already using
|
||||
"clock sync", # takes its time from the caller; a web request has none
|
||||
"log", # streams to Serial and stalls the radio ("log start" is offered)
|
||||
"get acl", # streams to Serial, returns nothing
|
||||
}
|
||||
|
||||
|
||||
def firmware_commands():
|
||||
"""Top-level command literals the firmware dispatches on."""
|
||||
found = set()
|
||||
for rel in COMMAND_SOURCES:
|
||||
path = os.path.join(HERE, "..", rel)
|
||||
try:
|
||||
src = open(path, encoding="utf-8").read()
|
||||
except OSError:
|
||||
continue
|
||||
for lit in re.findall(r'(?:mem|str)n?cmp\(\s*command\s*,\s*"([^"]+)"', src):
|
||||
found.add(lit.strip())
|
||||
return found - NOT_OFFERED
|
||||
ROUND_TRIPS = [
|
||||
("set radio.watchdog 30", "get radio.watchdog", "30"),
|
||||
("set dutycycle 25", "get dutycycle", "25.0"),
|
||||
("set alert.mqtt on", "get alert.mqtt", "on"),
|
||||
("set bridge.source tx", "get bridge.source", "tx"),
|
||||
("set mqtt.neighbors on", "get mqtt.neighbors", "on"),
|
||||
("set path.hash.mode 2", "get path.hash.mode", "2"),
|
||||
("set mqtt.iata den", "get mqtt.iata", "DEN"),
|
||||
# Secret reads are masked back down for an HTTP caller, in CommonCLI's own
|
||||
# words for a non-serial one (wcIsSecretReadCommand).
|
||||
("set guest.password hunter2", "get guest.password", "******** (serial only)"),
|
||||
("set wifi.pwd hunter2", "get wifi.pwd", "******** (serial only)"),
|
||||
]
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base):
|
||||
self.base = base
|
||||
r = self._open("/api/login", b'{"password":"password"}')
|
||||
self.cookie = r.headers["Set-Cookie"].split(";")[0]
|
||||
# The node caps a sequence at MAX_BATCH and reports it; chunk to match
|
||||
# rather than hardcoding a number that drifts when the slot is resized.
|
||||
self.max_cmds = json.load(self._open("/api/status")).get("max_cmds", 24)
|
||||
|
||||
def _open(self, path, data=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if getattr(self, "cookie", None):
|
||||
headers["Cookie"] = self.cookie
|
||||
return urllib.request.urlopen(urllib.request.Request(
|
||||
self.base + path, data=data, headers=headers,
|
||||
method="POST" if data is not None else "GET"))
|
||||
|
||||
def run(self, cmds):
|
||||
"""[(command, result)]. The node never echoes the command back — it may
|
||||
carry a secret — so results pair with what was sent, by index."""
|
||||
out = []
|
||||
for i in range(0, len(cmds), self.max_cmds):
|
||||
chunk = cmds[i:i + self.max_cmds]
|
||||
results = self._sequence(chunk)
|
||||
if len(results) != len(chunk):
|
||||
sys.exit("node returned %d results for %d commands" % (len(results), len(chunk)))
|
||||
out += list(zip(chunk, results))
|
||||
return out
|
||||
|
||||
def _sequence(self, cmds):
|
||||
reqid = secrets.token_hex(8)
|
||||
body = json.dumps({"reqid": reqid, "cmds": cmds}).encode()
|
||||
for _ in range(200): # the executor frees itself in time
|
||||
try:
|
||||
self._open("/api/cli", body)
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 409:
|
||||
raise
|
||||
time.sleep(0.5)
|
||||
# Results stream and page, so keep reading from a cursor until the node
|
||||
# says done — "done" arrives only once every result has been handed over.
|
||||
out = []
|
||||
while True:
|
||||
r = json.load(self._open("/api/cli/result?reqid=%s&from=%d" % (reqid, len(out))))
|
||||
out += r.get("results", [])
|
||||
if r["state"] == "done":
|
||||
return out
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
cli = Client(BASE)
|
||||
except OSError as e:
|
||||
sys.exit("cannot reach the mock at %s (%s)\n"
|
||||
"start it with: python3 scripts/webconfig_mock_server.py --port 8137" % (BASE, e))
|
||||
|
||||
failures = []
|
||||
|
||||
cmds = table()
|
||||
unexpected = []
|
||||
for cmd, res in cli.run(cmds):
|
||||
if res["ok"]:
|
||||
continue
|
||||
want = EXPECTED_FAILURES.get(cmd)
|
||||
if want and want in res["reply"]:
|
||||
continue
|
||||
unexpected.append((cmd, res["reply"]))
|
||||
print("commands offered by autocomplete : %d" % len(cmds))
|
||||
print("answered : %d" % (len(cmds) - len(unexpected)))
|
||||
print("sequence cap reported by the node: %d" % cli.max_cmds)
|
||||
for cmd, reply in unexpected:
|
||||
print(" FAIL %-30s %s" % (cmd, reply))
|
||||
failures += unexpected
|
||||
|
||||
# The reverse direction: a command the firmware implements but the table
|
||||
# never offers is invisible to the check above, because the check only ever
|
||||
# drives what the table already knows about.
|
||||
offered = " ".join(cmds) + " " + " ".join(
|
||||
re.findall(r'\["([^"]+)","', open(INDEX_HTML, encoding="utf-8").read()))
|
||||
missing = sorted(c for c in firmware_commands() if c not in offered)
|
||||
print("\nfirmware commands not in the table: %d" % len(missing))
|
||||
for c in missing:
|
||||
print(" MISSING %s" % c)
|
||||
failures += [(c, "not offered by autocomplete") for c in missing]
|
||||
|
||||
results = cli.run([c for probe in ROUND_TRIPS for c in probe[:2]])
|
||||
print("\nround-trips : %d" % len(ROUND_TRIPS))
|
||||
for i, (setc, getc, want) in enumerate(ROUND_TRIPS):
|
||||
setr, getr = results[i * 2][1], results[i * 2 + 1][1]
|
||||
# `get` answers "> value"; compare the value, as the terminal displays it
|
||||
got = re.sub(r"^>\s?", "", getr["reply"])
|
||||
if setr["ok"] and got == want:
|
||||
continue
|
||||
print(" FAIL %-30s got %r, wanted %r (set: %s)"
|
||||
% (getc, got, want, setr["reply"]))
|
||||
failures.append((getc, got))
|
||||
|
||||
# Commands the portal refuses must be refused clearly, not run and fudged.
|
||||
print("\nrefused with a reason : ", end="")
|
||||
refused = []
|
||||
for cmd in sorted(NOT_OFFERED - {"tls.bundletest"}):
|
||||
try:
|
||||
cli._sequence([cmd])
|
||||
refused.append((cmd, "was accepted, expected a 400"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = json.load(e) if e.code == 400 else {}
|
||||
if e.code != 400 or not body.get("error"):
|
||||
refused.append((cmd, "HTTP %d, expected 400 with a reason" % e.code))
|
||||
print("%d/%d" % (len(NOT_OFFERED) - 1 - len(refused), len(NOT_OFFERED) - 1))
|
||||
for cmd, why in refused:
|
||||
print(" FAIL %-30s %s" % (cmd, why))
|
||||
failures += refused
|
||||
|
||||
print("\n%s" % ("FAILED: %d" % len(failures) if failures else "all clear"))
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Comment/indentation stripping for webui/index.html.
|
||||
|
||||
Shared by the build-time generator (which embeds the stripped page in flash)
|
||||
and the mock backend's --minify flag (which serves it), so what you test in a
|
||||
browser is byte-for-byte what the device ships. A second implementation would
|
||||
only drift.
|
||||
|
||||
Stdlib only; imported from a PIO extra_script, so it must stay side-effect free.
|
||||
"""
|
||||
|
||||
# Comment openers/closers per region of the page. The region is tracked so a
|
||||
# `<!--` inside <script> is never treated as a comment, and vice versa.
|
||||
_COMMENTS = {
|
||||
"html": [("<!--", "-->")],
|
||||
"css": [("/*", "*/")],
|
||||
"js": [("/*", "*/")],
|
||||
}
|
||||
|
||||
|
||||
def strip_source(text):
|
||||
"""Drop comments, indentation and blank lines from the page.
|
||||
|
||||
Deliberately line-based and conservative. Only a comment that *starts* its
|
||||
own line is removed, so a `//` inside a URL or a `/*` inside a regex is
|
||||
never mistaken for one; trailing comments survive, which costs a little
|
||||
flash and removes the entire class of "the minifier ate a string" bug.
|
||||
|
||||
Line breaks are preserved. That keeps JS statement boundaries exactly as
|
||||
written (no ASI surprises) and keeps the single collapsed space a newline
|
||||
contributes between HTML inline elements.
|
||||
"""
|
||||
out, mode, closer = [], "html", None
|
||||
for line in text.split("\n"):
|
||||
s = line.strip()
|
||||
|
||||
if closer is not None: # inside a multi-line comment
|
||||
at = s.find(closer)
|
||||
if at < 0:
|
||||
continue
|
||||
s = s[at + len(closer):].strip() # code may follow the close
|
||||
closer = None
|
||||
|
||||
if not s:
|
||||
continue
|
||||
|
||||
for opener, close in _COMMENTS[mode]:
|
||||
if not s.startswith(opener):
|
||||
continue
|
||||
if s.endswith(close) and len(s) > len(opener) + len(close) - 1:
|
||||
s = "" # the whole line is a comment
|
||||
elif close not in s[len(opener):]:
|
||||
closer = close # ... and it continues below
|
||||
s = ""
|
||||
break # else code follows the close
|
||||
if not s: # on this line: leave it be
|
||||
continue
|
||||
|
||||
if mode == "js" and s.startswith("//"):
|
||||
continue
|
||||
|
||||
out.append(s)
|
||||
|
||||
low = s.lower()
|
||||
if mode == "html" and "<style" in low:
|
||||
mode = "css"
|
||||
elif mode == "html" and "<script" in low:
|
||||
mode = "js"
|
||||
elif mode != "html" and ("</style>" in low or "</script>" in low):
|
||||
mode = "html"
|
||||
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def check_stripped(raw, stripped):
|
||||
"""Guard against a stripper bug silently shipping a broken portal to the
|
||||
fleet. Returns a reason string when the output looks wrong, else None."""
|
||||
for tag in ("<script>", "</script>", "<style>", "</style>", "</body>"):
|
||||
if raw.count(tag) != stripped.count(tag):
|
||||
return "%s count changed" % tag
|
||||
if len(stripped) < len(raw) * 0.5:
|
||||
return "output shrank by more than half (%d -> %d)" % (len(raw), len(stripped))
|
||||
# Only comments and whitespace may go, so no structural token may appear
|
||||
# that the source did not already have.
|
||||
for token in ("{", "}", "(", ")", "<script", "<style"):
|
||||
if stripped.count(token) > raw.count(token):
|
||||
return "gained a %s" % token
|
||||
return None
|
||||
File diff suppressed because it is too large.
Load diff
@@ -213,6 +213,7 @@ public:
|
||||
uint32_t getNumRecvDirect() const { return n_recv_direct; }
|
||||
uint16_t getErrFlags() const { return _err_flags; } // Get error flags
|
||||
bool hasOutbound() const { return outbound != NULL; }
|
||||
bool isCurrentOutbound(const Packet* packet) const { return outbound == packet; }
|
||||
void resetStats() {
|
||||
n_sent_flood = n_sent_direct = n_recv_flood = n_recv_direct = 0;
|
||||
_err_flags = 0;
|
||||
|
||||
+19
-1
@@ -4,6 +4,11 @@
|
||||
#include <ed_25519.h>
|
||||
#include <Ed25519.h>
|
||||
|
||||
#ifdef USE_CC310_HW_CRYPTO
|
||||
#include <Adafruit_nRFCrypto.h>
|
||||
#include "nrf_cc310/include/crys_ec_edw_api.h"
|
||||
#endif
|
||||
|
||||
namespace mesh {
|
||||
|
||||
Identity::Identity() {
|
||||
@@ -15,7 +20,20 @@ Identity::Identity(const char* pub_hex) {
|
||||
}
|
||||
|
||||
bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const {
|
||||
#if 0
|
||||
#ifdef USE_CC310_HW_CRYPTO
|
||||
// nRF52840 CryptoCell CC310 hardware Ed25519 verification. The software
|
||||
// implementations need ~3KB of stack (which can overflow the Adafruit core's
|
||||
// 4KB loop task stack from the advert receive path); the hardware path
|
||||
// needs much less, around 600-700bytes. The CC310 workspace is static, faster,
|
||||
// should save power at scale as well.
|
||||
static CRYS_ECEDW_TempBuff_t cc310_tmp;
|
||||
nRFCrypto.begin();
|
||||
CRYSError_t rc = CRYS_ECEDW_Verify((uint8_t*)sig, CRYS_ECEDW_SIGNATURE_BYTES,
|
||||
(uint8_t*)pub_key, CRYS_ECEDW_MOD_SIZE_IN_BYTES,
|
||||
(uint8_t*)message, (size_t)msg_len, &cc310_tmp);
|
||||
nRFCrypto.end();
|
||||
return rc == CRYS_OK;
|
||||
#elif 0
|
||||
// NOTE: memory corruption bug was found in this function!!
|
||||
return ed25519_verify(sig, message, msg_len, pub_key);
|
||||
#else
|
||||
|
||||
+34
-17
@@ -50,10 +50,13 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
uint8_t path_sz = flags & 0x03; // NEW v1.11+: lower 2 bits is path hash size
|
||||
|
||||
uint8_t len = pkt->payload_len - i;
|
||||
uint8_t offset = pkt->path_len << path_sz;
|
||||
// path_len*entry_size can exceed 255 (path_len up to 63, entry_size up to 8);
|
||||
// a uint8_t offset would wrap and steer the isHashMatch() read to the wrong place.
|
||||
uint16_t offset = (uint16_t)pkt->path_len << path_sz;
|
||||
if (offset >= len) { // TRACE has reached end of given path
|
||||
onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len);
|
||||
} else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->hasSeen(pkt)) {
|
||||
} else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->wasSeen(pkt)) {
|
||||
_tables->markSeen(pkt);
|
||||
// append SNR (Not hash!)
|
||||
pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4);
|
||||
|
||||
@@ -87,14 +90,16 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) {
|
||||
return forwardMultipartDirect(pkt);
|
||||
} else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) {
|
||||
if (!_tables->hasSeen(pkt)) { // don't retransmit!
|
||||
if (!_tables->wasSeen(pkt)) { // don't retransmit!
|
||||
_tables->markSeen(pkt);
|
||||
removeSelfFromPath(pkt);
|
||||
routeDirectRecvAcks(pkt, 0);
|
||||
}
|
||||
return ACTION_RELEASE;
|
||||
}
|
||||
|
||||
if (!_tables->hasSeen(pkt)) {
|
||||
if (!_tables->wasSeen(pkt)) {
|
||||
_tables->markSeen(pkt);
|
||||
removeSelfFromPath(pkt);
|
||||
|
||||
uint32_t d = getDirectRetransmitDelay(pkt);
|
||||
@@ -115,7 +120,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
memcpy(&ack_crc, &pkt->payload[i], 4); i += 4;
|
||||
if (i > pkt->payload_len) {
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete ACK packet", getLogDateTime());
|
||||
} else if (!_tables->hasSeen(pkt)) {
|
||||
} else if (!_tables->wasSeen(pkt)) {
|
||||
_tables->markSeen(pkt);
|
||||
onAckRecv(pkt, ack_crc);
|
||||
action = routeRecvPacket(pkt);
|
||||
}
|
||||
@@ -132,7 +138,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
uint8_t* macAndData = &pkt->payload[i]; // MAC + encrypted data
|
||||
if (i + CIPHER_MAC_SIZE >= pkt->payload_len) {
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete data packet", getLogDateTime());
|
||||
} else if (!_tables->hasSeen(pkt)) {
|
||||
} else if (!_tables->wasSeen(pkt)) {
|
||||
_tables->markSeen(pkt);
|
||||
// NOTE: this is a 'first packet wins' impl. When receiving from multiple paths, the first to arrive wins.
|
||||
// For flood mode, the path may not be the 'best' in terms of hops.
|
||||
// FUTURE: could send back multiple paths, using createPathReturn(), and let sender choose which to use(?)
|
||||
@@ -153,6 +160,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) {
|
||||
int k = 0;
|
||||
uint8_t path_len = data[k++];
|
||||
if (!Packet::isValidPathLen(path_len)) {
|
||||
MESH_DEBUG_PRINTLN("%s PAYLOAD_TYPE_PATH, bad path_len: %u", getLogDateTime(), (uint32_t)path_len);
|
||||
break; // reject bad encoding
|
||||
}
|
||||
uint8_t hash_size = (path_len >> 6) + 1;
|
||||
uint8_t hash_count = path_len & 63;
|
||||
uint8_t* path = &data[k]; k += hash_size*hash_count;
|
||||
@@ -191,7 +202,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
uint8_t* macAndData = &pkt->payload[i]; // MAC + encrypted data
|
||||
if (i + 2 >= pkt->payload_len) {
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete data packet", getLogDateTime());
|
||||
} else if (!_tables->hasSeen(pkt)) {
|
||||
} else if (!_tables->wasSeen(pkt)) {
|
||||
_tables->markSeen(pkt);
|
||||
if (self_id.isHashMatch(&dest_hash)) {
|
||||
Identity sender(sender_pub_key);
|
||||
|
||||
@@ -218,7 +230,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
uint8_t* macAndData = &pkt->payload[i]; // MAC + encrypted data
|
||||
if (i + 2 >= pkt->payload_len) {
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete data packet", getLogDateTime());
|
||||
} else if (!_tables->hasSeen(pkt)) {
|
||||
} else if (!_tables->wasSeen(pkt)) {
|
||||
_tables->markSeen(pkt);
|
||||
// scan channels DB, for all matching hashes of 'channel_hash' (max 4 matches supported ATM)
|
||||
GroupChannel channels[4];
|
||||
int num = searchChannelsByHash(&channel_hash, channels, 4);
|
||||
@@ -249,7 +262,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete advertisement packet", getLogDateTime());
|
||||
} else if (self_id.matches(id.pub_key)) {
|
||||
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): receiving SELF advert packet", getLogDateTime());
|
||||
} else if (!_tables->hasSeen(pkt)) {
|
||||
} else if (!_tables->wasSeen(pkt)) {
|
||||
_tables->markSeen(pkt);
|
||||
uint8_t* app_data = &pkt->payload[i];
|
||||
int app_data_len = pkt->payload_len - i;
|
||||
if (app_data_len > MAX_ADVERT_DATA_SIZE) { app_data_len = MAX_ADVERT_DATA_SIZE; }
|
||||
@@ -276,7 +290,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
break;
|
||||
}
|
||||
case PAYLOAD_TYPE_RAW_CUSTOM: {
|
||||
if (pkt->isRouteDirect() && !_tables->hasSeen(pkt)) {
|
||||
if (pkt->isRouteDirect() && !_tables->wasSeen(pkt)) {
|
||||
_tables->markSeen(pkt);
|
||||
onRawDataRecv(pkt);
|
||||
//action = routeRecvPacket(pkt); don't flood route these (yet)
|
||||
}
|
||||
@@ -294,7 +309,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
tmp.payload_len = pkt->payload_len - 1;
|
||||
memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len);
|
||||
|
||||
if (!_tables->hasSeen(&tmp)) {
|
||||
if (!_tables->wasSeen(&tmp)) {
|
||||
_tables->markSeen(&tmp);
|
||||
uint32_t ack_crc;
|
||||
memcpy(&ack_crc, tmp.payload, 4);
|
||||
|
||||
@@ -351,7 +367,8 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) {
|
||||
tmp.payload_len = pkt->payload_len - 1;
|
||||
memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len);
|
||||
|
||||
if (!_tables->hasSeen(&tmp)) { // don't retransmit!
|
||||
if (!_tables->wasSeen(&tmp)) { // don't retransmit!
|
||||
_tables->markSeen(&tmp);
|
||||
removeSelfFromPath(&tmp);
|
||||
routeDirectRecvAcks(&tmp, ((uint32_t)remaining + 1) * 300); // expect multipart ACKs 300ms apart (x2)
|
||||
}
|
||||
@@ -631,7 +648,7 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_si
|
||||
packet->header |= ROUTE_TYPE_FLOOD;
|
||||
packet->setPathHashSizeAndCount(path_hash_size, 0);
|
||||
|
||||
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
_tables->markSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
|
||||
uint8_t pri;
|
||||
if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) {
|
||||
@@ -660,7 +677,7 @@ void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_m
|
||||
packet->transport_codes[1] = transport_codes[1];
|
||||
packet->setPathHashSizeAndCount(path_hash_size, 0);
|
||||
|
||||
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
_tables->markSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
|
||||
uint8_t pri;
|
||||
if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) {
|
||||
@@ -693,7 +710,7 @@ void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uin
|
||||
pri = 0;
|
||||
}
|
||||
}
|
||||
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
_tables->markSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
sendPacket(packet, pri, delay_millis);
|
||||
}
|
||||
|
||||
@@ -703,7 +720,7 @@ void Mesh::sendZeroHop(Packet* packet, uint32_t delay_millis) {
|
||||
|
||||
packet->path_len = 0; // path_len of zero means Zero Hop
|
||||
|
||||
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
_tables->markSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
|
||||
sendPacket(packet, 0, delay_millis);
|
||||
}
|
||||
@@ -716,7 +733,7 @@ void Mesh::sendZeroHop(Packet* packet, uint16_t* transport_codes, uint32_t delay
|
||||
|
||||
packet->path_len = 0; // path_len of zero means Zero Hop
|
||||
|
||||
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
_tables->markSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
|
||||
|
||||
sendPacket(packet, 0, delay_millis);
|
||||
}
|
||||
|
||||
+4
-3
@@ -15,8 +15,9 @@ public:
|
||||
*/
|
||||
class MeshTables {
|
||||
public:
|
||||
virtual bool hasSeen(const Packet* packet) = 0;
|
||||
virtual void clear(const Packet* packet) = 0; // remove this packet hash from table
|
||||
virtual bool wasSeen(const Packet* packet) = 0;
|
||||
virtual void markSeen(const Packet* packet) = 0;
|
||||
virtual void clear(const Packet* packet) = 0; // remove this packet hash from table
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -100,7 +101,7 @@ protected:
|
||||
* \param auth_code a code to authenticate the packet
|
||||
* \param flags zero for now
|
||||
* \param path_snrs single byte SNR*4 for each hop in the path
|
||||
* \param path_hashes hashes if each repeater in the path
|
||||
* \param path_hashes hashes of each repeater in the path
|
||||
* \param path_len length of the path_snrs[] and path_hashes[] arrays
|
||||
*/
|
||||
virtual void onTraceRecv(Packet* packet, uint32_t tag, uint32_t auth_code, uint8_t flags, const uint8_t* path_snrs, const uint8_t* path_hashes, uint8_t path_len) { }
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ namespace mesh {
|
||||
#define PAYLOAD_TYPE_GRP_DATA 0x06 // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: data_type(uint16), data_len, blob)
|
||||
#define PAYLOAD_TYPE_ANON_REQ 0x07 // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)
|
||||
#define PAYLOAD_TYPE_PATH 0x08 // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)
|
||||
#define PAYLOAD_TYPE_TRACE 0x09 // trace a path, collecting SNI for each hop
|
||||
#define PAYLOAD_TYPE_TRACE 0x09 // trace a path, collecting SNR for each hop
|
||||
#define PAYLOAD_TYPE_MULTIPART 0x0A // packet is one of a set of packets
|
||||
#define PAYLOAD_TYPE_CONTROL 0x0B // a control/discovery packet
|
||||
//...
|
||||
|
||||
+97
-1
@@ -2,6 +2,13 @@
|
||||
#include <AES.h>
|
||||
#include <SHA256.h>
|
||||
|
||||
#ifdef USE_CC310_HW_CRYPTO
|
||||
#include <Adafruit_nRFCrypto.h>
|
||||
#include "nrf_cc310/include/crys_hash.h"
|
||||
#include "nrf_cc310/include/crys_hmac.h"
|
||||
#include "nrf_cc310/include/ssi_aes.h"
|
||||
#endif
|
||||
|
||||
#ifdef ARDUINO
|
||||
#include <Arduino.h>
|
||||
#endif
|
||||
@@ -15,19 +22,58 @@ uint32_t RNG::nextInt(uint32_t _min, uint32_t _max) {
|
||||
}
|
||||
|
||||
void Utils::sha256(uint8_t *hash, size_t hash_len, const uint8_t* msg, int msg_len) {
|
||||
#ifdef USE_CC310_HW_CRYPTO
|
||||
static CRYS_HASH_Result_t result;
|
||||
nRFCrypto.begin();
|
||||
CRYS_HASH(CRYS_HASH_SHA256_mode, (uint8_t*)msg, (size_t)msg_len, result);
|
||||
nRFCrypto.end();
|
||||
memcpy(hash, result, hash_len);
|
||||
#else
|
||||
SHA256 sha;
|
||||
sha.update(msg, msg_len);
|
||||
sha.finalize(hash, hash_len);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Utils::sha256(uint8_t *hash, size_t hash_len, const uint8_t* frag1, int frag1_len, const uint8_t* frag2, int frag2_len) {
|
||||
#ifdef USE_CC310_HW_CRYPTO
|
||||
static CRYS_HASHUserContext_t ctx;
|
||||
static CRYS_HASH_Result_t result;
|
||||
nRFCrypto.begin();
|
||||
CRYS_HASH_Init(&ctx, CRYS_HASH_SHA256_mode);
|
||||
CRYS_HASH_Update(&ctx, (uint8_t*)frag1, (size_t)frag1_len);
|
||||
CRYS_HASH_Update(&ctx, (uint8_t*)frag2, (size_t)frag2_len);
|
||||
CRYS_HASH_Finish(&ctx, result);
|
||||
nRFCrypto.end();
|
||||
memcpy(hash, result, hash_len);
|
||||
#else
|
||||
SHA256 sha;
|
||||
sha.update(frag1, frag1_len);
|
||||
sha.update(frag2, frag2_len);
|
||||
sha.finalize(hash, hash_len);
|
||||
#endif
|
||||
}
|
||||
|
||||
int Utils::decrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* src, int src_len) {
|
||||
#ifdef USE_CC310_HW_CRYPTO
|
||||
static SaSiAesUserContext_t ctx;
|
||||
SaSiAesUserKeyData_t keyData = { (uint8_t*)shared_secret, CIPHER_KEY_SIZE };
|
||||
uint8_t* dp = dest;
|
||||
const uint8_t* sp = src;
|
||||
size_t dummy_out = 0;
|
||||
|
||||
nRFCrypto.begin();
|
||||
SaSi_AesInit(&ctx, SASI_AES_DECRYPT, SASI_AES_MODE_ECB, SASI_AES_PADDING_NONE);
|
||||
SaSi_AesSetKey(&ctx, SASI_AES_USER_KEY, &keyData, sizeof(keyData));
|
||||
while (sp - src < src_len) {
|
||||
SaSi_AesBlock(&ctx, (uint8_t*)sp, 16, dp);
|
||||
dp += 16; sp += 16;
|
||||
}
|
||||
SaSi_AesFinish(&ctx, 0, NULL, 0, NULL, &dummy_out);
|
||||
SaSi_AesFree(&ctx);
|
||||
nRFCrypto.end();
|
||||
return sp - src;
|
||||
#else
|
||||
AES128 aes;
|
||||
uint8_t* dp = dest;
|
||||
const uint8_t* sp = src;
|
||||
@@ -39,9 +85,34 @@ int Utils::decrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* s
|
||||
}
|
||||
|
||||
return sp - src; // will always be multiple of 16
|
||||
#endif
|
||||
}
|
||||
|
||||
int Utils::encrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* src, int src_len) {
|
||||
#ifdef USE_CC310_HW_CRYPTO
|
||||
static SaSiAesUserContext_t ctx;
|
||||
SaSiAesUserKeyData_t keyData = { (uint8_t*)shared_secret, CIPHER_KEY_SIZE };
|
||||
uint8_t* dp = dest;
|
||||
size_t dummy_out = 0;
|
||||
|
||||
nRFCrypto.begin();
|
||||
SaSi_AesInit(&ctx, SASI_AES_ENCRYPT, SASI_AES_MODE_ECB, SASI_AES_PADDING_NONE);
|
||||
SaSi_AesSetKey(&ctx, SASI_AES_USER_KEY, &keyData, sizeof(keyData));
|
||||
while (src_len >= 16) {
|
||||
SaSi_AesBlock(&ctx, (uint8_t*)src, 16, dp);
|
||||
dp += 16; src += 16; src_len -= 16;
|
||||
}
|
||||
if (src_len > 0) { // remaining partial block — zero-pad to 16 bytes
|
||||
uint8_t tmp[16] = {};
|
||||
memcpy(tmp, src, src_len);
|
||||
SaSi_AesBlock(&ctx, tmp, 16, dp);
|
||||
dp += 16;
|
||||
}
|
||||
SaSi_AesFinish(&ctx, 0, NULL, 0, NULL, &dummy_out);
|
||||
SaSi_AesFree(&ctx);
|
||||
nRFCrypto.end();
|
||||
return dp - dest;
|
||||
#else
|
||||
AES128 aes;
|
||||
uint8_t* dp = dest;
|
||||
|
||||
@@ -58,15 +129,27 @@ int Utils::encrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* s
|
||||
dp += 16;
|
||||
}
|
||||
return dp - dest; // will always be multiple of 16
|
||||
#endif
|
||||
}
|
||||
|
||||
int Utils::encryptThenMAC(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* src, int src_len) {
|
||||
int enc_len = encrypt(shared_secret, dest + CIPHER_MAC_SIZE, src, src_len);
|
||||
|
||||
#ifdef USE_CC310_HW_CRYPTO
|
||||
static CRYS_HMACUserContext_t hmac_ctx;
|
||||
static CRYS_HASH_Result_t hmac_result;
|
||||
nRFCrypto.begin();
|
||||
CRYS_HMAC_Init(&hmac_ctx, CRYS_HASH_SHA256_mode, (uint8_t*)shared_secret, PUB_KEY_SIZE);
|
||||
CRYS_HMAC_Update(&hmac_ctx, dest + CIPHER_MAC_SIZE, enc_len);
|
||||
CRYS_HMAC_Finish(&hmac_ctx, hmac_result);
|
||||
nRFCrypto.end();
|
||||
memcpy(dest, hmac_result, CIPHER_MAC_SIZE);
|
||||
#else
|
||||
SHA256 sha;
|
||||
sha.resetHMAC(shared_secret, PUB_KEY_SIZE);
|
||||
sha.update(dest + CIPHER_MAC_SIZE, enc_len);
|
||||
sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, dest, CIPHER_MAC_SIZE);
|
||||
#endif
|
||||
|
||||
return CIPHER_MAC_SIZE + enc_len;
|
||||
}
|
||||
@@ -75,12 +158,25 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin
|
||||
if (src_len <= CIPHER_MAC_SIZE) return 0; // invalid src bytes
|
||||
|
||||
uint8_t hmac[CIPHER_MAC_SIZE];
|
||||
#ifdef USE_CC310_HW_CRYPTO
|
||||
{
|
||||
static CRYS_HMACUserContext_t hmac_ctx;
|
||||
static CRYS_HASH_Result_t hmac_result;
|
||||
nRFCrypto.begin();
|
||||
CRYS_HMAC_Init(&hmac_ctx, CRYS_HASH_SHA256_mode, (uint8_t*)shared_secret, PUB_KEY_SIZE);
|
||||
CRYS_HMAC_Update(&hmac_ctx, (uint8_t*)(src + CIPHER_MAC_SIZE), src_len - CIPHER_MAC_SIZE);
|
||||
CRYS_HMAC_Finish(&hmac_ctx, hmac_result);
|
||||
nRFCrypto.end();
|
||||
memcpy(hmac, hmac_result, CIPHER_MAC_SIZE);
|
||||
}
|
||||
#else
|
||||
{
|
||||
SHA256 sha;
|
||||
sha.resetHMAC(shared_secret, PUB_KEY_SIZE);
|
||||
sha.update(src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE);
|
||||
sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, hmac, CIPHER_MAC_SIZE);
|
||||
}
|
||||
#endif
|
||||
if (memcmp(hmac, src, CIPHER_MAC_SIZE) == 0) {
|
||||
return decrypt(shared_secret, dest, src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE);
|
||||
}
|
||||
@@ -150,4 +246,4 @@ int Utils::parseTextParts(char* text, const char* parts[], int max_num, char sep
|
||||
return num;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#include <helpers/AdvertDataHelpers.h>
|
||||
#include <helpers/UTF8Helpers.h>
|
||||
|
||||
uint8_t AdvertDataBuilder::encodeTo(uint8_t app_data[]) {
|
||||
app_data[0] = _type;
|
||||
@@ -16,11 +17,12 @@
|
||||
app_data[0] |= ADV_FEAT2_MASK;
|
||||
memcpy(&app_data[i], &_extra2, 2); i += 2;
|
||||
}
|
||||
if (_name && *_name != 0) {
|
||||
app_data[0] |= ADV_NAME_MASK;
|
||||
const char* sp = _name;
|
||||
while (*sp && i < MAX_ADVERT_DATA_SIZE) {
|
||||
app_data[i++] = *sp++;
|
||||
if (_name && *_name != 0) {
|
||||
size_t name_len = mesh::validUtf8PrefixLength(_name, MAX_ADVERT_DATA_SIZE - i);
|
||||
if (name_len > 0) {
|
||||
app_data[0] |= ADV_NAME_MASK;
|
||||
memcpy(&app_data[i], _name, name_len);
|
||||
i += name_len;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
@@ -84,4 +86,4 @@ void AdvertTimeHelper::formatRelativeTimeDiff(char dest[], int32_t seconds_from_
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ void AutoDiscoverRTCClock::begin(TwoWire& wire) {
|
||||
}
|
||||
|
||||
if (i2c_probe(wire, PCF8563_ADDRESS)) {
|
||||
MESH_DEBUG_PRINTLN("PCF8563: Found");
|
||||
rtc_8563_success = rtc_8563.begin(&wire);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,29 +68,36 @@ void BaseChatMesh::bootstrapRTCfromContacts() {
|
||||
}
|
||||
|
||||
ContactInfo* BaseChatMesh::allocateContactSlot(bool transient_only) {
|
||||
if (num_contacts < MAX_CONTACTS) {
|
||||
return &contacts[num_contacts++];
|
||||
} else if (transient_only || shouldOverwriteWhenFull()) {
|
||||
// Find oldest non-favourite contact by oldest lastmod timestamp
|
||||
int oldest_idx = -1;
|
||||
uint32_t oldest_lastmod = 0xFFFFFFFF;
|
||||
for (int i = 0; i < num_contacts; i++) {
|
||||
if (transient_only) {
|
||||
if (contacts[i].type == ADV_TYPE_NONE && contacts[i].lastmod < oldest_lastmod) {
|
||||
oldest_lastmod = contacts[i].lastmod;
|
||||
oldest_idx = i;
|
||||
}
|
||||
} else {
|
||||
int oldest_idx = -1;
|
||||
uint32_t oldest_lastmod = 0xFFFFFFFF;
|
||||
if (transient_only) {
|
||||
// only allocate from first N
|
||||
for (int i = 0; i < MAX_ANON_CONTACTS; i++) {
|
||||
if (contacts[i].type == ADV_TYPE_NONE && contacts[i].lastmod < oldest_lastmod) {
|
||||
oldest_lastmod = contacts[i].lastmod;
|
||||
oldest_idx = i;
|
||||
}
|
||||
}
|
||||
if (oldest_idx >= 0) {
|
||||
// NOTE: do NOT call onContactOverwrite()
|
||||
return &contacts[oldest_idx];
|
||||
}
|
||||
} else {
|
||||
if (num_contacts < MAX_ANON_CONTACTS+MAX_CONTACTS) {
|
||||
return &contacts[num_contacts++];
|
||||
} else if (shouldOverwriteWhenFull()) {
|
||||
// Find oldest non-favourite contact by oldest lastmod timestamp
|
||||
for (int i = MAX_ANON_CONTACTS; i < num_contacts; i++) {
|
||||
bool is_favourite = (contacts[i].flags & 0x01) != 0;
|
||||
if (!is_favourite && contacts[i].lastmod < oldest_lastmod && contacts[i].type != ADV_TYPE_NONE) {
|
||||
if (!is_favourite && contacts[i].lastmod < oldest_lastmod) {
|
||||
oldest_lastmod = contacts[i].lastmod;
|
||||
oldest_idx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldest_idx >= 0) {
|
||||
onContactOverwrite(contacts[oldest_idx].id.pub_key);
|
||||
return &contacts[oldest_idx];
|
||||
if (oldest_idx >= 0) {
|
||||
onContactOverwrite(contacts[oldest_idx].id.pub_key);
|
||||
return &contacts[oldest_idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
return NULL; // no space, no overwrite or all contacts are all favourites
|
||||
@@ -139,6 +146,11 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
|
||||
packet->header = save;
|
||||
}
|
||||
|
||||
if (from && from->type == ADV_TYPE_NONE) { // already in contacts, but from a temporary ANON_REQ ?
|
||||
memset(from, 0, sizeof(*from)); // clear the anon/temp slot
|
||||
from = NULL; // do normal 'add' flow
|
||||
}
|
||||
|
||||
bool is_new = false; // true = not in contacts[], false = exists in contacts[]
|
||||
if (from == NULL) {
|
||||
if (!shouldAutoAddContactType(parser.getType())) {
|
||||
@@ -930,11 +942,11 @@ bool BaseChatMesh::getContactByIdx(uint32_t idx, ContactInfo& contact) {
|
||||
}
|
||||
|
||||
ContactsIterator BaseChatMesh::startContactsIterator() {
|
||||
return ContactsIterator();
|
||||
return ContactsIterator(MAX_ANON_CONTACTS); // start at offset, skip the anon entries
|
||||
}
|
||||
|
||||
bool ContactsIterator::hasNext(const BaseChatMesh* mesh, ContactInfo& dest) {
|
||||
if (next_idx >= mesh->getNumContacts()) return false;
|
||||
if (next_idx >= mesh->getTotalContactSlots()) return false;
|
||||
|
||||
dest = mesh->contacts[next_idx++];
|
||||
return true;
|
||||
|
||||
@@ -28,8 +28,9 @@ public:
|
||||
class BaseChatMesh;
|
||||
|
||||
class ContactsIterator {
|
||||
int next_idx = 0;
|
||||
int next_idx;
|
||||
public:
|
||||
ContactsIterator(int start) { next_idx = start; }
|
||||
bool hasNext(const BaseChatMesh* mesh, ContactInfo& dest);
|
||||
};
|
||||
|
||||
@@ -79,8 +80,9 @@ class BaseChatMesh : public mesh::Mesh {
|
||||
protected:
|
||||
BaseChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables)
|
||||
: mesh::Mesh(radio, ms, rng, rtc, mgr, tables)
|
||||
{
|
||||
num_contacts = 0;
|
||||
{
|
||||
resetContacts();
|
||||
|
||||
#ifdef MAX_GROUP_CHANNELS
|
||||
memset(channels, 0, sizeof(channels));
|
||||
num_channels = 0;
|
||||
@@ -91,7 +93,11 @@ protected:
|
||||
}
|
||||
|
||||
void bootstrapRTCfromContacts();
|
||||
void resetContacts() { num_contacts = 0; }
|
||||
|
||||
void resetContacts() {
|
||||
memset(contacts, 0, sizeof(contacts[0])*MAX_ANON_CONTACTS); // set all to have type = ADV_TYPE_NONE(0)
|
||||
num_contacts = MAX_ANON_CONTACTS; // seed the first contacts for anon requests
|
||||
}
|
||||
void populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp);
|
||||
ContactInfo* allocateContactSlot(bool transient_only=false); // helper to find slot for new contact
|
||||
|
||||
@@ -166,7 +172,8 @@ public:
|
||||
ContactInfo* lookupContactByPubKey(const uint8_t* pub_key, int prefix_len);
|
||||
bool removeContact(ContactInfo& contact);
|
||||
bool addContact(const ContactInfo& contact);
|
||||
int getNumContacts() const { return num_contacts; }
|
||||
int getTotalContactSlots() const { return num_contacts; }
|
||||
int getNumContacts() const { return num_contacts - MAX_ANON_CONTACTS; } // don't include the reserved slots at start
|
||||
bool getContactByIdx(uint32_t idx, ContactInfo& contact);
|
||||
ContactsIterator startContactsIterator();
|
||||
ChannelDetails* addChannel(const char* name, const char* psk_base64);
|
||||
|
||||
@@ -14,6 +14,7 @@ public:
|
||||
virtual bool isEnabled() const = 0;
|
||||
|
||||
virtual bool isConnected() const = 0;
|
||||
virtual void loop() {};
|
||||
|
||||
virtual bool isWriteBusy() const = 0;
|
||||
virtual size_t writeFrame(const uint8_t src[], size_t len) = 0;
|
||||
|
||||
+510
-335
File diff suppressed because it is too large.
Load diff
+200
-243
@@ -6,6 +6,7 @@
|
||||
#include <helpers/ClientACL.h>
|
||||
#include <helpers/MQTTPresets.h> // For MAX_MQTT_SLOTS (used in NodePrefs struct layout)
|
||||
#include <helpers/RegionMap.h>
|
||||
#include <helpers/ConfigSerializer.h>
|
||||
|
||||
#if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_MQTT_BRIDGE)
|
||||
#define WITH_BRIDGE
|
||||
@@ -20,254 +21,187 @@
|
||||
#define LOOP_DETECT_MODERATE 2
|
||||
#define LOOP_DETECT_STRICT 3
|
||||
|
||||
struct NodePrefs { // persisted to file
|
||||
float airtime_factor;
|
||||
class NodePrefs : public ConfigSerializer {
|
||||
public:
|
||||
// in-memory backing data
|
||||
float airtime_factor = 0;
|
||||
char node_name[32];
|
||||
double node_lat, node_lon;
|
||||
double node_lat = 0, node_lon = 0;
|
||||
char password[16];
|
||||
float freq;
|
||||
int8_t tx_power_dbm;
|
||||
uint8_t disable_fwd;
|
||||
uint8_t advert_interval; // minutes / 2
|
||||
uint8_t rx_boosted_gain; // power settings (file offset 79)
|
||||
uint8_t flood_advert_interval; // hours
|
||||
float rx_delay_base;
|
||||
float tx_delay_factor;
|
||||
float freq = 0;
|
||||
int8_t tx_power_dbm = 0;
|
||||
uint8_t disable_fwd = 0;
|
||||
uint8_t advert_interval = 0; // minutes / 2
|
||||
uint8_t flood_advert_interval = 0; // hours
|
||||
float rx_delay_base = 0;
|
||||
float tx_delay_factor = 0;
|
||||
char guest_password[16];
|
||||
float direct_tx_delay_factor;
|
||||
uint32_t guard;
|
||||
uint8_t sf;
|
||||
uint8_t cr;
|
||||
uint8_t allow_read_only;
|
||||
uint8_t multi_acks;
|
||||
float bw;
|
||||
uint8_t flood_max;
|
||||
uint8_t flood_max_unscoped;
|
||||
uint8_t flood_max_advert;
|
||||
uint8_t interference_threshold;
|
||||
uint8_t agc_reset_interval; // secs / 4
|
||||
uint8_t path_hash_mode; // which path mode to use when sending
|
||||
float direct_tx_delay_factor = 0;
|
||||
uint32_t guard = 0;
|
||||
uint8_t sf = 0;
|
||||
uint8_t cr = 0;
|
||||
uint8_t allow_read_only = 0;
|
||||
uint8_t multi_acks = 0;
|
||||
float bw = 0;
|
||||
uint8_t flood_max = 0;
|
||||
uint8_t flood_max_unscoped = 0;
|
||||
uint8_t flood_max_advert = 0;
|
||||
uint8_t interference_threshold = 0;
|
||||
uint8_t agc_reset_interval = 0; // secs / 4
|
||||
// Bridge settings
|
||||
uint8_t bridge_enabled; // boolean
|
||||
uint16_t bridge_delay; // milliseconds (default 500 ms)
|
||||
uint8_t bridge_pkt_src; // 0 = logTx, 1 = logRx (default logRx)
|
||||
uint32_t bridge_baud; // 9600, 19200, 38400, 57600, 115200 (default 115200)
|
||||
uint8_t bridge_channel; // 1-14 (ESP-NOW only)
|
||||
uint8_t bridge_enabled = 0; // boolean
|
||||
uint16_t bridge_delay = 0; // milliseconds (default 500 ms)
|
||||
uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (fresh installs default to logRx)
|
||||
uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200)
|
||||
uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only)
|
||||
char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only)
|
||||
// Power setting
|
||||
uint8_t powersaving_enabled; // boolean
|
||||
uint8_t powersaving_enabled = 0; // boolean
|
||||
// Gps settings
|
||||
uint8_t gps_enabled;
|
||||
uint32_t gps_interval; // in seconds
|
||||
uint8_t advert_loc_policy;
|
||||
uint32_t discovery_mod_timestamp;
|
||||
float adc_multiplier;
|
||||
uint8_t gps_enabled = 0;
|
||||
uint32_t gps_interval = 0; // in seconds
|
||||
uint8_t advert_loc_policy = 0;
|
||||
uint32_t discovery_mod_timestamp = 0;
|
||||
float adc_multiplier = 0;
|
||||
char owner_info[120];
|
||||
uint8_t rx_boosted_gain = 0; // power settings
|
||||
uint8_t radio_fem_rxgain = 0; // LoRa FEM RX-gain (LNA); hardware driving is wired per-board
|
||||
uint8_t path_hash_mode = 0; // which path mode to use when sending
|
||||
uint8_t loop_detect = 0;
|
||||
uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean)
|
||||
uint8_t extra_sf[4];
|
||||
|
||||
uint8_t loop_detect;
|
||||
// NOTE: observer settings (MQTT/WiFi/timezone/SNMP/alert) are not in NodePrefs.
|
||||
// They live in MQTTPrefs, persisted separately to /mqtt_prefs, so this struct
|
||||
// stays aligned with upstream. See struct MQTTPrefs below.
|
||||
|
||||
// Restored from upstream (dropped by the 22eb9b87 revert). Persisted at the same
|
||||
// /com_prefs offsets upstream uses (293, 294) so the file stays upstream-aligned.
|
||||
uint8_t radio_fem_rxgain; // LoRa FEM RX-gain (LNA); default on. Hardware driving is
|
||||
// wired per-board in the FEM-restore change; persisted here.
|
||||
uint8_t cad_enabled; // hardware Channel Activity Detection before TX; default off
|
||||
private:
|
||||
class RadioPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
protected:
|
||||
void structure() override {
|
||||
def("freq", _parent->freq);
|
||||
def("bw", _parent->bw);
|
||||
def("sf", _parent->sf);
|
||||
def("cr", _parent->cr);
|
||||
def("cad", _parent->cad_enabled);
|
||||
def("int_thr", _parent->interference_threshold);
|
||||
def("rxgain", _parent->rx_boosted_gain);
|
||||
def("fem_rxgain", _parent->radio_fem_rxgain);
|
||||
def("tx", _parent->tx_power_dbm);
|
||||
def("af", _parent->airtime_factor);
|
||||
def("rxdelay", _parent->rx_delay_base);
|
||||
def("f_txdelay", _parent->tx_delay_factor);
|
||||
def("d_txdelay", _parent->direct_tx_delay_factor);
|
||||
def("agc_int", _parent->agc_reset_interval);
|
||||
def("hash_mode", _parent->path_hash_mode);
|
||||
def("multi_ack", _parent->multi_acks);
|
||||
}
|
||||
public:
|
||||
RadioPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
RadioPrefs radio;
|
||||
|
||||
// NOTE: observer settings (MQTT/WiFi/timezone/SNMP/alert) were moved out of
|
||||
// NodePrefs into MQTTPrefs (persisted to /mqtt_prefs) so this struct stays
|
||||
// aligned with upstream. See struct MQTTPrefs below.
|
||||
class BridgePrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
protected:
|
||||
void structure() override {
|
||||
def("en", _parent->bridge_enabled); // boolean
|
||||
def("delay", _parent->bridge_delay); // milliseconds (default 500 ms)
|
||||
def("src", _parent->bridge_pkt_src); // 0 = logTx, 1 = logRx
|
||||
def("baud", _parent->bridge_baud); // 9600, 19200, 38400, 57600, 115200 (default 115200)
|
||||
def("ch", _parent->bridge_channel); // 1-14 (ESP-NOW only)
|
||||
def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret)); // for XOR encryption of bridge packets (ESP-NOW only)
|
||||
}
|
||||
public:
|
||||
BridgePrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
BridgePrefs bridge;
|
||||
|
||||
class GPSPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
protected:
|
||||
void structure() override {
|
||||
def("en", _parent->gps_enabled); // boolean
|
||||
def("int", _parent->gps_interval); // interval in seconds
|
||||
def("adv_loc", _parent->advert_loc_policy);
|
||||
}
|
||||
public:
|
||||
GPSPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
GPSPrefs gps;
|
||||
|
||||
class PowerPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
protected:
|
||||
void structure() override {
|
||||
def("adc_mult", _parent->adc_multiplier);
|
||||
def("pwr_sav_en", _parent->powersaving_enabled);
|
||||
}
|
||||
public:
|
||||
PowerPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
PowerPrefs power;
|
||||
|
||||
class RepeatPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
protected:
|
||||
void structure() override {
|
||||
def("disable", _parent->disable_fwd);
|
||||
def("f_max", _parent->flood_max);
|
||||
def("f_max_uns", _parent->flood_max_unscoped);
|
||||
def("f_max_adv", _parent->flood_max_advert);
|
||||
def("loop", _parent->loop_detect);
|
||||
}
|
||||
public:
|
||||
RepeatPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
RepeatPrefs repeat;
|
||||
|
||||
class RoomPrefs : public ConfigSerializer {
|
||||
NodePrefs* _parent;
|
||||
protected:
|
||||
void structure() override {
|
||||
def("rd_only", _parent->allow_read_only);
|
||||
}
|
||||
public:
|
||||
RoomPrefs(NodePrefs* parent) : _parent(parent) { }
|
||||
};
|
||||
RoomPrefs room;
|
||||
|
||||
protected:
|
||||
void structure() override {
|
||||
def("name", node_name, sizeof(node_name));
|
||||
def("pass", password, sizeof(password));
|
||||
def("guest", guest_password, sizeof(guest_password));
|
||||
def("owner", owner_info, sizeof(owner_info));
|
||||
def("adv_int", advert_interval);
|
||||
def("f_adv_int", flood_advert_interval);
|
||||
def("lat", node_lat);
|
||||
def("lon", node_lon);
|
||||
def("disc_mod", discovery_mod_timestamp); // gates 'since'-filtered DISCOVER replies
|
||||
def("radio", radio);
|
||||
def("bridge", bridge);
|
||||
def("gps", gps);
|
||||
def("repeat", repeat);
|
||||
def("room", room);
|
||||
def("power", power);
|
||||
}
|
||||
|
||||
public:
|
||||
NodePrefs() : ConfigSerializer(), bridge(this), gps(this), radio(this), power(this), repeat(this), room(this) {
|
||||
node_name[0] = 0;
|
||||
password[0] = 0;
|
||||
guest_password[0] = 0;
|
||||
bridge_secret[0] = 0;
|
||||
owner_info[0] = 0;
|
||||
}
|
||||
};
|
||||
|
||||
#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];
|
||||
};
|
||||
|
||||
// MQTT preferences stored in separate file to avoid conflicts with upstream NodePrefs changes
|
||||
struct MQTTPrefs {
|
||||
// MQTT settings
|
||||
char mqtt_origin[32]; // Device name for MQTT topics
|
||||
char mqtt_iata[8]; // IATA code for MQTT topics
|
||||
uint8_t mqtt_status_enabled; // Enable status messages
|
||||
uint8_t mqtt_packets_enabled; // Enable packet messages
|
||||
uint8_t mqtt_raw_enabled; // Enable raw messages
|
||||
uint8_t mqtt_tx_enabled; // Enable TX packet uplinking
|
||||
uint32_t mqtt_status_interval; // Status publish interval (ms)
|
||||
|
||||
// WiFi settings
|
||||
char wifi_ssid[32]; // WiFi SSID
|
||||
char wifi_password[64]; // WiFi password
|
||||
uint8_t wifi_power_save; // WiFi power save mode: 0=min, 1=none, 2=max (default: 1=none)
|
||||
|
||||
// Timezone settings
|
||||
char timezone_string[32]; // Timezone string (e.g., "America/Los_Angeles")
|
||||
int8_t timezone_offset; // Timezone offset in hours (-12 to +14) - fallback
|
||||
|
||||
// Slot presets (up to MAX_MQTT_SLOTS)
|
||||
char mqtt_slot_preset[MAX_MQTT_SLOTS][24]; // e.g. "analyzer-us", "meshmapper", "custom", "none"
|
||||
|
||||
// Per-slot custom broker settings (only used when preset is "custom")
|
||||
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];
|
||||
|
||||
// Shared authentication
|
||||
char mqtt_owner_public_key[65]; // Owner public key (hex string)
|
||||
char mqtt_email[64]; // Owner email address
|
||||
|
||||
// Per-slot extended fields
|
||||
char mqtt_slot_token[MAX_MQTT_SLOTS][48]; // Per-slot token (e.g., MeshRank account token)
|
||||
char mqtt_slot_topic[MAX_MQTT_SLOTS][96]; // Per-slot custom topic template (custom preset only)
|
||||
char mqtt_slot_audience[MAX_MQTT_SLOTS][64]; // JWT audience (non-empty enables JWT auth for custom slots)
|
||||
|
||||
uint8_t mqtt_rx_enabled; // Enable RX packet uplinking (default: on)
|
||||
char mqtt_ntp_server[64]; // Custom NTP server; empty = pool.ntp.org
|
||||
|
||||
// Observer non-MQTT settings (moved out of NodePrefs so this file stays aligned
|
||||
// with upstream). New fields are appended here so a shorter /mqtt_prefs payload
|
||||
// from an earlier v1 firmware still loads; the missing tail keeps its default.
|
||||
uint8_t snmp_enabled; // boolean
|
||||
char snmp_community[24]; // community string (default "public")
|
||||
uint8_t radio_watchdog_minutes; // 0=disabled, 1-120 minutes (observer-only radio recovery)
|
||||
uint8_t alert_enabled; // 0 = off (default)
|
||||
char alert_psk_hex[33]; // 32 hex chars + null; empty = alerts disabled
|
||||
uint16_t alert_wifi_minutes; // WiFi-down threshold (0 = disabled), default 30
|
||||
uint16_t alert_mqtt_minutes; // MQTT-down threshold (0 = disabled), default 240
|
||||
uint16_t alert_min_interval_min; // min minutes between same-fault alerts, default 60
|
||||
char alert_hashtag[24]; // readback for `get alert.hashtag`
|
||||
char alert_region[31]; // optional region override; empty = default_scope
|
||||
|
||||
// Neighbors publishing (PSRAM boards only). Appended at the end of the observer
|
||||
// tail so a shorter /mqtt_prefs payload from earlier firmware still loads with
|
||||
// these defaulting off (see applyMQTTDefaults); keeps the format at VERSION 1.
|
||||
uint8_t mqtt_neighbors_enabled; // Periodic neighbors/scopes MQTT publish (PSRAM boards only)
|
||||
uint32_t mqtt_neighbors_interval; // Neighbors publish interval (ms), default 24h, range 12-336h
|
||||
};
|
||||
|
||||
// Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose
|
||||
// signed-delta comparison requires intervals below INT32_MAX milliseconds.
|
||||
// Two weeks 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;
|
||||
|
||||
// /mqtt_prefs is written with an 8-byte header so the format is self-describing.
|
||||
// Files with no header are legacy (versionless) and detected by size in loadMQTTPrefs.
|
||||
// The magic leads with a non-ASCII byte so it can never collide with the first
|
||||
// bytes of a legacy file, whose payload starts with the mqtt_origin string.
|
||||
static const uint8_t MQTT_PREFS_MAGIC[4] = {0xF5, 'M', 'Q', 'P'};
|
||||
static const uint16_t MQTT_PREFS_VERSION = 1; // bump when the MQTTPrefs payload layout changes incompatibly
|
||||
|
||||
struct MQTTPrefsHeader {
|
||||
uint8_t magic[4]; // MQTT_PREFS_MAGIC
|
||||
uint16_t version; // MQTT_PREFS_VERSION
|
||||
uint16_t payload_len; // sizeof(MQTTPrefs) at write time (sanity / forward-compat)
|
||||
};
|
||||
|
||||
// 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");
|
||||
#include <helpers/MQTTPrefsStorage.h>
|
||||
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()
|
||||
@@ -359,16 +293,34 @@ public:
|
||||
return false;
|
||||
};
|
||||
|
||||
// Browser-based config portal (ESP32 WITH_MQTT_BRIDGE builds override).
|
||||
// force_ap=true requests the SoftAP setup portal even when WiFi is configured.
|
||||
// Returns true if handled (reply filled either way when true).
|
||||
virtual bool startWebConfig(bool force_ap, char* reply) {
|
||||
(void)force_ap; (void)reply;
|
||||
return false;
|
||||
};
|
||||
virtual bool stopWebConfig(char* reply) {
|
||||
(void)reply;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Probe all configured NTP servers for connectivity (verbose=serial console gets a
|
||||
// detailed table; otherwise reply gets a compact "<server> ok|fail" list).
|
||||
virtual bool runMqttNtpDiag(char* reply, size_t reply_size, bool verbose) {
|
||||
return false; // WITH_MQTT_BRIDGE builds override
|
||||
};
|
||||
|
||||
virtual void setRxBoostedGain(bool enable) {
|
||||
// no op by default
|
||||
virtual bool setRxBoostedGain(bool enable) {
|
||||
return false; // CommonCLI reports unsupported if not overridden by wrapper
|
||||
};
|
||||
|
||||
#if defined(USE_LR2021)
|
||||
virtual bool configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) {
|
||||
return false; // Override in wrapper
|
||||
}
|
||||
#endif
|
||||
|
||||
// Fault-alert channel hooks (see NodePrefs::alert_*). The default no-op
|
||||
// implementations keep CLI commands harmless on builds that don't wire up
|
||||
// an AlertReporter.
|
||||
@@ -388,6 +340,12 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
namespace MQTTPrefsAtomicStore {
|
||||
class LegacyUpgradeGate;
|
||||
}
|
||||
#endif
|
||||
|
||||
class CommonCLI {
|
||||
mesh::RTCClock* _rtc;
|
||||
NodePrefs* _prefs;
|
||||
@@ -400,19 +358,18 @@ 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
|
||||
bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load
|
||||
|
||||
mesh::RTCClock* getRTCClock() { return _rtc; }
|
||||
void savePrefs();
|
||||
void loadPrefsInt(FILESYSTEM* _fs, const char* filename);
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
void loadMQTTPrefs(FILESYSTEM* fs);
|
||||
void saveMQTTPrefs(FILESYSTEM* fs);
|
||||
void loadMQTTPrefs(FILESYSTEM* fs, MQTTPrefsAtomicStore::LegacyUpgradeGate* legacy_upgrade);
|
||||
bool saveMQTTPrefs(FILESYSTEM* fs);
|
||||
#endif
|
||||
|
||||
void handleRegionCmd(char* command, char* reply);
|
||||
@@ -434,7 +391,7 @@ public:
|
||||
: _board(&board), _rtc(&rtc), _sensors(&sensors), _region_map(®ion_map), _acl(&acl), _prefs(prefs), _callbacks(callbacks) { }
|
||||
|
||||
void loadPrefs(FILESYSTEM* _fs);
|
||||
void savePrefs(FILESYSTEM* _fs);
|
||||
bool 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);
|
||||
|
||||
@@ -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,7 +23,9 @@
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
#include "bridges/MQTTBridge.h"
|
||||
#include "MQTTConnectionPolicy.h" // classifySlotActivation() — "will this slot connect here?"
|
||||
#include "MQTTDefaults.h"
|
||||
#include "MQTTPacketFilter.h"
|
||||
#endif
|
||||
|
||||
// Local copy of the busted-libc-safe atoi (the original in CommonCLI.cpp is static).
|
||||
@@ -92,19 +97,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 +165,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 +203,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,32 +267,35 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
} else {
|
||||
strcpy(reply, "Error: interval must be between 1-60 minutes");
|
||||
}
|
||||
#if defined(BOARD_HAS_PSRAM) && defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0
|
||||
} else if (memcmp(config, "mqtt.neighbors ", 15) == 0) {
|
||||
_mqtt_prefs.mqtt_neighbors_enabled = memcmp(&config[15], "on", 2) == 0;
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
#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) {
|
||||
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)", hours,
|
||||
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 ", 15) == 0 ||
|
||||
memcmp(config, "mqtt.neighbors.interval ", 24) == 0) {
|
||||
strcpy(reply, "Err - not supported (requires PSRAM)");
|
||||
} else if (memcmp(config, "mqtt.neighbors.interval ", 24) == 0 ||
|
||||
memcmp(config, "mqtt.neighbors ", 15) == 0) {
|
||||
strcpy(reply, "Err - neighbors not enabled in this build");
|
||||
#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) {
|
||||
@@ -281,26 +305,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");
|
||||
@@ -344,6 +372,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");
|
||||
@@ -408,26 +437,26 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
// 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. Mirrors the bridge
|
||||
// setup loop: slots past the runtime array (RUNTIME_MQTT_SLOTS) are
|
||||
// never iterated; within it, only the first getMaxActiveSlots()
|
||||
// *enabled* slots connect (each WSS/TLS link costs ~40 KB heap).
|
||||
// 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 (slot >= RUNTIME_MQTT_SLOTS) {
|
||||
if (used < 158) {
|
||||
if (used < 158) {
|
||||
if (act == MQTTConnectionPolicy::SlotActivation::BeyondArray) {
|
||||
snprintf(reply + used, 160 - used, " (slot inactive on this hardware)");
|
||||
}
|
||||
} else {
|
||||
const int max_active = MQTTBridge::getMaxActiveSlots();
|
||||
int rank = 0; // this slot's position among enabled slots, by index
|
||||
for (int s = 0; s <= slot; s++) {
|
||||
if (_mqtt_prefs.mqtt_slot_preset[s][0] != '\0' &&
|
||||
strcmp(_mqtt_prefs.mqtt_slot_preset[s], MQTT_PRESET_NONE) != 0) {
|
||||
rank++;
|
||||
}
|
||||
}
|
||||
if (rank > max_active && used < 158) {
|
||||
} else if (act == MQTTConnectionPolicy::SlotActivation::OverActiveCap) {
|
||||
snprintf(reply + used, 160 - used,
|
||||
" (won't connect: %d-slot limit on this hardware)", max_active);
|
||||
}
|
||||
@@ -438,29 +467,38 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
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) {
|
||||
int port = atoi(&subcmd[5]);
|
||||
if (port > 0 && port <= 65535) {
|
||||
_mqtt_prefs.mqtt_slot_port[slot] = port;
|
||||
savePrefs();
|
||||
_callbacks->restartBridgeSlot(slot);
|
||||
strcpy(reply, "OK");
|
||||
} else {
|
||||
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);
|
||||
@@ -468,6 +506,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();
|
||||
@@ -475,6 +515,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);
|
||||
@@ -489,6 +530,32 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
savePrefs();
|
||||
_callbacks->restartBridgeSlot(slot);
|
||||
sprintf(reply, "OK - slot %d JWT audience cleared (using username/password auth)", slot + 1);
|
||||
} else if (strcmp(subcmd, "filter") == 0 ||
|
||||
strncmp(subcmd, "filter ", 7) == 0) {
|
||||
// Empty/bare input resets to the backwards-compatible all-types default.
|
||||
const char* filter_value = subcmd[6] == '\0' ? "" : &subcmd[7];
|
||||
uint16_t filter_mask = 0;
|
||||
if (!MQTTPacketFilter::parse(filter_value, &filter_mask)) {
|
||||
strcpy(reply, "Error: filter must be all, none, or a CSV of types 0-15 / names (advert,txt_msg,...)");
|
||||
} else {
|
||||
_mqtt_prefs.mqtt_slot_packet_filter[slot] = filter_mask;
|
||||
savePrefs();
|
||||
char filter_text[MQTTPacketFilter::kFilterTextSize];
|
||||
MQTTPacketFilter::format(filter_mask, filter_text, sizeof(filter_text));
|
||||
snprintf(reply, 160, "OK - slot %d packet types: %s", slot + 1, filter_text);
|
||||
// A non-default filter extends /mqtt_prefs past what pre-filter
|
||||
// firmware can read (see MQTTPrefsCodec::payloadLenFor), so say when
|
||||
// that cost buys nothing: slots beyond the runtime array are never
|
||||
// published to on this board, the same warning `preset` gives.
|
||||
if (slot >= RUNTIME_MQTT_SLOTS &&
|
||||
filter_mask != MQTTPacketFilter::kAllPacketTypes) {
|
||||
size_t used = strlen(reply);
|
||||
if (used < 158) {
|
||||
snprintf(reply + used, 160 - used,
|
||||
" (slot inactive on this hardware; blocks firmware rollback)");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sprintf(reply, "unknown config: %s", config);
|
||||
}
|
||||
@@ -514,28 +581,21 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
strcpy(reply, "OK");
|
||||
} 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");
|
||||
@@ -766,16 +826,18 @@ 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(BOARD_HAS_PSRAM) && defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0
|
||||
#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)", hours, (unsigned long)_mqtt_prefs.mqtt_neighbors_interval);
|
||||
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", 14) == 0 ||
|
||||
memcmp(config, "mqtt.neighbors.interval", 23) == 0) {
|
||||
strcpy(reply, "Err - not supported (requires PSRAM)");
|
||||
} else if (memcmp(config, "mqtt.neighbors.interval", 23) == 0 ||
|
||||
memcmp(config, "mqtt.neighbors", 14) == 0) {
|
||||
strcpy(reply, "Err - neighbors not enabled in this build");
|
||||
#endif
|
||||
} else if (memcmp(config, "mqtt.ntp.diag", 13) == 0 && (config[13] == '\0' || config[13] == ' ')) {
|
||||
#ifdef ESP_PLATFORM
|
||||
@@ -807,12 +869,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') {
|
||||
@@ -824,7 +894,15 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
|
||||
if (_mqtt_prefs.mqtt_slot_audience[slot][0] != '\0') {
|
||||
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_audience[slot]);
|
||||
} else {
|
||||
strcpy(reply, "> (not set — custom slots use username/password auth)");
|
||||
strcpy(reply, "> (not set - custom slots use username/password auth)");
|
||||
}
|
||||
} else if (strcmp(subcmd, "filter") == 0) {
|
||||
char filter_text[MQTTPacketFilter::kFilterTextSize];
|
||||
if (MQTTPacketFilter::format(_mqtt_prefs.mqtt_slot_packet_filter[slot],
|
||||
filter_text, sizeof(filter_text))) {
|
||||
snprintf(reply, 160, "> %s", filter_text);
|
||||
} else {
|
||||
strcpy(reply, "Error: invalid stored packet filter");
|
||||
}
|
||||
} else if (memcmp(subcmd, "diag", 4) == 0) {
|
||||
MQTTBridge::formatSlotDiagReply(reply, 160, slot);
|
||||
@@ -834,7 +912,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;
|
||||
@@ -895,10 +978,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) {
|
||||
@@ -1045,6 +1131,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] : "";
|
||||
@@ -1058,7 +1159,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
#include "ConfigSerializer.h"
|
||||
|
||||
bool ConfigSerializer::saveSerial(Stream& s) {
|
||||
Context context(&s, OP::WRITE);
|
||||
_context = &context; // set the context for structure() call
|
||||
s.print("{"); // root object
|
||||
_first = true;
|
||||
structure();
|
||||
if (s.print("}") != 1) context.success = false; // failure detect
|
||||
_context = NULL;
|
||||
return context.success;
|
||||
}
|
||||
|
||||
#define TOK_ERROR -1
|
||||
#define TOK_EOF 0
|
||||
#define TOK_KEY 1
|
||||
#define TOK_VALUE 2
|
||||
#define TOK_START_OBJ 3
|
||||
#define TOK_END_OBJ 4
|
||||
#define TOK_WHITESPACE 5
|
||||
|
||||
static bool is_whitespace(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
||||
}
|
||||
static bool is_key_char(char c) {
|
||||
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_';
|
||||
}
|
||||
static bool is_value_char(char c) {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || c == '-' || c == '.';
|
||||
}
|
||||
|
||||
#define EXPECT_OPEN_BRACE 0
|
||||
#define EXPECT_KEY 1
|
||||
#define EXPECT_VAL_OR_OBJ 2
|
||||
#define EXPECT_STRING_VAL 3
|
||||
#define EXPECT_STRING_ESCAPE 4
|
||||
#define EXPECT_COMMA_OR_CLOSE 5
|
||||
#define EXPECT_COMMA_OR_KEY 6
|
||||
#define EXPECT_COMMA_OR_KEY_OR_CLOSE 7
|
||||
|
||||
int ConfigSerializer::Context::readNext() {
|
||||
char c;
|
||||
if (pending) {
|
||||
c = pending;
|
||||
pending = 0;
|
||||
} else {
|
||||
if (_f->available() == 0) return TOK_EOF;
|
||||
|
||||
int n = _f->read();
|
||||
if (n < 0) return TOK_EOF;
|
||||
c = (char)n;
|
||||
}
|
||||
|
||||
switch (rd_mode) {
|
||||
case EXPECT_OPEN_BRACE:
|
||||
if (c == '{') { rd_mode = EXPECT_KEY; return TOK_START_OBJ; }
|
||||
if (is_whitespace(c)) return TOK_WHITESPACE;
|
||||
return TOK_ERROR;
|
||||
|
||||
case EXPECT_COMMA_OR_KEY_OR_CLOSE:
|
||||
if (c == '}') { rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; return TOK_END_OBJ; }
|
||||
case EXPECT_COMMA_OR_KEY:
|
||||
if (c == ',') { rd_mode = EXPECT_KEY; return TOK_WHITESPACE; }
|
||||
case EXPECT_KEY:
|
||||
if (rd_len > 0 && c == ':') { rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_VAL_OR_OBJ; return TOK_KEY; }
|
||||
if (rd_len == 0 && is_whitespace(c)) return TOK_WHITESPACE;
|
||||
if (rd_len < CONFIG_MAX_KEYLEN-1 && is_key_char(c)) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; }
|
||||
return TOK_ERROR;
|
||||
|
||||
case EXPECT_VAL_OR_OBJ:
|
||||
if (rd_len == 0 && is_whitespace(c)) return TOK_WHITESPACE;
|
||||
if (rd_len == 0 && c == '"') { rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; }
|
||||
if (rd_len == 0 && c == '{') { rd_mode = EXPECT_KEY; return TOK_START_OBJ; }
|
||||
if (is_value_char(c) && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; }
|
||||
if (rd_len > 0 && (c == ',' || c == '}' || is_whitespace(c))) { pending = c; rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_COMMA_OR_CLOSE; return TOK_VALUE; }
|
||||
return TOK_ERROR;
|
||||
|
||||
case EXPECT_STRING_ESCAPE:
|
||||
if ((c == 'n') && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = '\n'; rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; }
|
||||
if ((c == 'r') && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = '\r'; rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; }
|
||||
if ((c == '"' || c == '\\' || c == '/') && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; }
|
||||
return TOK_ERROR; // unsupport escape
|
||||
|
||||
case EXPECT_STRING_VAL:
|
||||
if (c == '"') { rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_COMMA_OR_CLOSE; return TOK_VALUE; }
|
||||
if (c == '\\') { rd_mode = EXPECT_STRING_ESCAPE; return TOK_WHITESPACE; }
|
||||
if (rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; }
|
||||
return TOK_ERROR;
|
||||
|
||||
case EXPECT_COMMA_OR_CLOSE:
|
||||
if (c == ',') { rd_mode = EXPECT_KEY; return TOK_WHITESPACE; }
|
||||
if (c == '}') { rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; return TOK_END_OBJ; }
|
||||
if (is_whitespace(c)) return TOK_WHITESPACE;
|
||||
return TOK_ERROR;
|
||||
}
|
||||
return TOK_ERROR; // unknown mode
|
||||
}
|
||||
|
||||
bool ConfigSerializer::loadSerial(Stream& s) {
|
||||
Context context(&s, OP::READ);
|
||||
_context = &context; // set the context for structure() call
|
||||
uint8_t sp = 0; // object nesting stack pointer
|
||||
int next_tok;
|
||||
|
||||
// parse the Json file
|
||||
while ((next_tok = context.readNext()) > TOK_EOF) {
|
||||
if (next_tok == TOK_KEY) {
|
||||
context.setKey(sp, context.getToken());
|
||||
} else if (next_tok == TOK_VALUE) {
|
||||
_depth = 1; // re-run the structure() hierarchy again (looking for specific key, at specific depth)
|
||||
structure();
|
||||
} else if (next_tok == TOK_START_OBJ) {
|
||||
if (sp < CONFIG_MAX_DEPTH - 1) {
|
||||
sp++;
|
||||
} else {
|
||||
//Serial.printf("Error: max nesting reached"); // TODO: debug logging
|
||||
context.success = false;
|
||||
break;
|
||||
}
|
||||
} else if (next_tok == TOK_END_OBJ) {
|
||||
if (sp > 0) {
|
||||
sp--;
|
||||
} else {
|
||||
//Serial.printf("Error: too many closing '}'"); // TODO: debug logging
|
||||
context.success = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sp != 0 || next_tok == TOK_ERROR) {
|
||||
context.success = false; // unmatched { }, or other parse error
|
||||
}
|
||||
_context = NULL;
|
||||
return context.success;
|
||||
}
|
||||
|
||||
void ConfigSerializer::writeComma() {
|
||||
if (_first) {
|
||||
_first = false;
|
||||
} else {
|
||||
_context->file()->print(","); // comma separated properties
|
||||
}
|
||||
}
|
||||
|
||||
#include <Utils.h>
|
||||
|
||||
void ConfigSerializer::def(const char* key, void* value, size_t len) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":\"");
|
||||
mesh::Utils::printHex(*_context->file(), (uint8_t*) value, len);
|
||||
_context->file()->print("\"");
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
memset(value, 0, len);
|
||||
mesh::Utils::fromHex((uint8_t *)value, len, _context->getToken());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, char* value, size_t max_len) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":\"");
|
||||
char c;
|
||||
while ((c = *value++) != 0) { // TODO: handle UTF-8 encoding
|
||||
if (c == '"') {
|
||||
_context->file()->print("\\\"");
|
||||
} else if (c == '\\') {
|
||||
_context->file()->print("\\\\");
|
||||
} else if (c == '\n') {
|
||||
_context->file()->print("\\n");
|
||||
} else if (c == '\r') {
|
||||
_context->file()->print("\\r");
|
||||
} else {
|
||||
_context->file()->print(c);
|
||||
}
|
||||
}
|
||||
_context->file()->print("\"");
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
strncpy(value, _context->getToken(), max_len - 1);
|
||||
value[max_len - 1] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, int32_t& value) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":");
|
||||
_context->file()->print(value);
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
value = atol(_context->getToken());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, uint32_t& value) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":");
|
||||
_context->file()->print(value);
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
value = atol(_context->getToken());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, int16_t& value) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":");
|
||||
_context->file()->print((int32_t) value, 10);
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
value = atol(_context->getToken());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, uint16_t& value) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":");
|
||||
_context->file()->print((uint32_t) value, 10);
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
value = atoi(_context->getToken());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, uint8_t& value) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":");
|
||||
_context->file()->print((uint32_t) value, 10);
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
value = atoi(_context->getToken());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, int8_t& value) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":");
|
||||
_context->file()->print((int32_t) value, 10);
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
value = atoi(_context->getToken());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, bool& value) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":");
|
||||
_context->file()->print(value ? "true" : "false");
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
value = strcmp(_context->getToken(), "true") == 0 || atoi(_context->getToken()) != 0; // 'true' or a non-zero number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, double& value) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":");
|
||||
if (value == 0.0) {
|
||||
_context->file()->print("0"); // shorter encoding
|
||||
} else {
|
||||
_context->file()->print(value, 6); // REVISIT: how many dec places?
|
||||
}
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
value = atof(_context->getToken());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, float& value) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":");
|
||||
if (value == 0.0f) {
|
||||
_context->file()->print("0"); // shorter encoding
|
||||
} else {
|
||||
_context->file()->print(value, 4); // REVISIT: how many dec places?
|
||||
}
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
value = (float) atof(_context->getToken());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigSerializer::def(const char* key, ConfigSerializer& sub_obj) {
|
||||
if (_context->op() == OP::WRITE) {
|
||||
writeComma();
|
||||
_context->file()->print(key);
|
||||
_context->file()->print(":{");
|
||||
sub_obj._context = _context; // inherit the Context
|
||||
sub_obj._first = true;
|
||||
sub_obj.structure(); // recurse into sub object
|
||||
if (_context->file()->print("}") != 1) _context->success = false; // failure detect
|
||||
} else {
|
||||
if (_context->keyMatch(_depth, key)) {
|
||||
sub_obj._context = _context; // inherit the Context
|
||||
sub_obj._depth = _depth + 1;
|
||||
sub_obj.structure(); // recurse into sub object
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#ifndef CONFIG_MAX_DEPTH
|
||||
#define CONFIG_MAX_DEPTH 8
|
||||
#endif
|
||||
|
||||
#ifndef CONFIG_MAX_KEYLEN
|
||||
#define CONFIG_MAX_KEYLEN 16
|
||||
#endif
|
||||
|
||||
#ifndef CONFIG_MAX_TOKEN_LEN
|
||||
#define CONFIG_MAX_TOKEN_LEN 128
|
||||
#endif
|
||||
|
||||
class ConfigSerializer {
|
||||
bool _first;
|
||||
int8_t _depth;
|
||||
|
||||
enum OP { READ, WRITE };
|
||||
|
||||
class Context {
|
||||
Stream* _f;
|
||||
OP _op;
|
||||
uint8_t rd_len;
|
||||
uint8_t rd_mode;
|
||||
char pending;
|
||||
char rd_buf[CONFIG_MAX_TOKEN_LEN];
|
||||
char _keys[CONFIG_MAX_DEPTH][CONFIG_MAX_KEYLEN];
|
||||
|
||||
public:
|
||||
bool success = true;
|
||||
Context(Stream* f, OP op) : _f(f), _op(op) { rd_buf[rd_len = 0] = 0; rd_mode = 0; pending = 0; }
|
||||
OP op() const { return _op; }
|
||||
Stream* file() const { return _f; }
|
||||
int readNext();
|
||||
const char* getToken() const { return rd_buf; }
|
||||
bool keyMatch(int8_t depth, const char* key) { return strcmp(key, _keys[depth]) == 0; }
|
||||
void setKey(uint8_t depth, const char* key) { strcpy(_keys[depth], key); }
|
||||
};
|
||||
|
||||
Context* _context = NULL;
|
||||
|
||||
void writeComma();
|
||||
|
||||
protected:
|
||||
ConfigSerializer() { }
|
||||
|
||||
void def(const char* key, char* value, size_t max_len); // max_len inclusive of null
|
||||
void def(const char* key, void* value, size_t len); // binary blob (encoded in hex)
|
||||
void def(const char* key, int32_t& value);
|
||||
void def(const char* key, int16_t& value);
|
||||
void def(const char* key, int8_t& value);
|
||||
void def(const char* key, uint32_t& value);
|
||||
void def(const char* key, uint16_t& value);
|
||||
void def(const char* key, uint8_t& value);
|
||||
void def(const char* key, float& value);
|
||||
void def(const char* key, double& value);
|
||||
void def(const char* key, bool& value);
|
||||
void def(const char* key, ConfigSerializer& sub_obj);
|
||||
|
||||
virtual void structure() = 0;
|
||||
|
||||
public:
|
||||
bool loadSerial(Stream& s);
|
||||
bool saveSerial(Stream& s);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
#include "ESP32Board.h"
|
||||
#include <target.h>
|
||||
|
||||
#if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only
|
||||
#include <WiFi.h>
|
||||
@@ -422,4 +423,45 @@ bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char rep
|
||||
}
|
||||
#endif // WITH_MQTT_BRIDGE
|
||||
|
||||
void ESP32Board::powerOff() {
|
||||
enterDeepSleep(0); // Do not wakeup
|
||||
}
|
||||
|
||||
void ESP32Board::enterDeepSleep(uint32_t secs) {
|
||||
// Power off the display if any
|
||||
#ifdef DISPLAY_CLASS
|
||||
display.turnOff();
|
||||
#endif
|
||||
|
||||
// Power off LoRa
|
||||
radio_driver.powerOff();
|
||||
|
||||
// Keep LoRa inactive during deepsleep
|
||||
digitalWrite(P_LORA_NSS, HIGH);
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
gpio_hold_en((gpio_num_t)P_LORA_NSS);
|
||||
#else
|
||||
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
|
||||
#endif
|
||||
|
||||
// Power off GPS if any
|
||||
if (sensors.getLocationProvider() != NULL) {
|
||||
sensors.getLocationProvider()->stop();
|
||||
}
|
||||
|
||||
// Flush serial buffers
|
||||
Serial.flush();
|
||||
delay(100);
|
||||
|
||||
// Clear stale wakeup sources to avoid ghost wakeup
|
||||
// This is required when Power Management and automatic lightsleep are enabled
|
||||
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000ULL);
|
||||
}
|
||||
|
||||
// Finally set ESP32 into deepsleep
|
||||
esp_deep_sleep_start(); // CPU halts here and never returns!
|
||||
}
|
||||
#endif
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <Wire.h>
|
||||
#include "soc/rtc.h"
|
||||
#include "esp_system.h"
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
class ESP32Board : public mesh::MainBoard {
|
||||
protected:
|
||||
@@ -62,6 +63,9 @@ public:
|
||||
return raw / 4;
|
||||
}
|
||||
|
||||
virtual void powerOff() override;
|
||||
void enterDeepSleep(uint32_t secs);
|
||||
|
||||
uint32_t getIRQGpio() override {
|
||||
return P_LORA_DIO_1; // default for SX1262
|
||||
}
|
||||
@@ -160,6 +164,42 @@ public:
|
||||
void setInhibitSleep(bool inhibit) {
|
||||
inhibit_sleep = inhibit;
|
||||
}
|
||||
|
||||
uint32_t getResetReason() const override {
|
||||
return esp_reset_reason();
|
||||
}
|
||||
|
||||
// https://docs.espressif.com/projects/esp-idf/en/v4.4.7/esp32/api-reference/system/system.html
|
||||
const char* getResetReasonString(uint32_t reason) {
|
||||
switch (reason) {
|
||||
case ESP_RST_UNKNOWN:
|
||||
return "Unknown or first boot";
|
||||
case ESP_RST_POWERON:
|
||||
return "Power-on reset";
|
||||
case ESP_RST_EXT:
|
||||
return "External reset";
|
||||
case ESP_RST_SW:
|
||||
return "Software reset";
|
||||
case ESP_RST_PANIC:
|
||||
return "Panic / exception reset";
|
||||
case ESP_RST_INT_WDT:
|
||||
return "Interrupt watchdog reset";
|
||||
case ESP_RST_TASK_WDT:
|
||||
return "Task watchdog reset";
|
||||
case ESP_RST_WDT:
|
||||
return "Other watchdog reset";
|
||||
case ESP_RST_DEEPSLEEP:
|
||||
return "Wake from deep sleep";
|
||||
case ESP_RST_BROWNOUT:
|
||||
return "Brownout (low voltage)";
|
||||
case ESP_RST_SDIO:
|
||||
return "SDIO reset";
|
||||
default:
|
||||
static char buf[40];
|
||||
snprintf(buf, sizeof(buf), "Unknown reset reason (%d)", reason);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class ESP32RTCClock : public mesh::RTCClock {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
class ExternalWatchdogManager {
|
||||
protected:
|
||||
unsigned long last_feed_watchdog;
|
||||
public:
|
||||
ExternalWatchdogManager() { last_feed_watchdog = 0; }
|
||||
virtual bool begin() { return false; }
|
||||
virtual void loop() { }
|
||||
virtual unsigned long getIntervalMs() const { return 0; }
|
||||
virtual void feed() { }
|
||||
};
|
||||
Loaded 100 of 429 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user