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:
agessaman committed 2026-08-09 07:04:22 -07:00
commit 2668ed87d6
429 files changed
+30549 -3436

No files matched your search

+1
View File
@@ -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>&#9888; DEV/BETA CHANNEL:</b> pre-release firmware. A node flashed from this channel keeps receiving OTA updates from this channel until re-flashed by cable.</p>'
cat firmware-notes.html
} > /tmp/beta-notes.html
gh_retry gh release edit "$RELEASE_TAG" --notes-file /tmp/beta-notes.html \
|| echo "WARNING: release notes sync failed" >&2
# Pruning is housekeeping and runs AFTER the upload has succeeded. If the
# API is flaky here, skip it rather than fail the job — old assets simply
# linger until the next run, which is strictly better than reporting
# failure for a build whose binaries are already published.
KEEP_BUILDS=2
if ! asset_list=$(gh_retry gh release view "$RELEASE_TAG" --json assets \
-q '.assets[] | "\(.createdAt) \(.name)"'); then
echo "::warning::could not list assets; skipping prune this run"
exit 0
fi
keep_hashes=$(printf '%s\n' "$asset_list" \
| sort -r \
| while read -r _ts name; do
printf '%s' "$name" | grep -oiE '[0-9a-f]{7,40}(-merged)?\.bin$' | grep -oiE '^[0-9a-f]{7,40}'
done \
| awk '!seen[$0]++' | head -n "$KEEP_BUILDS")
echo "Retaining build hashes:"; echo "$keep_hashes"
# Reuse asset_list rather than making a second API call (its lines are
# "<createdAt> <name>", so the name is field 2).
printf '%s\n' "$asset_list" | awk '{print $2}' \
| while read -r asset; do
ah=$(printf '%s' "$asset" | grep -oiE '[0-9a-f]{7,40}(-merged)?\.bin$' | grep -oiE '^[0-9a-f]{7,40}' || true)
if [ -n "$ah" ] && grep -qxF "$ah" <<<"$keep_hashes"; then
continue
fi
gh release delete-asset "$RELEASE_TAG" "$asset" --yes || true
done
- name: Checkout Flasher Repo
uses: actions/checkout@v4
with:
repository: agessaman/flasher.meshcore.io
token: ${{ secrets.FLASHER_DISPATCH_TOKEN }}
path: flasher
- name: Generate Beta Manifests
env:
BUILD_NUMBER: ${{ needs.enumerate.outputs.build_number }}
run: |
# config-beta.json is no longer derived here: the flasher's Version
# dropdown is feed-driven (/releases on the firmware-proxy Worker
# lists both channels), so the beta channel needs no config of its
# own and this workflow's flasher commit touches only beta/v/ and the
# counter.
mkdir -p "flasher/$MANIFEST_DIR"
# Slim manifests come from the build output in out/ (the assets
# actually uploaded to the release), not from config-beta.json — see
# gen-slim-manifests.py's --bin-dir mode (flasher repo PR #1).
python3 flasher/scripts/gen-slim-manifests.py \
--bin-dir out \
--static-path "$STATIC_PATH" \
--out-dir "flasher/$MANIFEST_DIR" \
--base-version "$FIRMWARE_VERSION" \
--build "$BUILD_NUMBER" \
--partsig-dir out
printf '{\n "baseVersion": "%s",\n "build": %s\n}\n' \
"$FIRMWARE_VERSION" "$BUILD_NUMBER" > "flasher/$COUNTER_FILE"
echo "Beta build $FIRMWARE_VERSION.$BUILD_NUMBER"
- name: Verify beta manifests point at the beta channel
run: |
# Guards against a beta manifest handing out a production download URL.
SAMPLE=$(find "flasher/$MANIFEST_DIR" -name '*.json' | head -1)
echo "sample: $SAMPLE"; cat "$SAMPLE"
if ! grep -qF "$STATIC_PATH" "$SAMPLE"; then
echo "ERROR: beta manifest does not use $STATIC_PATH" >&2; exit 1
fi
# NOTE: production's "Generate Changelog" and "Sync Docs into Flasher" steps
# are deliberately omitted. Those rewrite site-wide content (CHANGELOG.md,
# MQTT_IMPLEMENTATION.md, ...) that the production channel owns; a beta build
# must not overwrite them.
- name: Commit & Push Beta Artifacts
working-directory: flasher
run: |
# Scoped add: beta only ever touches its own manifest dir and counter,
# so a stray edit elsewhere in the flasher checkout (in particular
# production's config.json) can never be published by this workflow.
git add -A "$MANIFEST_DIR" "$COUNTER_FILE"
if git diff --cached --quiet; then
echo "No beta changes to commit."
exit 0
fi
git config user.name "meshcore-bot"
git config user.email "noreply@gessaman.com"
git commit -m "Update BETA observer firmware to ${{ steps.sha.outputs.short }} (build ${FIRMWARE_VERSION}.${{ needs.enumerate.outputs.build_number }})"
# 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
+5 -30
View File
@@ -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'
+90
View File
@@ -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/*
+4 -4
View File
@@ -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
+25 -1
View File
@@ -5,17 +5,35 @@ on:
branches: [main, dev]
paths:
- 'src/**'
- 'include/**'
- 'lib/**'
- 'examples/**'
- 'variants/**'
- 'boards/**'
- 'arch/**'
- 'scripts/**'
- 'ssl_certs/**'
- 'webui/**'
- 'default_8MB.csv'
- 'platformio.ini'
- '.github/actions/setup-build-environment/**'
- '.github/workflows/pr-build-check.yml'
push:
branches: [main, dev]
paths:
- 'src/**'
- 'include/**'
- 'lib/**'
- 'examples/**'
- 'variants/**'
- 'boards/**'
- 'arch/**'
- 'scripts/**'
- 'ssl_certs/**'
- 'webui/**'
- 'default_8MB.csv'
- 'platformio.ini'
- '.github/actions/setup-build-environment/**'
- '.github/workflows/pr-build-check.yml'
jobs:
@@ -29,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
+6 -1
View File
@@ -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.
+32
View File
@@ -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
View File
@@ -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__/*
-10
View File
@@ -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.
+11 -1
View File
@@ -31,6 +31,16 @@ Alert floods ride the **repeater's default scope** by default (the same Transpor
A "recovered" message is sent once when the underlying connection comes back. After firing, a fault is rate-limited by `alert.interval` (default 60 minutes) before it can re-fire - this prevents flapping links from spamming the channel.
## OTA milestone alerts
Key `ota update` milestones are also broadcast on the alert channel (in addition to the Serial log), so an operator who triggered the update via remote management still gets feedback: the real OTA work runs ~2.5 s after the command and reboots on success, both **outside** the command's reply window.
- **Start** - `OTA update starting`, sent when the update is confirmed and scheduled (during the reply window, so it transmits before the flash blocks the loop).
- **Fail/abort** - `OTA aborted: MQTT stop unclean, bridge resumed` (the teardown barrier withheld flashing) or `OTA aborted: <reason>` (preflight/download error). The bridge is resumed either way.
- **Success** has no dedicated message: a successful flash reboots into the new image, so the node reappearing on the new version is the success signal.
These share the alert channel, scope (`alert.region`), and the `alert` master switch with fault alerts - if `alert` is `off` or no channel is configured, OTA milestones are silent. Unlike fault alerts they are **not** rate-limited (OTA is operator-initiated and rare), and they are limited to these start/fail milestones - routine MQTT slot connect/disconnect never triggers an OTA alert.
## Defaults
| Setting | Default | Notes |
@@ -67,7 +77,7 @@ Set:
- `set alert.interval <minutes>` (60-10080; 60-minute floor to protect mesh airtime)
Action:
- `alert test` - send a one-off `[test] alert channel ok` immediately on the configured channel; ignores `alert on/off` so operators can verify the channel before enabling fault firing. Returns an error if no channel is configured.
- `alert test` - send a one-off `[test] alert channel ok` immediately on the configured channel; ignores `alert on/off` so operators can verify the channel before enabling fault firing. Returns an error if no channel is configured. If the send succeeds but the `alert` master switch is still `off`, the reply says so (`OK - test sent, but automatic alerts are OFF`) - a working test alone does **not** mean automatic WiFi/MQTT/OTA alerts will fire.
- `alert test <message>` - send a custom test message: `[test] <message>`.
## Example: dedicated hashtag channel (recommended for operator groups)
+221 -23
View File
@@ -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
View File
@@ -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
+185
View File
@@ -0,0 +1,185 @@
# MQTT Bridge Cross-Core Ownership Model
This document is the Phase 4 deliverable from `STABILITY_TESTABILITY_HANDOFF.md`:
it records **one owner for each mutable runtime domain**, maps every place a
non-owner reads owned state across cores today, and states the target primitive
for each. It is paired with the fork-owned lifecycle test seam
(`src/helpers/MQTTLifecycle.h`, `test/test_mqtt_lifecycle/`).
**Status:** ownership model documented and the lifecycle/teardown test seam
landed (Phase 4). **Phase 5 (branch `phase5/cooperative-mqtt-shutdown`) has now
implemented the cooperative shutdown and the OTA barrier — hazard §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.
+1 -1
View File
@@ -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
View File
@@ -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
+122
View File
@@ -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).
+516
View File
@@ -0,0 +1,516 @@
# WebConfig Branch Review
## Scope
This review covers the fork-owned WebConfig and Heltec Tracker additions on the `webconfig` branch, principally commits `d7a7e1b6`, `dfee21a0`, and `639c07a4`, plus the fork-owned MQTT/CLI paths they invoke. Issues inherited unchanged from `meshcore-dev/MeshCore` are intentionally excluded.
No implementation changes are included in this document.
## Executive Summary
The portal builds successfully and has a sound high-level design: HTTP handlers avoid directly running CLI/radio operations, configuration writes are marshalled to the loop task, secrets are represented by placeholders, and the UI is self-contained for offline provisioning.
Before deployment, the most important work is:
1. Secure setup/forced-AP reachability.
2. Correlate each save with its own result.
3. Prevent reboot after partially failed wizard saves.
4. Make persisted Wi-Fi and MQTT settings match live runtime behavior.
5. Remove cross-task preference/statistics races and long loop-task blocking.
## Priority 1: Security and Data Integrity
### 1. Forced AP mode exposes the unauthenticated setup API on the existing LAN
**Severity:** High
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:125-134`
- `src/helpers/esp32/WebConfigServer.cpp:346-349`
- `src/helpers/bridges/MQTTBridge.cpp:801-866`
- `src/helpers/bridges/MQTTBridge.cpp:919-926`
- `examples/simple_repeater/MyMesh.cpp:1329-1339`
- `examples/simple_room_server/MyMesh.cpp:974-984`
**Problem:**
`MQTTBridge::end()` deliberately leaves the STA association connected. Forced setup then selects `WIFI_AP_STA`, starts the server on port 80, and disables authentication for all requests while `MODE_SETUP` is active. The server is therefore reachable through both the setup AP and the existing LAN connection.
The comment that setup mode implies physical proximity is not valid in forced-AP mode. Any host on the existing LAN can read or change configuration and reboot the node without the admin password.
**Suggested fix:**
Choose one of these approaches:
- Disconnect and disable STA before entering unauthenticated setup mode, leaving only the SoftAP interface active. If scanning requires STA mode, keep the interface enabled but explicitly disconnect it and prevent auto-reconnect.
- Bind the setup listener only to the SoftAP interface if the ESPAsyncWebServer/network stack supports reliable interface binding.
- Keep authentication enabled for setup-mode requests arriving through STA, while allowing unauthenticated requests only through the SoftAP interface.
Add a hardware test that starts from an associated STA connection, stops the bridge, enters forced AP mode, and confirms that another LAN host cannot access `/api/config` or `/api/reboot` without authentication.
### 2. Default provisioning sends credentials over an open HTTP network
**Severity:** High
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:130-134`
- `src/helpers/esp32/WebConfigServer.cpp:346-349`
- `src/helpers/esp32/WebConfigServer.cpp:530-598`
**Problem:**
No reviewed observer environment defines `WEBCONFIG_AP_PASSWORD`, so setup creates an open AP. Setup requests are unauthenticated and use plain HTTP. Wi-Fi passwords, MQTT passwords, and access tokens can be captured by another nearby station. A nearby party can also provision the device before the intended operator.
**Suggested fix:**
- Generate a unique per-device setup password from secure random data during first boot and show it on the display or a physical label.
- Alternatively require a short-lived setup PIN displayed on-device and validated by the API before secrets can be submitted.
- Add an explicit provisioning-session expiry and invalidate the setup credential after successful setup.
- If an intentionally open AP remains supported, document the threat model prominently and avoid describing proximity as authentication.
### 3. Save results are not correlated with the submitted batch
**Severity:** High
**Locations:**
- `webui/index.html:630-680`
- `src/helpers/esp32/WebConfigServer.cpp:547-550`
- `src/helpers/esp32/WebConfigServer.cpp:601-641`
**Problem:**
The server keeps the last completed result readable. The frontend polls `/api/config/result` after nearly every POST failure because the POST may have reached the device even if its response was lost. There is no request or batch identity.
A rejected, lost, or concurrent save can therefore consume a previous or different tab's result, report success, clear `st.dirty`, and overwrite unsaved values with the device configuration.
**Suggested fix:**
- Have the browser generate a random request ID and include it in the POST body.
- Store that ID with the batch and include it in the 202 response and every result response.
- Require the frontend to accept `pending` or `done` only when the ID matches.
- Treat definite HTTP responses such as 400, 409, and 413 as final rejection; only poll after an ambiguous network failure.
- Return the active request ID with 409 so the browser can distinguish its own retry from another client's batch.
Add regression tests for stale `DONE`, two tabs, 409, lost 202, lost result response, and a POST that never reached the server.
### 4. Failed wizard commands still cause partial application and reboot
**Severity:** High
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:265-297`
- `src/helpers/esp32/WebConfigServer.cpp:624-637`
- `webui/index.html:950-957`
**Problem:**
Commands are persisted independently. Error replies are recorded, but `_batch_reboot` still schedules a fallback reboot, and reading the result arms the three-second reboot without checking aggregate success. The wizard can display rejected settings while the device reboots with a partially applied configuration.
**Suggested fix:**
- Track aggregate batch success while draining commands.
- Arm automatic reboot only if every required command succeeded.
- If partial persistence cannot be rolled back, return an explicit `partial` state listing applied and rejected keys.
- Keep the portal active after partial failure and present deliberate choices: correct and retry, or reboot with the partial configuration.
- Longer term, validate all values before executing any setter, or stage a complete preference snapshot and commit it atomically.
## Priority 2: Runtime Correctness
### 5. LAN Wi-Fi edits do not perform the reconnect promised by the UI
**Severity:** High
**Locations:**
- `webui/index.html:339-350`
- `webui/index.html:630-641`
- `src/helpers/CommonCLI_Observer.cpp:278-285`
**Problem:**
The Wi-Fi tab says the connection will restart and the page will drop. Normal editor saves do not request reboot, and the underlying setters only persist the SSID/password. The active connection remains on the old network, while subsequent config reads show the new persisted values.
**Suggested fix:**
Use an explicit `Save and reconnect` or `Save and reboot` flow for SSID/password changes. Deliver the result first, then schedule the reconnect/reboot. Do not replace the displayed active network with the persisted network until the transition has begun successfully.
### 6. MQTT publishing controls report success without changing live behavior
**Severity:** High
**Locations:**
- `webui/index.html:303-319`
- `src/helpers/CommonCLI_Observer.cpp:215-238`
- `src/helpers/bridges/MQTTBridge.cpp:637-645`
**Problem:**
Status, packet, raw, RX, and TX options are copied into cached bridge fields during initialization. Their CLI setters persist preferences but do not refresh those fields or restart the bridge. The UI reports success although the running bridge continues using old values.
**Suggested fix:**
- Add one task-safe bridge method that reloads the publishing flags from preferences on the MQTT task, or
- coalesce these changes into one full bridge restart after the batch.
If live application is not desirable, label these controls as restart-required and provide a reboot action instead of claiming immediate success.
### 7. Custom MQTT endpoint edits do not reliably refresh the live slot
**Severity:** High
**Locations:**
- `src/helpers/CommonCLI_Observer.cpp:385-425`
- `examples/simple_repeater/MyMesh.cpp:1354-1367`
- `examples/simple_room_server/MyMesh.cpp:999-1012`
- `src/helpers/bridges/MQTTBridge.cpp:1066-1072`
- `src/helpers/bridges/MQTTBridge.cpp:2076-2095`
**Problem:**
Custom server and port setters persist without requesting slot reconfiguration. Credential changes request reconfiguration, but the custom branch of `applySlotPreset()` reuses the existing slot fields instead of copying the current host, port, username, and password from preferences.
**Suggested fix:**
Create a single `reloadSlotFromPrefs(slot)` operation executed on the MQTT task. It should tear down the slot, copy every custom field and preset-dependent credential from `MQTTPrefs`, validate the complete endpoint, and then reconnect. Every slot-affecting CLI setter should queue that same operation rather than implementing partial restart behavior.
### 8. Wizard review and validation mishandle intentionally cleared values
**Severity:** Medium
**Locations:**
- `webui/index.html:907-929`
- `webui/index.html:931-933`
**Problem:**
Review calculations use `dirtyValue || originalValue`. An intentional empty string is treated as absent, so Review shows the original SSID/password/name/identity/slot while the submitted batch clears it. Final SSID validation can pass using the old SSID even though the effective new value is empty.
**Suggested fix:**
Resolve values by key presence, not truthiness. Add a helper such as `effectiveValue(key)` that returns `st.dirty[key]` when the key exists in `st.dirty`, including `""`, and otherwise returns `st.orig[key]`. Use it for review, validation, and reboot messaging.
### 9. A credential equal to `********` cannot be configured
**Severity:** Low
**Locations:**
- `webui/index.html:422`
- `webui/index.html:527-538`
- `src/helpers/esp32/WebConfigServer.cpp:564`
**Problem:**
The secret sentinel is also a valid possible password/token. The UI either considers it unchanged or the backend silently drops it.
**Suggested fix:**
Track secret-field edit state separately from the displayed placeholder. Prefer an empty password control with an adjacent “stored credential unchanged” indicator. If retaining the sentinel, reject that exact value with a clear validation message rather than silently ignoring it.
## Priority 3: Concurrency, Responsiveness, and Lifecycle
### 10. The preference mutex does not synchronize reads with writes
**Severity:** Medium
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:454-523`
- `src/helpers/esp32/WebConfigServer.cpp:265-280`
- `src/helpers/CommonCLI.cpp:1074-1216`
- `src/helpers/CommonCLI_Observer.cpp:198-486`
**Problem:**
`handleConfigGet()` takes `_mux` while reading preferences, but `drainBatch()` invokes CLI setters outside `_mux`. Those setters mutate the same strings and scalar fields. The lock therefore does not protect the data from concurrent async HTTP reads.
**Suggested fix:**
Prefer loop-task ownership: have the HTTP handler request a configuration snapshot, let `tick()` build it on the loop task, and return the immutable snapshot. If retaining direct reads, every writer must take the same mutex, with careful review to avoid holding it through flash writes or callbacks.
### 11. Changing `mqtt.ntp` can block the main loop for up to 30 seconds
**Severity:** Medium
**Locations:**
- `src/helpers/CommonCLI_Observer.cpp:249-272`
- `src/helpers/bridges/MQTTBridge.cpp:3230-3247`
- `src/helpers/esp32/WebConfigServer.cpp:275-280`
**Problem:**
Web batches run from `tick()` on the Arduino loop task. The NTP setter waits for the MQTT task in a polling loop that can last 30 seconds. During that period mesh/radio processing, portal DNS, further batch work, stats, and reboot timers stop progressing.
**Suggested fix:**
Persist and validate hostname syntax synchronously, then queue NTP synchronization without waiting. Represent NTP validation as a separate asynchronous operation/status result. The config batch should finish immediately and the UI can poll NTP validation independently.
### 12. MQTT status snapshots read mutable cross-core state without synchronization
**Severity:** Medium
**Locations:**
- `src/helpers/bridges/MQTTBridge.cpp:295-320`
- `examples/simple_repeater/MyMesh.cpp:1403-1408`
- `examples/simple_room_server/MyMesh.cpp:1048-1053`
**Problem:**
The loop task reads slot state and client counters while the MQTT task and callbacks mutate them. This can yield inconsistent snapshots and formal C++ data races.
**Suggested fix:**
Build a plain-data `SlotStatusSnapshot` array on the MQTT task and publish it atomically or under a shared lock. The web/loop side should read only the copied snapshot and should not call client methods cross-task.
### 13. Fixed-delay server deletion does not prove connections have ended
**Severity:** Medium, hardware/library stress-test required
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:188-204`
**Problem:**
Stopping the listener and waiting two seconds does not establish that accepted slow or stalled clients have completed. Deleting the server and route lambdas while a request remains active risks use-after-free or crashes.
**Suggested fix:**
Use connection/request reference counting and finalize deletion only after all accepted requests disconnect, with an upper-bound recovery policy. If the library cannot expose lifecycle state safely, consider retaining one server instance for the firmware lifetime and enabling/disabling routes/listening without deleting captured handler state.
### 14. Repeated starts permanently grow the global default-header list
**Severity:** Medium
**Location:**
- `src/helpers/esp32/WebConfigServer.cpp:177-185`
**Problem:**
`DefaultHeaders::Instance().addHeader()` appends to a process-lifetime list on every server creation. Repeated start/stop cycles consume heap and add duplicate `Cache-Control` headers to every response.
**Suggested fix:**
Register the global header once through a static one-time guard, or avoid global headers and add `Cache-Control: no-store` to each WebConfig response.
### 15. Stats polling can accumulate overlapping requests
**Severity:** Low
**Locations:**
- `webui/index.html:717-747`
**Problem:**
A three-second `setInterval` starts a new request even if the previous fetch is still pending. Degraded Wi-Fi can accumulate requests and pressure both browser and ESP32 memory.
**Suggested fix:**
Schedule the next poll with `setTimeout` only after the current request settles. Add an in-flight guard and use the existing API timeout support.
## Priority 4: Validation, Build, and Maintainability
### 16. Malformed keys can cause out-of-bounds reads and invalid JSON errors
**Severity:** Low
**Locations:**
- `src/helpers/esp32/WebConfigServer.cpp:41-59`
- `src/helpers/esp32/WebConfigServer.cpp:554-562`
**Problem:**
Short attacker-supplied keys are indexed at positions 4 and 5 without first establishing their length. Rejected keys are interpolated into hand-built JSON without escaping quotes or backslashes.
**Suggested fix:**
Check the key length before `memcmp` and indexed access. Construct error responses with ArduinoJson rather than string interpolation.
### 17. Input lengths do not match fixed firmware buffers
**Severity:** Low
**Locations:**
- `webui/index.html` configuration controls
- `src/helpers/CommonCLI.h:106-159`
**Problem:**
Several controls accept more text than the fixed `MQTTPrefs` fields retain. The CLI truncates values while the UI reports success.
**Suggested fix:**
Add `maxlength` values matching each destination buffer minus the NUL terminator, and validate lengths in the backend because client-side constraints are bypassable. Return a clear error instead of silently truncating.
### 18. Generated HTML freshness relies only on timestamps
**Severity:** Low
**Location:**
- `scripts/generate_webconfig_html.py:30-38`
**Problem:**
A generated header with a future timestamp can survive later source edits and embed stale UI code.
**Suggested fix:**
Use an explicit SCons source/target dependency or store the source hash in the generated header and regenerate whenever it differs. Deterministic gzip output can remain unchanged.
### 19. Tracker v1.1 reports itself as Tracker V2
**Severity:** Medium
**Locations:**
- `boards/heltec_tracker_v1_1.json:19,28`
- `variants/heltec_tracker_v2/platformio.ini:61-109`
- `variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp:82-84`
**Problem:**
The v1.1 environment reuses the V2 board implementation, whose manufacturer name is hardcoded as `Heltec Tracker V2`. WebConfig and MQTT metadata therefore report the wrong board.
**Suggested fix:**
Return `Heltec Tracker V1.1` when `HELTEC_TRACKER_V1_1` is defined, and retain the V2 value otherwise. Add a build-time or host-side assertion for both target identities.
### 20. WebConfig operation and security behavior are undocumented
**Severity:** Documentation gap
**Locations:**
- Commands added in `src/helpers/CommonCLI_Observer.cpp:982-994`
- `MQTT_IMPLEMENTATION.md`
**Problem:**
The build targets are documented, but operators cannot discover `start webconfig`, `start webconfig ap`, `stop webconfig`, first-boot AP behavior, authentication, timeout, or the security implications of setup mode.
**Suggested fix:**
Add an operator section to `MQTT_IMPLEMENTATION.md` covering:
- first-boot setup behavior;
- AP name and setup credential;
- LAN versus AP modes;
- exact CLI commands;
- authentication requirements;
- idle and absolute timeout behavior;
- how Wi-Fi changes are applied;
- how to recover through serial if provisioning fails.
### 21. Repeater and room-server integration is duplicated
**Severity:** Optimization
**Locations:**
- `examples/simple_repeater/MyMesh.*`
- `examples/simple_room_server/MyMesh.*`
- Corresponding `UITask.cpp` files
**Problem:**
Lifecycle, restart coalescing, stats construction, and display behavior are implemented twice. Fixes can easily land in only one role.
**Suggested fix:**
Extract shared WebConfig callbacks, lifecycle ownership, and JSON snapshot helpers into a common observer helper. Keep role-specific radio/stat sources as injected callbacks.
### 22. The HTML generator runs for every ESP32 build
**Severity:** Optimization
**Locations:**
- `platformio.ini:57-72`
**Problem:**
The generator runs from `esp32_base` even when WebConfig is not compiled into the target.
**Suggested fix:**
Move the pre-script to MQTT observer environments or have the script inspect build flags and return immediately unless `WITH_MQTT_BRIDGE`/WebConfig is enabled.
### 23. New observer targets enable MQTT debug logging
**Severity:** Optimization
**Locations:**
- `variants/heltec_tracker_v2/platformio.ini:190,226,262,298`
**Problem:**
`MQTT_DEBUG=1` increases serial activity and code/logging overhead in targets that otherwise appear intended for deployment.
**Suggested fix:**
Remove it from production environments or create explicit debug variants. Confirm that no diagnostic output includes credentials or tokens.
## Test Recommendations
No WebConfig-specific automated tests were found. Add focused tests for:
1. Save request/result correlation, including stale and concurrent batches.
2. Partial command failures and reboot gating.
3. Effective-value handling when fields are intentionally cleared.
4. Secret placeholder/edit semantics.
5. CLI parsing and backend length validation.
6. Runtime application of Wi-Fi and cached MQTT settings.
7. Complete custom-slot reconfiguration.
8. NTP updates without loop-task blocking.
9. Config and MQTT status snapshots under concurrent updates.
10. Repeated server start/stop heap behavior and duplicate headers.
11. Forced AP reachability from both SoftAP and STA networks.
12. Setup AP absolute expiry with an idle associated station.
13. Correct board identity for Tracker v1.1 and V2 targets.
## Verification Already Performed
The review ran the following checks successfully:
- `git diff --check`
- Extracted JavaScript with `node --check`
- PlatformIO builds:
- `heltec_tracker_v1_1_repeater_observer_mqtt`
- `heltec_tracker_v2_repeater_observer_mqtt`
- `heltec_tracker_v1_1_room_server_observer_mqtt`
- `heltec_tracker_v2_room_server_observer_mqtt`
Observed static usage:
- Repeater: 76,960 bytes RAM (23.5%), approximately 47.4% flash.
- Room server: 80,656 bytes RAM (24.6%), approximately 47.3% flash.
Successful compilation does not validate the AP/STA security boundary, async teardown, concurrency, reconnection, or partial-save behavior; those require the targeted host/hardware tests above.
## Suggested Implementation Order
1. Fix forced-AP LAN exposure and define secure provisioning authentication.
2. Introduce request IDs for save/result correlation.
3. Gate reboot on aggregate success and represent partial application explicitly.
4. Correct effective empty-value handling in the wizard.
5. Align Wi-Fi, MQTT flags, and custom-slot runtime behavior with the UI.
6. Remove NTP blocking and marshal config/status snapshots to their owning tasks.
7. Fix server lifecycle/global-header accumulation.
8. Add input-length/backend validation and polling safeguards.
9. Fix Tracker v1.1 identity and add operator documentation.
10. Add automated tests, then run the full hardware matrix.
+43
View File
@@ -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"
}
+61
View File
@@ -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"
}
+2 -2
View File
@@ -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"
}
}
+43
View File
@@ -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"
}
+43
View File
@@ -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"
}
+60
View File
@@ -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"
}
+42
View File
@@ -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"
}
+57
View File
@@ -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"
}
+66 -14
View File
@@ -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
View File
@@ -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
];
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"version": 1,
"author": "MeshCore",
"editor": "wokwi",
"parts": [
{ "type": "board-esp32-s3-devkitc-1", "id": "esp", "top": 0, "left": 0, "attrs": {} },
{ "type": "board-ssd1306", "id": "oled", "top": -110, "left": 90,
"attrs": { "i2cAddress": "0x3c" } }
],
"connections": [
[ "esp:3V3", "oled:VCC", "red", [] ],
[ "esp:GND.1", "oled:GND", "black", [] ],
[ "esp:17", "oled:SDA", "green", [] ],
[ "esp:18", "oled:SCL", "yellow", [] ],
[ "esp:TX", "$serialMonitor:RX", "", [] ],
[ "esp:RX", "$serialMonitor:TX", "", [] ]
]
}
+127 -30
View File
@@ -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.
---
+2
View File
@@ -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
View File
@@ -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.
---
+10 -4
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6 -6
View File
@@ -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;
+25 -47
View File
@@ -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)) {
+3 -3
View File
@@ -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);
+22 -16
View File
@@ -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) {
+7 -3
View File
@@ -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() {
+124 -23
View File
@@ -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; }
};
+102 -96
View File
@@ -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, &ethernet_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)
+62 -30
View File
@@ -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();
+2 -2
View File
@@ -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;
+159 -20
View File
@@ -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() {
+8 -1
View File
@@ -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);
+24 -20
View File
@@ -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
+2 -2
View File
@@ -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;
+166 -33
View File
@@ -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);
}
+32 -4
View File
@@ -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; }
};
+7 -1
View File
@@ -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();
File diff suppressed because it is too large. Load diff
+139 -26
View File
@@ -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
};
+103 -31
View File
@@ -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 -1
View File
@@ -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();
+45 -2
View File
@@ -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
+95 -13
View File
@@ -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
};
+53 -7
View File
@@ -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();
+42
View File
@@ -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
}
+8 -1
View File
@@ -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
}
+1 -1
View File
@@ -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));
}
+2 -2
View File
@@ -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"
+5 -6
View File
@@ -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);
+7
View File
@@ -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
View File
@@ -1 +1 @@
<b>MQTT Observer v1.16.0 (experimental)</b><ul><li>Up to 6 MQTT broker slots with built-in presets</li><li>Presets: Analyzer, MeshMapper, MeshRank, Waev, Meshomatic, CascadiaMesh, TennMesh, NashMesh, and more</li><li>JWT (Ed25519) and username/password authentication</li><li>Automatic reconnection with exponential backoff</li><li>After flashing, configure via serial console (115200 baud)</li></ul><p>See <a target='_blank' href='https://github.com/agessaman/MeshCore/blob/mqtt-bridge-implementation-flex/MQTT_IMPLEMENTATION.md'>setup guide</a> for configuration instructions.</p>
<b>MQTT Observer v1.16.0 (experimental)</b><ul><li>Up to 6 MQTT broker slots with built-in presets</li><li>Presets: Analyzer, MeshMapper, MeshRank, Waev, Meshomatic, CascadiaMesh, TennMesh, NashMesh, and more</li><li>JWT (Ed25519) and username/password authentication</li><li>Automatic reconnection with exponential backoff</li><li>After flashing, configure via serial console (115200 baud)</li></ul><p>See <a target='_blank' href='https://github.com/agessaman/MeshCore/blob/observer-firmware/MQTT_IMPLEMENTATION.md'>setup guide</a> for configuration instructions.</p>
+38 -4
View File
@@ -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
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Require ArduinoJson 7.4.3 in checked-in PlatformIO configurations.
The checker scans the root ``platformio.ini`` plus every
``variants/**/platformio.ini``. It checks every explicit ArduinoJson
declaration and, without a fragile fixed count, requires one in the native
test environment and each MQTT bridge environment.
Run ``python3 scripts/check_arduinojson_pin.py`` for the repository and add
``--self-test`` to exercise accepted, unpinned, wrong, and missing pins in a
temporary fixture.
"""
import argparse
from pathlib import Path
import re
import sys
from tempfile import TemporaryDirectory
VERSION = "7.4.3"
PACKAGE = "bblanchon/ArduinoJson"
PACKAGE_PATTERN = re.compile(r"\bbblanchon\s*/\s*ArduinoJson\b", re.IGNORECASE)
PIN_PATTERN = re.compile(r"@\s*7\.4\.3\Z")
SECTION_PATTERN = re.compile(r"^\s*\[([^]]+)\]\s*$")
def active(line):
"""Remove PlatformIO whole-line and inline comments."""
if line.lstrip().startswith((";", "#")):
return ""
comments = [index for index in (line.find(";"), line.find("#")) if index >= 0]
return line[: min(comments)] if comments else line
def configuration_paths(root):
root_config = root / "platformio.ini"
if not root_config.is_file():
raise FileNotFoundError(f"root PlatformIO configuration is missing: {root_config}")
variants = root / "variants"
return [root_config] + sorted(variants.rglob("platformio.ini") if variants.is_dir() else [])
def sections(lines):
"""Yield ``(name, header_line, lines)`` while preserving source locations."""
name, header_line, content = "<global>", 1, []
for number, line in enumerate(lines, 1):
match = SECTION_PATTERN.fullmatch(active(line).strip())
if match:
yield name, header_line, content
name, header_line, content = match.group(1), number, []
else:
content.append((number, line))
yield name, header_line, content
def expects_pin(config, root, section, lines):
if config == root / "platformio.ini":
return section == "env:native"
if not section.startswith("env:"):
return False
text = "\n".join(active(line) for _, line in lines)
return "WITH_MQTT_BRIDGE" in text or "elims/PsychicMqttClient" in text
def check(root):
"""Return (configs, declarations, required environments, error messages)."""
root = root.resolve()
configs, declarations, required, errors = configuration_paths(root), 0, 0, []
for config in configs:
relative = config.relative_to(root).as_posix()
for section, header_line, lines in sections(config.read_text(encoding="utf-8").splitlines()):
found = False
for number, raw_line in lines:
declaration = active(raw_line)
match = PACKAGE_PATTERN.search(declaration)
if not match:
continue
found, declarations = True, declarations + 1
version = declaration[match.end() :].strip()
if not version:
errors.append(f"{relative}:{number}: {PACKAGE} is unpinned; use '@ {VERSION}'")
elif not PIN_PATTERN.fullmatch(version):
errors.append(
f"{relative}:{number}: {PACKAGE} must be pinned exactly to {VERSION}; "
f"found '{version}'"
)
if expects_pin(config, root, section, lines):
required += 1
if not found:
errors.append(
f"{relative}:{header_line}: missing required {PACKAGE} @ {VERSION} "
f"in [{section}]"
)
return configs, declarations, required, errors
def self_test():
"""Exercise discovery and the valid, unpinned, wrong, and missing cases."""
with TemporaryDirectory() as directory:
root = Path(directory)
root_config = root / "platformio.ini"
variant = root / "variants" / "nested" / "mqtt" / "platformio.ini"
variant.parent.mkdir(parents=True)
root_config.write_text("[env:native]\nbblanchon/ArduinoJson\n", encoding="utf-8")
variant.write_text(
"[env:wrong]\n-D WITH_MQTT_BRIDGE=1\nbblanchon/ArduinoJson @ ^7.4.3\n"
"[env:missing]\nelims/PsychicMqttClient@^0.2.4\n",
encoding="utf-8",
)
configs, _, required, errors = check(root)
joined = "\n".join(errors)
if len(configs) != 2 or required != 3 or len(errors) != 3 or not all(
phrase in joined for phrase in ("unpinned", "must be pinned exactly", "missing required")
):
print("ArduinoJson pin checker self-test failed: invalid cases were not reported.", file=sys.stderr)
return 1
root_config.write_text("[env:native]\nbblanchon/ArduinoJson @ 7.4.3\n", encoding="utf-8")
variant.write_text(
"[env:one]\n-D WITH_MQTT_BRIDGE=1\nbblanchon/ArduinoJson @ 7.4.3\n"
"[env:two]\nelims/PsychicMqttClient@^0.2.4\nbblanchon/ArduinoJson@7.4.3\n",
encoding="utf-8",
)
if check(root)[3]:
print("ArduinoJson pin checker self-test failed: valid pins were rejected.", file=sys.stderr)
return 1
print("ArduinoJson pin checker self-test passed.")
return 0
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project-root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args(argv)
if args.self_test:
return self_test()
try:
configs, declarations, required, errors = check(args.project_root)
except FileNotFoundError as error:
print(f"ArduinoJson pin check could not run: {error}", file=sys.stderr)
return 2
if errors:
print("ArduinoJson pin check failed:", *[f" {error}" for error in errors], sep="\n", file=sys.stderr)
return 1
print(
f"ArduinoJson pin check passed: {declarations} declaration(s) across {len(configs)} "
f"root/variant configuration file(s); {required} required environment section(s) covered."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+143
View File
@@ -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()
+235
View File
@@ -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())
+88
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
}
+8 -6
View File
@@ -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_
}
}
}
}
}
+1
View File
@@ -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);
}
+32 -20
View File
@@ -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;
+12 -5
View File
@@ -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);
+1
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+200 -243
View File
@@ -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(&region_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);
+196 -86
View File
@@ -3,15 +3,18 @@
// only two small delegation hooks. These are CommonCLI member functions, so they
// retain full access to _prefs/_callbacks/_board/savePrefs() with no re-plumbing.
//
// Behavior is intentionally identical to the previously-inlined branches: MQTT
// commands keep their WITH_MQTT_BRIDGE guard; alert/SNMP commands remain unguarded.
// Each handler returns true if it recognized the command, false to fall through to
// the base get/set parser in CommonCLI.cpp.
// Behavior is intentionally identical to the previously-inlined branches. NOTE:
// the entire body of each set/get handler here is compiled under WITH_MQTT_BRIDGE
// (see the #ifdef at the top of each), so on an observer build without the bridge
// the WiFi/timezone/alert/SNMP commands compile out too — they are not guarded
// independently of the MQTT commands. Each handler returns true if it recognized
// the command, false to fall through to the base get/set parser in CommonCLI.cpp.
#include <Arduino.h>
#include "CommonCLI.h"
#include "TxtDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]()
#include "MQTTObserverValidation.h" // pure input validators (host-testable)
#include <Utils.h>
#ifdef ESP_PLATFORM
#include <WiFi.h>
@@ -20,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;
}
+331
View File
@@ -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
}
}
}
+68
View File
@@ -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);
};
+42
View File
@@ -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
+40
View File
@@ -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 {
+12
View File
@@ -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