diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..262a9ee4b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: meshcore-dev diff --git a/.github/actions/setup-build-environment/action.yml b/.github/actions/setup-build-environment/action.yml index 2ba7617e9..cba2d2e56 100644 --- a/.github/actions/setup-build-environment/action.yml +++ b/.github/actions/setup-build-environment/action.yml @@ -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 diff --git a/.github/workflows/build-companion-firmwares.yml b/.github/workflows/build-companion-firmwares.yml index 721076a17..8fd796f74 100644 --- a/.github/workflows/build-companion-firmwares.yml +++ b/.github/workflows/build-companion-firmwares.yml @@ -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/* \ No newline at end of file + build-companion-firmwares: + uses: ./.github/workflows/firmware-builder.yml + with: + firmware_type: 'companion' + release_title_prefix: 'Companion Firmware' diff --git a/.github/workflows/build-observer-firmwares-beta.yml b/.github/workflows/build-observer-firmwares-beta.yml new file mode 100644 index 000000000..43c2ee176 --- /dev/null +++ b/.github/workflows/build-observer-firmwares-beta.yml @@ -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 /.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. -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' '

⚠ DEV/BETA CHANNEL: pre-release firmware. A node flashed from this channel keeps receiving OTA updates from this channel until re-flashed by cable.

' + 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 + # " ", 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 diff --git a/.github/workflows/build-observer-firmwares.yml b/.github/workflows/build-observer-firmwares.yml index 804139b7b..68dd184ae 100644 --- a/.github/workflows/build-observer-firmwares.yml +++ b/.github/workflows/build-observer-firmwares.yml @@ -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 diff --git a/.github/workflows/build-repeater-firmwares.yml b/.github/workflows/build-repeater-firmwares.yml index f12bd8294..46b9076cc 100644 --- a/.github/workflows/build-repeater-firmwares.yml +++ b/.github/workflows/build-repeater-firmwares.yml @@ -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/* \ No newline at end of file + build-repeater-firmwares: + uses: ./.github/workflows/firmware-builder.yml + with: + firmware_type: 'repeater' + release_title_prefix: 'Repeater Firmware' diff --git a/.github/workflows/build-room-server-firmwares.yml b/.github/workflows/build-room-server-firmwares.yml index a488af6a1..c8bd19f7f 100644 --- a/.github/workflows/build-room-server-firmwares.yml +++ b/.github/workflows/build-room-server-firmwares.yml @@ -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/* \ No newline at end of file + build-room-server-firmwares: + uses: ./.github/workflows/firmware-builder.yml + with: + firmware_type: 'room-server' + release_title_prefix: 'Room Server Firmware' diff --git a/.github/workflows/firmware-builder.yml b/.github/workflows/firmware-builder.yml new file mode 100644 index 000000000..adc9e4248 --- /dev/null +++ b/.github/workflows/firmware-builder.yml @@ -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/* diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 5fd2734bf..b01ddc939 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -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 diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 67468b569..788390c57 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -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 diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index e3af3aafb..826c4cb37 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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. diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml new file mode 100644 index 000000000..ec1665875 --- /dev/null +++ b/.github/workflows/stale-bot.yml @@ -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 + \ No newline at end of file diff --git a/.github/workflows/sync-flasher-content.yml b/.github/workflows/sync-flasher-content.yml index 93ef1b34d..7c4c409d2 100644 --- a/.github/workflows/sync-flasher-content.yml +++ b/.github/workflows/sync-flasher-content.yml @@ -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 " 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 ("", "", ""): + 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 ("{", "}", "(", ")", " raw.count(token): + return "gained a %s" % token + return None diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py new file mode 100644 index 000000000..c38d804e3 --- /dev/null +++ b/scripts/webconfig_mock_server.py @@ -0,0 +1,1069 @@ +#!/usr/bin/env python3 +"""Local mock of the WebConfig portal backend, for iterating on webui/index.html +in a real browser with no firmware, no flashing, and no paid emulator account. + +It serves the real webui/index.html and implements the same /api/* contract as +src/helpers/esp32/WebConfigServer.cpp — including the 202+reqid handshake, the +pending -> done result polling, aggregate-success reboot gating, secret masking +(********), and the IATA / owner-key / length validation the firmware enforces. +So the browser drives the actual portal JS (wizard, save/poll/reqid, effective +value handling, reboot overlay, stats, scan) against realistic responses. + +/api/cli is the CLI terminal's backend and has no firmware counterpart yet: it +is the proposed contract (202 + reqid, streamed per-command results) executed +against a CommonCLI-shaped interpreter, so the terminal UI can be designed +against realistic single- and multi-line replies before any of it goes on-device. + +It does NOT run the C++ handlers (that's what test/ gtest covers) or the +AsyncTCP transport — it's a frontend + contract harness. + +Usage: + python3 scripts/webconfig_mock_server.py # LAN mode (login: password) + python3 scripts/webconfig_mock_server.py --setup # first-boot setup wizard + python3 scripts/webconfig_mock_server.py --port 9000 --active-slots 2 +Then open http://localhost:8080/ (or the chosen port). Editing index.html and +refreshing shows changes immediately — the page is re-read per request. + +Stdlib only; no pip install. +""" + +import argparse +import copy +import json +import os +import re +import secrets +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + +HERE = os.path.dirname(os.path.abspath(__file__)) +INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html") + +sys.path.insert(0, HERE) +# The build-time comment stripper, shared so --minify serves byte-for-byte what +# the generator embeds rather than a second implementation that could drift. +from webconfig_minify import strip_source # noqa: E402 + +MINIFY = False +# Overridable with --fw-version to exercise the console's channel labelling: +# v1.16.0.5-observer-a1b2c3d release +# v1.16.0.5-observer-beta-dev-a1b2c3d dev +# v1.16.0 local build, no OTA +FW_VERSION = "v1.16.0.5-observer-a1b2c3d" + +SENTINEL = "********" +ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build flag +BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling +SCAN_SECS = 0.8 + +# Destination buffer sizes (chars, minus the NUL) — mirrors the MQTTPrefs fields +# the firmware validates in CommonCLI_Observer.cpp. +LEN_LIMITS = { + "name": 31, "wifi.ssid": 31, "wifi.pwd": 63, "mqtt.origin": 31, + "mqtt.email": 63, "mqtt.ntp": 63, "timezone": 31, "snmp.community": 23, +} +# "filter" is absent on purpose: it is a bitmask, not a text buffer, so it has +# no destination-buffer limit. It is still bounded by the shared CLI command +# budget below, like every other key. +SLOT_LEN_LIMITS = {"server": 63, "username": 31, "password": 63, + "token": 47, "topic": 95, "audience": 63} + +# BatchEntry::cmd[160] in WebConfigServer.cpp holds "set " plus a +# NUL. Over-long values are rejected there rather than truncated, because a +# clipped value can still be valid and would persist as a different setting. +BATCH_CMD_SIZE = 160 + +# Preset names + what the UI must collect (mirrors handlePresets()). +PRESETS = ( + [(n, "none") for n in ( + "analyzer-us", "analyzer-eu", "nz-analyzer", "meshmapper", "waev", + "meshomatic", "cascadiamesh", "tennmesh", "nashmesh", "ctmesh", "chimesh", + "meshat.se", "eastidahomesh", "coloradomesh", "dutchmeshcore-1", + "dutchmeshcore-2", "meshcore-ca-1", "meshcore-ca-2", "meshcore-fi", + "bostonmesh", "rflab", "ipnt.uk", "flmesh", "corecomms")] + + [("meshrank", "token"), ("inwmesh", "userpass")] +) + +SCAN_NETWORKS = [ + {"ssid": "Wokwi-GUEST", "rssi": -42, "enc": False}, + {"ssid": "HomeNet", "rssi": -55, "enc": True}, + {"ssid": "HomeNet-5G", "rssi": -61, "enc": True}, + {"ssid": "Neighbor 2.4", "rssi": -78, "enc": True}, + {"ssid": "OpenGuest", "rssi": -83, "enc": False}, +] + + +def default_config(setup_mode): + return { + "radio": { + "freq": 910.525, "bw": 62.5, "sf": 7, "cr": 5, "tx": 22, "af": 1.0, + "rxdelay": 0.0, "txdelay": 0.5, "cad": False, "rxgain": True, + "repeat": True, "flood_max": 64, "flood_max_advert": 8, + "flood_max_unscoped": 8, "loop_detect": "moderate", + "name": "MockNode", "lat": 39.7392, "lon": -104.9903, + "advert_interval": 240, "flood_advert_interval": 6, + }, + "wifi": { + # setup mode = unconfigured (empty ssid -> wizard); LAN mode = joined + "ssid": "" if setup_mode else "HomeNet", + "pwd": "" if setup_mode else "secretpw", # stored raw; masked on GET + "powersave": "min", + }, + "mqtt": { + "origin": "" if setup_mode else "MockNode", "iata": "" if setup_mode else "DEN", + "status": True, "packets": True, "raw": False, "tx": "advert", "rx": True, + "interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7, + "ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False, + "snmp_community": "public", + "neighbors": False, "neighbors_interval": 24, + "slots": [_slot() for _ in range(6)], + }, + # Settings the CLI reaches but no portal form does, so they are absent + # from /api/config (see config_json) and live only here. Without them + # the terminal answers "unknown config key" for perfectly real commands. + "cli": { + "radio.watchdog": 0, "int.thresh": 0, "agc.reset.interval": 0, + "direct.txdelay": 0.0, "multi.acks": 0, "allow.read.only": False, + "path.hash.mode": 0, "owner.info": "", "guest.password": "", + "adc.multiplier": 1.0, + "alert": False, "alert.psk": "", "alert.hashtag": "", + "alert.region": "", "alert.interval": 15, + "alert.mqtt": False, "alert.wifi": False, + "bridge.enabled": False, "bridge.source": "rx", "bridge.baud": 115200, + "bridge.delay": 0, "bridge.channel": 0, "bridge.secret": "", + }, + } + + +def _slot(): + return {"preset": "none", "server": "", "port": 8883, "username": "", + "password": "", "token": "", "topic": "", "audience": "", + "filter": "all"} + + +class State: + def __init__(self, args): + self.lock = threading.Lock() + self.setup_mode = args.setup + self.active_slots = args.active_slots + self.cfg = default_config(args.setup) + # latched at AP start, like WebConfigServer::_initial_setup + self.initial_setup = args.setup and self.cfg["wifi"]["ssid"] == "" + self.start = time.time() + self.session = None # cookie token when logged in (LAN mode) + self.batch = {"state": "idle"} + self.cli = {"state": "idle"} # deferred CLI sequence, see /api/cli + self.admin_pwd_set = False # satisfies the initial-setup invariant + self.scan_started = None + + # ---- auth ------------------------------------------------------------- + def is_authed(self, headers): + if self.setup_mode: + return True # setup mode: proximity trust, no auth + if not self.session: + return False + cookie = headers.get("Cookie", "") + m = re.search(r"wcs=([0-9a-f]+)", cookie) + return bool(m and m.group(1) == self.session) + + # ---- config serialization (masks secrets, like handleConfigGet) ------- + def config_json(self): + c = copy.deepcopy(self.cfg) + c.pop("cli") # CLI-only settings: not part of this contract + c["wifi"]["pwd"] = SENTINEL if self.cfg["wifi"]["pwd"] else "" + for s in c["mqtt"]["slots"]: + s["password"] = SENTINEL if s["password"] else "" + s["token"] = SENTINEL if s["token"] else "" + return c + + def status_json(self, authed): + return { + "mode": "setup" if self.setup_mode else "lan", + "auth": authed, + "needs_setup": self.cfg["wifi"]["ssid"] == "", + "name": self.cfg["radio"]["name"], "node_id": "a1b2c3d4e5f60718", + # Shaped like build.sh's EMBEDDED_VERSION_STRING + # (base[.build][-observer][-channel]-hash) so the console's channel + # labelling is exercised against a real version, not "v1.x-mock". + "fw": FW_VERSION, "build_date": "6 Jun 2026", + "role": "Repeater", "board": "Heltec V3 (mock)", + "uptime_s": int(time.time() - self.start), + "runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots, + "max_cmds": CLI_MAX_CMDS, + } + + +# --------------------------------------------------------------------------- +# set-command application + validation (mirrors the firmware's setters enough +# to produce realistic per-field OK / Error replies for the UI chips). +# --------------------------------------------------------------------------- +BOOL_KEYS = {"cad": ("radio", "cad"), "radio.rxgain": ("radio", "rxgain"), + "repeat": ("radio", "repeat"), "mqtt.status": ("mqtt", "status"), + "mqtt.packets": ("mqtt", "packets"), "mqtt.raw": ("mqtt", "raw"), + "mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp"), + "mqtt.neighbors": ("mqtt", "neighbors")} +INT_KEYS = {"tx": ("radio", "tx"), "flood.max": ("radio", "flood_max"), + "flood.max.advert": ("radio", "flood_max_advert"), + "flood.max.unscoped": ("radio", "flood_max_unscoped"), + "advert.interval": ("radio", "advert_interval"), + "flood.advert.interval": ("radio", "flood_advert_interval"), + "mqtt.interval": ("mqtt", "interval"), + "mqtt.neighbors.interval": ("mqtt", "neighbors_interval"), + "timezone.offset": ("mqtt", "timezone_offset")} +FLOAT_KEYS = {"lat": ("radio", "lat"), "lon": ("radio", "lon"), + "af": ("radio", "af"), "rxdelay": ("radio", "rxdelay"), + "txdelay": ("radio", "txdelay")} +STR_KEYS = {"name": ("radio", "name"), "wifi.ssid": ("wifi", "ssid"), + "wifi.powersave": ("wifi", "powersave"), "loop.detect": ("radio", "loop_detect"), + "mqtt.origin": ("mqtt", "origin"), "mqtt.ntp": ("mqtt", "ntp"), + "mqtt.email": ("mqtt", "email"), "timezone": ("mqtt", "timezone"), + "snmp.community": ("mqtt", "snmp_community"), "mqtt.tx": ("mqtt", "tx")} +SECRET_STR_KEYS = {"wifi.pwd": ("wifi", "pwd")} + +# The CLI-only settings, typed the same way so apply_set/cli_read_key reach them +# through the existing lookups rather than a parallel code path. +for _k, _v in default_config(False)["cli"].items(): + _t = {bool: BOOL_KEYS, int: INT_KEYS, float: FLOAT_KEYS, str: STR_KEYS}[type(_v)] + _t[_k] = ("cli", _k) +SECRET_STR_KEYS.update({k: ("cli", k) for k in + ("guest.password", "alert.psk", "bridge.secret")}) +for _k in SECRET_STR_KEYS: + STR_KEYS.pop(_k, None) + + +def _hex64(v): + return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v) + + +def apply_set(cfg, key, val): + """Return (ok, reply) and mutate cfg. Mirrors the firmware's validation for + the fields where it matters (length, IATA, owner key, port, radio combo).""" + # length guard for the plain string fields + if key in LEN_LIMITS and len(val) > LEN_LIMITS[key]: + return False, "Error: %s too long (max %d chars)" % (key, LEN_LIMITS[key]) + + if key == "password": + # Stored outside cfg: it must never appear in the /api/config GET. The + # firmware overwrites the CLI's "password now: " echo, so the + # reply carries no secret either. + global ADMIN_PASSWORD + ADMIN_PASSWORD = val + return True, "OK" + + if key == "radio.fem.rxgain": + return False, "Error: unsupported" # no FEM on the mock board, see GETTERS + + if key == "dutycycle": + try: + dc = float(val) + except ValueError: + return False, "Error: expected a number" + if not 0 < dc <= 100: + return False, "Error, must be 1-100" + cfg["radio"]["af"] = 100.0 / dc - 1 # the CLI stores it as airtime_factor + return True, "OK" + + if key in ("freq", "bw", "sf", "cr"): + # single-component radio setters, reachable from the CLI but not from + # the form batch (which always sends the whole `radio` combo) + try: + cfg["radio"][key] = int(val) if key in ("sf", "cr") else float(val) + except ValueError: + return False, "Error: expected a number" + return True, "OK - reboot to apply" + + if key == "radio": + try: + f, bw, sf, cr = val.split(",") + f, bw, sf, cr = float(f), float(bw), int(sf), int(cr) + except ValueError: + return False, "Error, invalid radio params" + if not (150 <= f <= 2500 and 7 <= bw <= 500 and 5 <= sf <= 12 and 5 <= cr <= 8): + return False, "Error, invalid radio params" + cfg["radio"].update(freq=f, bw=bw, sf=sf, cr=cr) + return True, "OK - reboot to apply" + + if key == "mqtt.iata": + if val == "": + cfg["mqtt"]["iata"] = "" + return True, "OK - IATA cleared" + if len(val) != 3 or not val.isalnum() or not val.isascii(): + return False, "Error: IATA code must be exactly 3 letters/digits (e.g. DEN)" + cfg["mqtt"]["iata"] = val.upper() + return True, "OK" + + if key == "prv.key": + # write-only by design: the identity goes in, nothing reads it back + if not _hex64(val): + return False, "Error: private key must be 64 hex characters" + return True, "OK - identity restored, reboot to apply" + + if key == "mqtt.owner": + if val == "": + cfg["mqtt"]["owner"] = "" + return True, "OK - owner key cleared" + if not _hex64(val): + return False, "Error: public key must be 64 hex characters (32 bytes)" + cfg["mqtt"]["owner"] = val + return True, "OK" + + m = re.match(r"^mqtt([1-6])\.(\w+)$", key) + if m: + return apply_slot_set(cfg, int(m.group(1)) - 1, m.group(2), val) + + if key in BOOL_KEYS: + sec, f = BOOL_KEYS[key] + cfg[sec][f] = (val == "on") + return True, "OK" + if key in INT_KEYS: + sec, f = INT_KEYS[key] + try: + cfg[sec][f] = int(val) + except ValueError: + return False, "Error: expected a number" + return True, "OK" + if key in FLOAT_KEYS: + sec, f = FLOAT_KEYS[key] + try: + cfg[sec][f] = float(val) + except ValueError: + return False, "Error: expected a number" + return True, "OK" + if key in SECRET_STR_KEYS: + sec, f = SECRET_STR_KEYS[key] + cfg[sec][f] = val + return True, "OK" + if key in STR_KEYS: + sec, f = STR_KEYS[key] + cfg[sec][f] = val + return True, "OK" + # Strict fallthrough: this function is the single authority on what can be + # set, for the batch and the CLI alike. Accepting unknown keys here once hid + # the fact that the CLI could not reach `dutycycle` or `radio.fem.rxgain`. + # Verbatim shape from CommonCLI::handleSetCmd's fallthrough. + return False, "unknown config: %s" % key + + +# Payload-type names accepted alongside the decimal form. Mirrors +# namedPacketTypes() in src/helpers/MQTTPacketFilter.h; 12-14 are reserved +# upstream and stay reachable by number only. +PACKET_TYPE_NAMES = { + "req": 0, "response": 1, "txt_msg": 2, "ack": 3, "advert": 4, + "grp_txt": 5, "grp_data": 6, "anon_req": 7, "path": 8, "trace": 9, + "multipart": 10, "control": 11, "raw_custom": 15, +} + + +def packet_filter_mask(text): + """Canonical filter text -> bitmask, for the stats payload.""" + if text == "all": + return 0xFFFF + if text == "none": + return 0 + mask = 0 + for token in text.split(","): + mask |= 1 << int(token) + return mask + + +def canonical_packet_filter(val): + """Mirror of MQTTPacketFilter::parse + ::format. Returns None if invalid.""" + stripped = val.strip() + if stripped == "" or stripped == "all": + return "all" + if stripped == "none": + return "none" + mask = 0 + for part in stripped.split(","): + token = part.strip() + if re.fullmatch(r"[0-9]+", token): + packet_type = int(token) + if packet_type > 15: + return None + elif token in PACKET_TYPE_NAMES: + packet_type = PACKET_TYPE_NAMES[token] + else: + return None + mask |= 1 << packet_type + if mask == 0xFFFF: + return "all" + return ",".join(str(i) for i in range(16) if mask & (1 << i)) + + +def apply_slot_set(cfg, idx, field, val): + slot = cfg["mqtt"]["slots"][idx] + if field in SLOT_LEN_LIMITS and len(val) > SLOT_LEN_LIMITS[field]: + return False, "Error: %s too long (max %d chars)" % (field, SLOT_LEN_LIMITS[field]) + if field == "port": + try: + p = int(val) + except ValueError: + return False, "Error: port must be between 1 and 65535" + if not (1 <= p <= 65535): + return False, "Error: port must be between 1 and 65535" + slot["port"] = p + return True, "OK" + if field == "filter": + canonical = canonical_packet_filter(val) + if canonical is None: + return False, ("Error: filter must be all, none, or a CSV of " + "types 0-15 / names (advert,txt_msg,...)") + slot["filter"] = canonical + return True, "OK - slot %d packet types: %s" % (idx + 1, canonical) + if field in ("preset", "server", "username", "password", "token", "topic", "audience"): + slot[field] = val + if field == "token": + return True, "OK - slot %d token set" % (idx + 1) + return True, "OK" + return False, "Error: unknown slot field" + + +def is_secret_key(key): + # The serial console prints these back; the portal is reachable over the + # LAN, so it masks them in `get` replies the way /api/config already does. + return key in SECRET_STR_KEYS or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key)) + + +# --------------------------------------------------------------------------- +# CLI command execution (backs /api/cli), mirroring CommonCLI enough to give +# the terminal UI realistic single- and multi-line replies. +# +# The portal's `set` batch is allowlisted (WebConfigKeys.h) because it is driven +# by form fields; the CLI is deliberately NOT, since its whole point is reaching +# the same surface the serial console reaches. Auth is the boundary — exactly +# as it is for the serial console and for remote admin over the mesh. +# --------------------------------------------------------------------------- +# MAX_BATCH in WebConfigServer.h: the CLI shares the config batch's fixed slot, +# so this is the real cap, reported to the page as status.max_cmds. +CLI_MAX_CMDS = 24 +CLI_RESULT_PAGE = 8 # WebConfigBatch::kCliResultPage +CLI_CMD_SECS = 0.25 # simulated per-command execution time +# Board::reboot() does not return, so the firmware answers `reboot` itself and +# arms the deferred reboot once results have been read (see wcIsDeferredReboot). +CLI_DEFERRED_REBOOT = "reboot" # matched as a PREFIX, like CommonCLI does + +# Commands the CLI reaches but the portal cannot honestly serve; rejected at +# POST. Mirrors wcCliUnavailable() in WebConfigServer.cpp. +CLI_UNAVAILABLE = [ + ("start ota", True, "start ota needs port 80, which this portal is using. " + "Run it from the serial console, or use `ota update`."), + ("clock sync", True, "clock sync takes its time from the caller, which a web request " + "has no way to supply. Use `time ` instead."), + ("log", False, "log writes the packet log to the serial console, not here, and " + "blocks the radio while it does. Use `log start` / `log stop`."), + ("get acl", False, "get acl writes to the serial console, not here."), +] + + +def cli_unavailable(cmd): + for token, is_prefix, why in CLI_UNAVAILABLE: + if cmd.startswith(token) if is_prefix else cmd == token: + return why + return None + + +# Failure replies CommonCLI emits that do NOT start with "Err" — the shapes that +# made a naive prefix test call them success. Mirrors +# WebConfigBatch::cliReplyIsFailure. +def cli_reads_secret(cmd): + """Commands that READ a secret. CommonCLI gates these on the caller being + the serial console; the portal is not, so the value is masked here the way + CommonCLI masks it for remote callers. Mirrors wcCliReadsSecret().""" + if not cmd.startswith("get "): + return False + key = cmd[4:].strip() + return key in ("prv.key", "guest.password", "alert.psk", "bridge.secret") \ + or is_secret_key(key) + + +def cli_reply_is_failure(reply): + if not reply: + return False + if reply.startswith(("Err", "ERR", "err", "(ERR", "Unknown command", + "unknown config", "??", "Can't find")): + return True + return ": Err" in reply + +# Commands the device answers but that have no config-key equivalent. +GETTERS = { + "freq": lambda c: "%.3f" % c["radio"]["freq"], + "bw": lambda c: "%.2f" % c["radio"]["bw"], + "sf": lambda c: str(c["radio"]["sf"]), + "cr": lambda c: str(c["radio"]["cr"]), + "public.key": lambda c: "a1b2c3d4" * 8, + "wifi.status": lambda c: ( + "SSID: %s\nIP: 192.168.1.42\nRSSI: -58 dBm\nUptime: %dm" + % (c["wifi"]["ssid"] or "(not set)", int(time.time() - ST.start) // 60)), + "mqtt.status": lambda c: cli_mqtt_status(c), + "mqtt.presets": lambda c: "\n".join( + "%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd) + for i, (n, nd) in enumerate(PRESETS)), + "role": lambda c: "Repeater", + "acl": lambda c: "a1b2c3d4e5f60718 perms 3\n1122334455667788 perms 1", + # not its own pref: the CLI derives it from airtime_factor both ways + "dutycycle": lambda c: "%.1f" % (100.0 / (c["radio"]["af"] + 1)), + "mqtt.config.valid": lambda c: ( + "yes" if any(s["preset"] != "none" for s in c["mqtt"]["slots"]) else "no - no slot configured"), + "mqtt.ntp.diag": lambda c: "last sync: 42s ago via %s (offset +0.011s)" % (c["mqtt"]["ntp"] or "none"), + "mqtt.stats": lambda c: ("published: %d\ndropped: 0\nqueue: 0/24\nreconnects: 1" + % (100 + int(time.time() - ST.start))), + # Runtime-gated on the real device (Board::canControlLoRaFemLna), not + # compiled out — the command exists everywhere and the board answers for + # itself. The mock board is a Heltec V3, which has no FEM. + "radio.fem.rxgain": lambda c: None, +} + + +def cli_mqtt_status(cfg): + lines = [] + for i, s in enumerate(cfg["mqtt"]["slots"][:ST.active_slots]): + if s["preset"] == "none": + lines.append("slot %d: unconfigured" % (i + 1)) + else: + lines.append("slot %d: %-16s connected tx=%d err=0" + % (i + 1, s["preset"], 100 + int(time.time() - ST.start))) + return "\n".join(lines) + + +def cli_get(cfg, key): + """Reply to `get `. + + CommonCLI::handleGetCmd answers `> value` — the marker sets the value apart + on the serial console. Reproduced here because it is load-bearing: a reply + that starts with "> " does not start with "OK", which is what made the + firmware's first cut mark every getter a failure. + """ + ok, val = _cli_get_value(cfg, key) + return (ok, "> " + val) if ok else (ok, val) + + +def _cli_get_value(cfg, key): + if key in GETTERS: + val = GETTERS[key](cfg) + return (True, val) if val is not None else (False, "Error: unsupported") + if is_secret_key(key): + # The serial console prints these; the portal is reachable over the LAN, + # so it masks them the same way /api/config does. + return True, SENTINEL if cli_read_key(cfg, key) else "(not set)" + val = cli_read_key(cfg, key) + if val is None: + return False, "??: %s" % key # CommonCLI::handleGetCmd fallthrough + return True, str(val) + + +def cli_read_key(cfg, key): + """Current value of a `set` key, or None when the key is unknown.""" + m = re.match(r"^mqtt([1-6])\.(\w+)$", key) + if m: + slot = cfg["mqtt"]["slots"][int(m.group(1)) - 1] + return slot.get(m.group(2)) + for table in (BOOL_KEYS, INT_KEYS, FLOAT_KEYS, STR_KEYS, SECRET_STR_KEYS): + if key in table: + sec, f = table[key] + v = cfg[sec][f] + return ("on" if v else "off") if key in BOOL_KEYS else v + # keys apply_set() special-cases, so they appear in none of the tables above + r = cfg["radio"] + return { + "name": r["name"], "lat": r["lat"], "lon": r["lon"], + "radio": "%.3f,%.2f,%d,%d" % (r["freq"], r["bw"], r["sf"], r["cr"]), + "bw": r["bw"], "sf": r["sf"], "cr": r["cr"], + "mqtt.iata": cfg["mqtt"]["iata"], "mqtt.owner": cfg["mqtt"]["owner"], + }.get(key) + + +def run_cli(cfg, line): + """Execute one command line. Returns (ok, reply); reply may be multi-line.""" + cmd = line.strip() + if cmd == "": + return True, "" + if cmd == "ver": + # Same source as /api/status's fw on the device: both are + # FIRMWARE_VERSION, so they must not disagree here either. + return True, "%s (Build: 6 Jun 2026)" % FW_VERSION + if cmd == "board": + return True, "Heltec V3 (mock)" + if cmd == "clock": + return True, time.strftime("%d/%m/%Y %H:%M:%S", time.gmtime()) + " UTC" + if cmd == "advert": + return True, "OK - Advert sent (zero hop)" + if cmd == "advert.zerohop": + return True, "OK - Advert sent (zero hop)" + if cmd in ("reboot", "clkreboot"): + return True, "OK - rebooting" + if cmd in ("poweroff", "shutdown"): + return True, "OK - powering off" + if cmd == "erase": + return True, "File system erase: OK" + if cmd == "memory": + return True, ("heap free: 142000\nheap min: 118000\n" + "largest block: 96000\npsram free: 3980000") + if cmd == "neighbors": + return True, ("d4e5f60718 -71 dBm snr 9.5 2m ago\n" + "1122334455 -94 dBm snr 2.0 14m ago") + # Handled by MyMesh::handleCommand before it delegates to CommonCLI. + if cmd == "discover.neighbors": + return True, "OK - Discover sent" + if cmd == "discover.scopes": + return True, "OK - scopes queued (18s discovery remaining)" + if cmd.startswith("setperm "): + parts = cmd[8:].split() + if len(parts) != 2 or not _hex64(parts[0]): + return False, "Err - bad params" + return True, "OK" + if cmd.startswith("clock sync"): + # Rejected at POST, but modelled anyway: over the web the caller's + # timestamp is 0, so CommonCLI always takes this branch. + return False, "(ERR: clock cannot go backwards)" + if cmd == "region": + return True, "US915" + if cmd == "sensor list": + return True, "0: battery (mV)\n1: temperature (C)\n2: humidity (%)" + if cmd.startswith("sensor get "): + return True, "> 22.4" + if cmd.startswith("sensor set "): + return True, "OK" + if cmd.startswith("gps advert "): + mode = cmd[11:] + if mode not in ("none", "share", "prefs"): + return False, "Error, must be none, share or prefs" + return True, "OK - advert position: %s" % mode + if cmd in ("gps on", "gps off"): + return True, "OK - GPS %s" % cmd[4:] + if cmd == "gps sync": + return True, "OK - clock and location set from GPS" + if cmd == "gps setloc": + return True, "OK - lat/lon set from the current fix" + if cmd == "gps": + return True, "GPS: no fix (0 satellites)" + if cmd in ("powersaving on", "powersaving off"): + return True, "OK - power saving %s" % cmd[12:] + if cmd == "powersaving": + return True, "off" + if cmd.startswith("alert test"): + if not ST.cfg["cli"]["alert.psk"]: + return False, "Error: alert channel not configured (set alert.psk or set alert.hashtag)" + return True, "OK - test alert sent" + if cmd.startswith("ota "): + return True, ("v1.7.2 available (current v1.7.1-mock)" if cmd == "ota check" + else "OK - downloading v1.7.2, will reboot when flashed") + if cmd.startswith("start webconfig"): + return True, "OK - already running (you are using it)" + if cmd == "stop webconfig": + return True, "OK - portal stopping" + if cmd == "start ota": + return True, "OK - upload AP raised at 192.168.4.1" + if cmd.startswith("neighbor.remove "): + return (True, "OK") if _hex64(cmd[16:]) else (False, "ERR: bad pubkey") + if cmd.startswith("tempradio "): + return True, "OK - temporary radio params applied (not saved)" + if cmd == "clear stats": + return True, "OK - stats cleared" + if cmd.startswith("stats-"): + return True, "recv=512 sent=88 rx_err=3 airtime=41s" + if cmd == "log": + return True, "packet log: 128 entries, 14 KB" + if cmd.startswith("log "): + return True, "OK" + if cmd.startswith("password "): + global ADMIN_PASSWORD + ADMIN_PASSWORD = cmd[9:] + return True, "OK - password changed" + if cmd.startswith("time "): + return True, "OK - clock set" + if cmd.startswith("get "): + return cli_get(cfg, cmd[4:].strip()) + if cmd.startswith("set "): + rest = cmd[4:].strip() + key, _, val = rest.partition(" ") + if not key: + return False, "Error: set what?" + # apply_set owns the "is this settable" decision; gating on whether the + # key is *readable* rejected write-only and computed ones (`dutycycle`, + # `prv.key`, `radio.fem.rxgain`). + return apply_set(cfg, key, val.strip()) + return False, "Unknown command" + + +def valid_reqid(reqid): + return isinstance(reqid, str) and bool(re.fullmatch(r"[0-9A-Fa-f]{16}", reqid)) + + +# --------------------------------------------------------------------------- +# HTTP handler +# --------------------------------------------------------------------------- +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): # concise one-line log + print(" %s %s" % (self.command, self.path)) + + # -- helpers -- + def _json(self, code, obj, extra_headers=None): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + for k, v in (extra_headers or {}): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def _read_body(self): + n = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(n) if n else b"" + + def _need_auth(self): + if not ST.is_authed(self.headers): + self._json(401, {"error": "auth"}) + return True + return False + + # -- GET -- + def do_GET(self): + path = self.path.split("?", 1)[0] + if path == "/": + return self._serve_index() + if path == "/api/status": + return self._json(200, ST.status_json(ST.is_authed(self.headers))) + if path == "/api/presets": + return self._json(200, {"presets": [{"name": n, "needs": nd} for n, nd in PRESETS]}) + if path == "/api/config": + if self._need_auth(): + return + with ST.lock: + return self._json(200, ST.config_json()) + if path == "/api/config/result": + if self._need_auth(): + return + return self._config_result() + if path == "/api/cli/result": + if self._need_auth(): + return + return self._cli_result() + if path == "/api/stats": + if self._need_auth(): + return + return self._json(200, self._stats()) + if path == "/api/scan": + if self._need_auth(): + return + return self._scan() + return self._json(404, {"error": "not found"}) + + # -- POST -- + def do_POST(self): + path = self.path.split("?", 1)[0] + if path == "/api/login": + return self._login() + if path == "/api/logout": + ST.session = None + return self._json(200, {"ok": True}, [("Set-Cookie", "wcs=; Max-Age=0; Path=/")]) + if path == "/api/config": + if self._need_auth(): + return + return self._config_post() + if path == "/api/cli": + if self._need_auth(): + return + return self._cli_post() + if path == "/api/reboot": + if self._need_auth(): + return + return self._json(200, {"ok": True}) + if path == "/api/portal/exit": + return self._json(200, {"ok": True, "url": "http://localhost:%d/" % PORT}) + return self._json(404, {"error": "not found"}) + + # -- endpoint impls -- + def _serve_index(self): + try: + with open(INDEX_HTML, "rb") as f: # re-read each time -> live edits + html = f.read() + except OSError: + self.send_error(500, "webui/index.html not found") + return + if MINIFY: + # Serve what the device actually serves. The generator strips + # comments and indentation before compressing, so --minify is how + # you exercise those bytes in a browser rather than trusting that + # stripping a 100 KB page never changes its behaviour. + html = strip_source(html.decode("utf-8")).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(html))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(html) + + def _login(self): + if ST.setup_mode: + return self._json(200, {"ok": True}) + try: + body = json.loads(self._read_body() or b"{}") + except ValueError: + return self._json(400, {"error": "bad request"}) + if body.get("password") != ADMIN_PASSWORD: + return self._json(401, {"error": "wrong password"}) + ST.session = secrets.token_hex(16) + return self._json(200, {"ok": True}, + [("Set-Cookie", "wcs=%s; HttpOnly; SameSite=Lax; Path=/" % ST.session)]) + + def _config_post(self): + raw = self._read_body() + if len(raw) > 4096: + return self._json(413, {"error": "body too large"}) + try: + body = json.loads(raw or b"{}") + except ValueError: + return self._json(400, {"error": "bad json"}) + reqid = body.get("reqid", "") + if not valid_reqid(reqid): + return self._json(400, {"error": "bad reqid"}) + reboot = bool(body.get("reboot", False)) + setmap = body.get("set", {}) or {} + + # `password` maps to the top-level CLI command rather than a setter. It + # is accepted in both modes (LAN already required a login), but first + # onboarding cannot finish without it. + if "password" in setmap: + pwd = str(setmap["password"]) + if not 0 < len(pwd) <= 15 or "\r" in pwd or "\n" in pwd: + return self._json(400, {"error": "admin password must be 1-15 characters with no line breaks"}) + elif ST.setup_mode and ST.initial_setup and (reboot or "wifi.ssid" in setmap): + return self._json(400, {"error": "admin password required for initial setup"}) + + with ST.lock: + if ST.batch.get("state") != "idle" and ST.batch.get("reqid") == reqid: + return self._json(202, { + "state": ST.batch["state"], "count": len(ST.batch.get("results", [])), + "reqid": reqid, + }) + if ST.batch.get("state") == "pending": + return self._json(409, {"error": "busy", "reqid": ST.batch.get("reqid", "")}) + # drop unchanged secrets (sentinel), like the firmware does + entries = [(k, v) for k, v in setmap.items() + if not (is_secret_key(k) and v == SENTINEL)] + # Same command-budget rejection the firmware applies while building + # BatchEntry::cmd; CR/LF are stripped there and don't count. + for k, v in entries: + prefix = "password " if k == "password" else "set %s " % k + stripped = str(v).replace("\r", "").replace("\n", "") + if len(prefix) + len(stripped) > BATCH_CMD_SIZE - 1: + return self._json(400, {"error": "value too long", "key": k[:32]}) + if not entries and not reboot: + return self._json(400, {"error": "no changes"}) + # apply now, but expose as pending->done to exercise polling + results, all_ok = [], True + for k, v in entries: + ok, reply = apply_set(ST.cfg, k, str(v)) + if not ok: + all_ok = False + results.append({"key": k, "reply": reply}) + ST.batch = {"state": "pending", "reqid": reqid, "results": results, + "all_ok": all_ok, "reboot": reboot, + "done_at": time.time() + BATCH_PENDING_SECS} + return self._json(202, {"state": "pending", "count": len(entries), "reqid": reqid}) + + def _config_result(self): + query = parse_qs(urlsplit(self.path).query) + reqid = query.get("reqid", [""])[0] + if not valid_reqid(reqid): + return self._json(400, {"error": "bad reqid"}) + with ST.lock: + b = ST.batch + if b.get("state") == "idle": + return self._json(200, {"state": "idle", "reqid": reqid}) + if b.get("reqid") != reqid: + return self._json(404, {"error": "unknown request"}) + if b["state"] == "pending" and time.time() < b["done_at"]: + return self._json(200, {"state": "pending", "reqid": b["reqid"]}) + b["state"] = "done" # stays readable until next POST + return self._json(200, { + "state": "done", "reqid": b["reqid"], "all_ok": b["all_ok"], + "reboot": b["reboot"] and b["all_ok"], "results": b["results"], + }) + + # ---- CLI --------------------------------------------------------------- + # Same 202 + reqid + poll shape as /api/config, for the same reason: the + # commands have to run on the main loop, not the web server's task. The + # difference is that results stream -- a pasted sequence fills the terminal + # command by command instead of appearing all at once at the end. + def _cli_post(self): + raw = self._read_body() + if len(raw) > 8192: + return self._json(413, {"error": "body too large"}) + try: + body = json.loads(raw or b"{}") + except ValueError: + return self._json(400, {"error": "bad json"}) + reqid = body.get("reqid", "") + if not valid_reqid(reqid): + return self._json(400, {"error": "bad reqid"}) + cmds = body.get("cmds") + if not isinstance(cmds, list) or not cmds: + return self._json(400, {"error": "no commands"}) + if len(cmds) > CLI_MAX_CMDS: + return self._json(413, {"error": "too many commands", "max": CLI_MAX_CMDS}) + cmds = [str(c).replace("\r", "").replace("\n", "").strip() for c in cmds] + cmds = [c for c in cmds if c] + if not cmds: + return self._json(400, {"error": "no commands"}) + for c in cmds: + if len(c) > BATCH_CMD_SIZE - 1: + return self._json(400, {"error": "command too long"}) + why = cli_unavailable(c) + if why: + return self._json(400, {"error": why}) + # Same invariant handleConfigPost enforces (see wcCliUnavailable's + # neighbour in WebConfigServer.cpp): first onboarding is committed by the + # reboot, and must not commit the factory password onto someone's LAN. + if (ST.setup_mode and ST.initial_setup and not ST.admin_pwd_set + and not any(c.startswith("password ") for c in cmds) + and (any(c.startswith(CLI_DEFERRED_REBOOT) for c in cmds) + or any(c.startswith("set wifi.ssid ") for c in cmds))): + return self._json(400, {"error": "admin password required for initial setup — " + "run `password ` first"}) + + with ST.lock: + self._cli_advance(ST.cli) + if ST.cli.get("state") != "idle" and ST.cli.get("reqid") == reqid: + return self._json(202, {"state": ST.cli["state"], "reqid": reqid, + "total": len(ST.cli["cmds"])}) + if ST.cli.get("state") == "running": + return self._json(409, {"error": "busy", "reqid": ST.cli.get("reqid", "")}) + ST.cli = {"state": "running", "reqid": reqid, "cmds": cmds, "results": [], + "all_ok": True, + "reboot": any(c.startswith(CLI_DEFERRED_REBOOT) for c in cmds), + "next_at": time.time() + CLI_CMD_SECS} + return self._json(202, {"state": "running", "reqid": reqid, "total": len(cmds)}) + + @staticmethod + def _cli_advance(job): + """Run whichever queued commands are now due. Execution belongs to the + node's loop, not to the client's polling — a client that walks away must + not leave the executor claimed forever.""" + now = time.time() + while (job.get("state") == "running" and len(job["results"]) < len(job["cmds"]) + and now >= job["next_at"]): + cmd = job["cmds"][len(job["results"])] + if cmd.startswith(CLI_DEFERRED_REBOOT): + reply = "OK - reboot queued" + else: + _, reply = run_cli(ST.cfg, cmd) + if cmd.startswith("password "): + reply = "OK" # never echo the new password back + ST.admin_pwd_set = True + elif cli_reads_secret(cmd): + val = reply[2:] if reply.startswith("> ") else reply + reply = ("> (not set)" if val in ("", "(not set)") + else "> ******** (serial only)") + ok = not cli_reply_is_failure(reply) + # Only writes gate the reboot, and only on the "OK" convention every + # setter keeps (WebConfigBatch::cliReplyGatesReboot). + if cmd.startswith(("set ", "password ")): + job["all_ok"] = job.get("all_ok", True) and reply.startswith("OK") + # The command is NOT echoed: it may carry a password or token, and + # the client matches results to its own sequence by index. + job["results"].append({"ok": ok, "reply": reply}) + job["next_at"] = now + CLI_CMD_SECS + if job.get("state") == "running" and len(job["results"]) == len(job["cmds"]): + job["state"] = "done" # stays readable until the next POST + + def _cli_result(self): + query = parse_qs(urlsplit(self.path).query) + reqid = query.get("reqid", [""])[0] + if not valid_reqid(reqid): + return self._json(400, {"error": "bad reqid"}) + # `from` lets the client ask only for results it has not rendered yet, + # so a long sequence isn't re-sent on every poll. + try: + frm = max(0, int(query.get("from", ["0"])[0])) + except ValueError: + frm = 0 + with ST.lock: + j = ST.cli + if j.get("state") == "idle": + return self._json(200, {"state": "idle", "reqid": reqid}) + if j.get("reqid") != reqid: + return self._json(404, {"error": "unknown request"}) + self._cli_advance(j) # one command per CLI_CMD_SECS + # Results stream, capped per read so the device's JSON document + # stays small; a longer sequence pages across reads. "done" means + # the client has been handed everything, not just that execution + # finished — a client that stops polling at "done" must lose nothing. + page = j["results"][frm:frm + CLI_RESULT_PAGE] + final = j["state"] == "done" and frm + len(page) >= len(j["cmds"]) + body = {"state": "done" if final else "running", "reqid": reqid, + "total": len(j["cmds"]), "from": frm, "results": page} + if final: + body["all_ok"] = j["all_ok"] + body["reboot"] = j["reboot"] and j["all_ok"] + if j["reboot"] and not j["all_ok"]: + body["reboot_withheld"] = True + return self._json(200, body) + + def _scan(self): + rescan = "rescan=1" in self.path + now = time.time() + if rescan or ST.scan_started is None: + ST.scan_started = now + return self._json(200, {"state": "scanning"}) + if now - ST.scan_started < SCAN_SECS: + return self._json(200, {"state": "scanning"}) + return self._json(200, {"state": "done", "networks": SCAN_NETWORKS}) + + def _stats(self): + up = int(time.time() - ST.start) + slots = [] + for i, s in enumerate(ST.cfg["mqtt"]["slots"]): + if s["preset"] == "none": + continue + row = {"n": i + 1, "name": s["preset"], "state": "ok", + "ok": 100 + up, "err": 0} + # Mirrors buildStatsJson(): "filt" carries the raw mask and is + # omitted entirely for the all-types default. + mask = packet_filter_mask(s.get("filter", "all")) + if mask != 0xFFFF: + row["filt"] = mask + slots.append(row) + return { + "uptime_s": up, "batt_mv": 4020, "heap_free": 142000, "heap_min": 118000, + "heap_max_alloc": 96000, "noise": -98, "rssi": -71, "snr": 9.5, + "airtime_s": up // 20, "rx_airtime_s": up // 8, "recv": 512 + up, + "sent": 88 + up // 3, "rx_err": 3, "sent_flood": 40, "sent_direct": 48, + "recv_flood": 300, "recv_direct": 212, "tx_queue": 0, "mqtt_queue": 0, + "wifi_rssi": -58, "ip": "192.168.1.42", "slots": slots, + } + + +def main(): + global ST, PORT, MINIFY, FW_VERSION + ap = argparse.ArgumentParser(description="Mock WebConfig portal backend") + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--setup", action="store_true", help="first-boot setup wizard mode") + ap.add_argument("--active-slots", type=int, default=5, help="server slots to expose (2 or 5)") + ap.add_argument("--fw-version", default=FW_VERSION, + help="version string to report, shaped like build.sh's embedded one") + ap.add_argument("--minify", action="store_true", + help="serve the comment-stripped page the device ships, not the source") + args = ap.parse_args() + ST, PORT, MINIFY = State(args), args.port, args.minify + FW_VERSION = args.fw_version + + srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + mode = "SETUP (wizard)" if args.setup else "LAN (login: %s)" % ADMIN_PASSWORD + print("WebConfig mock backend — %s%s" % (mode, " [minified]" if MINIFY else "")) + print(" open http://localhost:%d/ (Ctrl-C to stop)" % args.port) + try: + srv.serve_forever() + except KeyboardInterrupt: + print("\nstopped") + + +if __name__ == "__main__": + main() diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 67f706c3a..7edb7c7fe 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -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; diff --git a/src/Identity.cpp b/src/Identity.cpp index ea546274d..25419fd4f 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -4,6 +4,11 @@ #include #include +#ifdef USE_CC310_HW_CRYPTO +#include +#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 diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 7252974a9..c11f37cac 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -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); } diff --git a/src/Mesh.h b/src/Mesh.h index 2302d6b5b..49a299a6a 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -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) { } diff --git a/src/Packet.h b/src/Packet.h index 0886a06c4..c19d9e9d8 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -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 //... diff --git a/src/Utils.cpp b/src/Utils.cpp index 186c8720a..d4bc8c450 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -2,6 +2,13 @@ #include #include +#ifdef USE_CC310_HW_CRYPTO +#include +#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 #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; } -} \ No newline at end of file +} diff --git a/src/helpers/AdvertDataHelpers.cpp b/src/helpers/AdvertDataHelpers.cpp index 0e05620ec..998733ae0 100644 --- a/src/helpers/AdvertDataHelpers.cpp +++ b/src/helpers/AdvertDataHelpers.cpp @@ -1,4 +1,5 @@ #include +#include 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_ } } } -} \ No newline at end of file +} diff --git a/src/helpers/AutoDiscoverRTCClock.cpp b/src/helpers/AutoDiscoverRTCClock.cpp index a310539bc..af08fddb0 100644 --- a/src/helpers/AutoDiscoverRTCClock.cpp +++ b/src/helpers/AutoDiscoverRTCClock.cpp @@ -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); } diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index d3ef034e6..972a97e9e 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -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; diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index c04bfda38..d98785470 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -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); diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index e9a3f2ab4..23933fcb4 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -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; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 03d548c7e..e579ba24a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3,6 +3,7 @@ #include "TxtDataHelpers.h" #include "AdvertDataHelpers.h" #include "AlertReporter.h" // for alertReporterBannedChannelMatch() +#include "MQTTPrefsAtomicStore.h" #include #include @@ -14,10 +15,14 @@ #include #include #include +#else +#include // mallinfo() for the `memory` command on nRF52/RP2040 #endif #ifdef WITH_MQTT_BRIDGE #include "bridges/MQTTBridge.h" #include "MQTTDefaults.h" +#include "MQTTPrefsCodec.h" +#include "MQTTPrefsRecovery.h" #endif // Believe it or not, this std C function is busted on some platforms! @@ -49,24 +54,40 @@ static const size_t LEGACY_MQTT_GAP_6SLOT = 306 + 6 * 186; // 1422 static const size_t LEGACY_MQTT_GAP_3SLOT = 306 + 3 * 186; // 864 static const size_t LEGACY_OBS_TAIL_MAX = 124; // rx_boosted(1) + flood(2) + snmp(25) + watchdog(1) + alert block(95) -// Bytes savePrefs() writes after owner_info (offsets 290-294): rx_boosted_gain, -// flood_max_unscoped, flood_max_advert, radio_fem_rxgain, cad_enabled. loadPrefsInt() -// treats any larger remainder as a legacy MQTT-gap file — keep this in sync with the -// trailing writes in savePrefs() whenever an upstream merge appends /com_prefs fields. +// Bytes the last binary layout wrote after owner_info (offsets 290-294): +// rx_boosted_gain, flood_max_unscoped, flood_max_advert, radio_fem_rxgain, +// cad_enabled. loadPrefsInt() treats any larger remainder as a legacy MQTT-gap +// file. Prefs are now written as JSON, so this describes read-side history only. static const size_t COM_PREFS_TAIL_BYTES = 5; void CommonCLI::loadPrefs(FILESYSTEM* fs) { bool is_fresh_install = false; bool is_upgrade = false; - - if (fs->exists("/com_prefs")) { - loadPrefsInt(fs, "/com_prefs"); // new filename + // Set when prefs came from one of the legacy binary files; they are republished + // as /prefs.json below. The legacy file is never removed, so it stays available + // as a fallback if the JSON save does not commit this boot. + bool loaded_from_legacy = false; + + if (fs->exists("/prefs.json")) { +#if defined(RP2040_PLATFORM) + File file = fs->open("/prefs.json", "r"); +#else + File file = fs->open("/prefs.json"); +#endif + if (file) { + _prefs->loadSerial(file); + file.close(); + } + } else if (fs->exists("/com_prefs")) { + // Legacy binary layout. This is a file-format migration only: settings keep + // their stored values, so it must not trigger the bridge.source upgrade below. + loadPrefsInt(fs, "/com_prefs"); + loaded_from_legacy = true; } else if (fs->exists("/node_prefs")) { loadPrefsInt(fs, "/node_prefs"); - is_upgrade = true; // Migrating from old filename - savePrefs(fs); // save to new filename - fs->remove("/node_prefs"); // remove old + is_upgrade = true; // pre-/com_prefs filename + loaded_from_legacy = true; } else { // File doesn't exist - set default bridge settings for fresh installs is_fresh_install = true; @@ -76,31 +97,55 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { // Load observer preferences (MQTT/WiFi/timezone/SNMP/alert) from /mqtt_prefs. // Readers (MQTTBridge, AlertReporter, observer CLI) use _mqtt_prefs directly — // these fields no longer exist in NodePrefs, so there is nothing to sync. - loadMQTTPrefs(fs); + MQTTPrefsAtomicStore::LegacyUpgradeGate legacy_upgrade( + _com_prefs_needs_upgrade || loaded_from_legacy); + loadMQTTPrefs(fs, &legacy_upgrade); + if (_mqtt_prefs_hold) legacy_upgrade.holdMqttSource(); // For MQTT bridge, migrate bridge.source to RX (logRx) only on fresh installs or upgrades // so legacy "tx" is not the default. mqtt.rx / mqtt.tx are separate (fresh default: advert for TX) if ((is_fresh_install || is_upgrade) && _prefs->bridge_pkt_src == 0) { - MESH_DEBUG_PRINTLN("MQTT Bridge: Migrating bridge.source from tx to rx (MQTT bridge default)"); - _prefs->bridge_pkt_src = 1; // Set to RX (logRx) - savePrefs(fs); // Save the updated preference + if (legacy_upgrade.blocksComPrefsRewrite()) { + MESH_DEBUG_PRINTLN("MQTT Bridge: deferring bridge.source migration until legacy prefs are preserved"); + } else { + MESH_DEBUG_PRINTLN("MQTT Bridge: Migrating bridge.source from tx to rx (MQTT bridge default)"); + _prefs->bridge_pkt_src = 1; // Set to RX (logRx) + if (loaded_from_legacy) { + // The /prefs.json migration below persists this in-memory change. + MESH_DEBUG_PRINTLN("MQTT Bridge: bridge.source will be saved with the prefs migration"); + } else { + savePrefs(fs); // Save the updated preference + } + } } // mqtt_rx_enabled: new field appended to end of MQTTPrefs. On upgrade from older firmware, // the shorter /mqtt_prefs file won't contain it, so it keeps the default value (1 = on) // set by setMQTTPrefsDefaults(). No explicit migration needed. #endif - if (_com_prefs_needs_upgrade) { - // Old-format /com_prefs (legacy MQTT gap + trailing observer block) was detected: - // rewrite the prefs files in the current layout, one time. This persists the - // recovered rx_boosted_gain/flood_max_* values and (on MQTT builds) the observer - // settings that loadMQTTPrefs carried over into /mqtt_prefs. + // Republish legacy binary prefs as /prefs.json. Old-format files also carried a + // trailing observer block, which loadPrefsInt() recovered into _legacy_tail; wait + // for loadMQTTPrefs() to commit that to /mqtt_prefs first. The legacy file is left + // on flash either way, so a deferred or failed save just retries on the next boot. +#ifdef WITH_MQTT_BRIDGE + if (loaded_from_legacy || _com_prefs_needs_upgrade) { + if (legacy_upgrade.mayRewriteComPrefs()) { + savePrefs(fs, false); // loadMQTTPrefs already committed the MQTT payload + legacy_upgrade.recordComPrefsRewrite(); + _com_prefs_needs_upgrade = false; + } else { + MESH_DEBUG_PRINTLN("Prefs: deferring /prefs.json migration until /mqtt_prefs commits"); + } + } +#else + if (loaded_from_legacy || _com_prefs_needs_upgrade) { savePrefs(fs); _com_prefs_needs_upgrade = false; } +#endif } -void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { +void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy prefs loader #if defined(RP2040_PLATFORM) File file = fs->open(filename, "r"); #else @@ -306,82 +351,34 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2); _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean + _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean + _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean file.close(); } } -void CommonCLI::savePrefs(FILESYSTEM* fs) { +bool CommonCLI::savePrefs(FILESYSTEM* fs, bool save_mqtt) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - fs->remove("/com_prefs"); - File file = fs->open("/com_prefs", FILE_O_WRITE); + fs->remove("/prefs.json"); + File file = fs->open("/prefs.json", FILE_O_WRITE); #elif defined(RP2040_PLATFORM) - File file = fs->open("/com_prefs", "w"); + File file = fs->open("/prefs.json", "w"); #else - File file = fs->open("/com_prefs", "w", true); + File file = fs->open("/prefs.json", "w", true); #endif + bool success = false; if (file) { - uint8_t pad[8]; - memset(pad, 0, sizeof(pad)); - - file.write((uint8_t *)&_prefs->airtime_factor, sizeof(_prefs->airtime_factor)); // 0 - file.write((uint8_t *)&_prefs->node_name, sizeof(_prefs->node_name)); // 4 - file.write(pad, 4); // 36 - file.write((uint8_t *)&_prefs->node_lat, sizeof(_prefs->node_lat)); // 40 - file.write((uint8_t *)&_prefs->node_lon, sizeof(_prefs->node_lon)); // 48 - file.write((uint8_t *)&_prefs->password[0], sizeof(_prefs->password)); // 56 - file.write((uint8_t *)&_prefs->freq, sizeof(_prefs->freq)); // 72 - file.write((uint8_t *)&_prefs->tx_power_dbm, sizeof(_prefs->tx_power_dbm)); // 76 - file.write((uint8_t *)&_prefs->disable_fwd, sizeof(_prefs->disable_fwd)); // 77 - file.write((uint8_t *)&_prefs->advert_interval, sizeof(_prefs->advert_interval)); // 78 - file.write(pad, 1); // 79 : 1 byte unused (rx_boosted_gain moved to end) - file.write((uint8_t *)&_prefs->rx_delay_base, sizeof(_prefs->rx_delay_base)); // 80 - file.write((uint8_t *)&_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84 - file.write((uint8_t *)&_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88 - file.write((uint8_t *)&_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104 - file.write(pad, 4); // 108 - file.write((uint8_t *)&_prefs->sf, sizeof(_prefs->sf)); // 112 - file.write((uint8_t *)&_prefs->cr, sizeof(_prefs->cr)); // 113 - file.write((uint8_t *)&_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114 - file.write((uint8_t *)&_prefs->multi_acks, sizeof(_prefs->multi_acks)); // 115 - file.write((uint8_t *)&_prefs->bw, sizeof(_prefs->bw)); // 116 - file.write((uint8_t *)&_prefs->agc_reset_interval, sizeof(_prefs->agc_reset_interval)); // 120 - file.write((uint8_t *)&_prefs->path_hash_mode, sizeof(_prefs->path_hash_mode)); // 121 - file.write((uint8_t *)&_prefs->loop_detect, sizeof(_prefs->loop_detect)); // 122 - file.write(pad, 1); // 123 - file.write((uint8_t *)&_prefs->flood_max, sizeof(_prefs->flood_max)); // 124 - file.write((uint8_t *)&_prefs->flood_advert_interval, sizeof(_prefs->flood_advert_interval)); // 125 - file.write((uint8_t *)&_prefs->interference_threshold, sizeof(_prefs->interference_threshold)); // 126 - file.write((uint8_t *)&_prefs->bridge_enabled, sizeof(_prefs->bridge_enabled)); // 127 - file.write((uint8_t *)&_prefs->bridge_delay, sizeof(_prefs->bridge_delay)); // 128 - file.write((uint8_t *)&_prefs->bridge_pkt_src, sizeof(_prefs->bridge_pkt_src)); // 130 - file.write((uint8_t *)&_prefs->bridge_baud, sizeof(_prefs->bridge_baud)); // 131 - file.write((uint8_t *)&_prefs->bridge_channel, sizeof(_prefs->bridge_channel)); // 135 - file.write((uint8_t *)&_prefs->bridge_secret, sizeof(_prefs->bridge_secret)); // 136 - file.write((uint8_t *)&_prefs->powersaving_enabled, sizeof(_prefs->powersaving_enabled)); // 152 - file.write(pad, 3); // 153 - file.write((uint8_t *)&_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156 - file.write((uint8_t *)&_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157 - file.write((uint8_t *)&_prefs->advert_loc_policy, sizeof(_prefs->advert_loc_policy)); // 161 - file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 - file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 - file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 - // MQTT/observer settings are stored in /mqtt_prefs, not here. No zero-gap is - // written anymore — /com_prefs holds only the (non-observer) fields below. - // These trailing writes are COM_PREFS_TAIL_BYTES; keep the two in sync. - file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - file.write((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291 - file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 - file.write((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 - file.write((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - + success = _prefs->saveSerial(file); file.close(); } #ifdef WITH_MQTT_BRIDGE // Observer config (MQTT/WiFi/timezone/SNMP/alert) is persisted separately. The // observer CLI writes _mqtt_prefs directly, so no NodePrefs->MQTTPrefs sync runs. - saveMQTTPrefs(fs); + // Runs regardless of the NodePrefs result so a failed JSON write cannot strand it. + if (save_mqtt) saveMQTTPrefs(fs); #endif + return success; } #ifdef WITH_MQTT_BRIDGE @@ -390,236 +387,326 @@ static void setMQTTPrefsDefaults(MQTTPrefs* prefs) { applyMQTTDefaults(prefs); } -static File openMqttPrefsRead(FILESYSTEM* fs) { +static File openMqttPrefsRead(FILESYSTEM* fs, const char* path = "/mqtt_prefs") { #if defined(RP2040_PLATFORM) - return fs->open("/mqtt_prefs", "r"); + return fs->open(path, "r"); #else - return fs->open("/mqtt_prefs"); + return fs->open(path); #endif } -void CommonCLI::loadMQTTPrefs(FILESYSTEM* fs) { - // Initialize with defaults first - setMQTTPrefsDefaults(&_mqtt_prefs); - _mqtt_prefs_hold = false; +static MQTTPrefsRecovery::FileState mqttPrefsFileState(FILESYSTEM* fs, const char* path) { + if (!fs->exists(path)) return MQTTPrefsRecovery::FileState::Missing; + File file = openMqttPrefsRead(fs, path); + if (!file) return MQTTPrefsRecovery::FileState::Preserve; + const size_t file_size = file.size(); + uint8_t prefix[sizeof(MQTTPrefsHeader)] = {}; + const size_t prefix_size = file_size < sizeof(prefix) ? file_size : sizeof(prefix); + const size_t prefix_read = file.read(prefix, prefix_size); + file.close(); + return MQTTPrefsCodec::classify(prefix, prefix_read, file_size).preserve_file + ? MQTTPrefsRecovery::FileState::Preserve + : MQTTPrefsRecovery::FileState::Usable; +} - // Whether the loaded /mqtt_prefs already contained the observer fields (snmp/ - // watchdog/alert) appended in Phase 2 — if not, they may be carried over from an - // old-format /com_prefs trailing block below. - bool has_observer_fields = false; +// Restore the only usable image before the normal loader inspects /mqtt_prefs. +// SPIFFS cannot rename over an existing destination, so publishing moves the +// old primary to .bak before moving the verified temp into the empty name. +// The decision helper deliberately treats unsupported/corrupt files as opaque: +// no recovery path overwrites one with an older layout. +static bool recoverMqttPrefsFiles(FILESYSTEM* fs) { + const MQTTPrefsRecovery::FileState primary = mqttPrefsFileState(fs, "/mqtt_prefs"); + const MQTTPrefsRecovery::FileState temp = mqttPrefsFileState(fs, "/mqtt_prefs.tmp"); + const MQTTPrefsRecovery::FileState backup = mqttPrefsFileState(fs, "/mqtt_prefs.bak"); + const MQTTPrefsRecovery::Action action = MQTTPrefsRecovery::select(primary, temp, backup); - bool file_existed = fs->exists("/mqtt_prefs"); - if (file_existed) { - // First, peek the header to see if this is a versioned file. - File file = openMqttPrefsRead(fs); - bool versioned = false; - if (file) { - size_t file_size = file.size(); - if (file_size >= sizeof(MQTTPrefsHeader)) { - MQTTPrefsHeader hdr; - if (file.read((uint8_t *)&hdr, sizeof(hdr)) == sizeof(hdr) - && memcmp(hdr.magic, MQTT_PREFS_MAGIC, sizeof(hdr.magic)) == 0) { - versioned = true; - if (hdr.version == MQTT_PREFS_VERSION) { - // Current version: the payload follows the header. Read up to - // sizeof(MQTTPrefs); a shorter payload (an earlier v1 firmware that - // hadn't appended a tail field) leaves the trailing fields at their - // defaults, and a longer one (a future append) is truncated harmlessly. - size_t payload_avail = file_size - sizeof(hdr); - size_t to_read = payload_avail < sizeof(_mqtt_prefs) ? payload_avail : sizeof(_mqtt_prefs); - size_t got = file.read((uint8_t *)&_mqtt_prefs, to_read); - if (got != to_read) { - setMQTTPrefsDefaults(&_mqtt_prefs); - } else { - has_observer_fields = true; // observer tail is part of the v1 payload - } - } else { - // Unknown (newer) version: don't risk misreading a layout we don't know. - // Keep defaults for this boot and hold the file so later savePrefs() - // calls can't overwrite the newer config (no downgrade). - _mqtt_prefs_hold = true; - MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs version unsupported, using defaults (file preserved)"); - } - } - } - file.close(); + if (action == MQTTPrefsRecovery::Action::KeepPrimary) { + // A current/known legacy primary has already published. Every transaction + // artifact is therefore unpublished or stale, including a partial temp + // left by a reset during write(), and can be discarded. Preserve artifacts + // only when the primary itself is opaque (the branch above still keeps it). + if (primary == MQTTPrefsRecovery::FileState::Usable) { + if (temp != MQTTPrefsRecovery::FileState::Missing) fs->remove("/mqtt_prefs.tmp"); + if (backup != MQTTPrefsRecovery::FileState::Missing) fs->remove("/mqtt_prefs.bak"); } - - if (!versioned) { - // Headerless (legacy) file. Detect the historical on-disk layout by size, - // migrate it into the compact versioned struct, and re-save — which adds the - // header and drops the vestigial `_legacy_*` fields. Reopen because the peek - // above advanced the read cursor past the (non-matching) leading bytes. - File file = openMqttPrefsRead(fs); - if (file) { - size_t file_size = file.size(); - - // Detect old (pre-slot) format by file size. - // Old MQTTPrefs was ~472 bytes (no slot fields). - // If the file is smaller than the new struct but close to OldMQTTPrefs size, - // read it with the old layout and migrate. - if (file_size > 0 && file_size <= sizeof(OldMQTTPrefs)) { - OldMQTTPrefs old_prefs; - memset(&old_prefs, 0, sizeof(old_prefs)); - size_t bytes_read = file.read((uint8_t *)&old_prefs, file_size < sizeof(old_prefs) ? file_size : sizeof(old_prefs)); - file.close(); - - if (bytes_read > 0) { - MESH_DEBUG_PRINTLN("MQTT: Migrating old-format prefs to versioned layout"); - - // Copy common fields (identical layout at start of both structs) - memcpy(_mqtt_prefs.mqtt_origin, old_prefs.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin)); - memcpy(_mqtt_prefs.mqtt_iata, old_prefs.mqtt_iata, sizeof(_mqtt_prefs.mqtt_iata)); - _mqtt_prefs.mqtt_status_enabled = old_prefs.mqtt_status_enabled; - _mqtt_prefs.mqtt_packets_enabled = old_prefs.mqtt_packets_enabled; - _mqtt_prefs.mqtt_raw_enabled = old_prefs.mqtt_raw_enabled; - _mqtt_prefs.mqtt_tx_enabled = old_prefs.mqtt_tx_enabled; - _mqtt_prefs.mqtt_status_interval = old_prefs.mqtt_status_interval; - memcpy(_mqtt_prefs.wifi_ssid, old_prefs.wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid)); - memcpy(_mqtt_prefs.wifi_password, old_prefs.wifi_password, sizeof(_mqtt_prefs.wifi_password)); - _mqtt_prefs.wifi_power_save = old_prefs.wifi_power_save; - memcpy(_mqtt_prefs.timezone_string, old_prefs.timezone_string, sizeof(_mqtt_prefs.timezone_string)); - _mqtt_prefs.timezone_offset = old_prefs.timezone_offset; - - // Migrate shared auth fields - memcpy(_mqtt_prefs.mqtt_owner_public_key, old_prefs.mqtt_owner_public_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); - memcpy(_mqtt_prefs.mqtt_email, old_prefs.mqtt_email, sizeof(_mqtt_prefs.mqtt_email)); - - // Migrate analyzer presets to slots - if (old_prefs.mqtt_analyzer_us_enabled == 1) { - strncpy(_mqtt_prefs.mqtt_slot_preset[0], "analyzer-us", sizeof(_mqtt_prefs.mqtt_slot_preset[0]) - 1); - } else { - strncpy(_mqtt_prefs.mqtt_slot_preset[0], "none", sizeof(_mqtt_prefs.mqtt_slot_preset[0]) - 1); - } - if (old_prefs.mqtt_analyzer_eu_enabled == 1) { - strncpy(_mqtt_prefs.mqtt_slot_preset[1], "analyzer-eu", sizeof(_mqtt_prefs.mqtt_slot_preset[1]) - 1); - } else { - strncpy(_mqtt_prefs.mqtt_slot_preset[1], "none", sizeof(_mqtt_prefs.mqtt_slot_preset[1]) - 1); - } - - // Migrate custom server to slot 3 - if (old_prefs.mqtt_server[0] != '\0' && old_prefs.mqtt_port > 0) { - strncpy(_mqtt_prefs.mqtt_slot_preset[2], "custom", sizeof(_mqtt_prefs.mqtt_slot_preset[2]) - 1); - strncpy(_mqtt_prefs.mqtt_slot_host[2], old_prefs.mqtt_server, sizeof(_mqtt_prefs.mqtt_slot_host[2]) - 1); - _mqtt_prefs.mqtt_slot_port[2] = old_prefs.mqtt_port; - strncpy(_mqtt_prefs.mqtt_slot_username[2], old_prefs.mqtt_username, sizeof(_mqtt_prefs.mqtt_slot_username[2]) - 1); - strncpy(_mqtt_prefs.mqtt_slot_password[2], old_prefs.mqtt_password, sizeof(_mqtt_prefs.mqtt_slot_password[2]) - 1); - } else { - strncpy(_mqtt_prefs.mqtt_slot_preset[2], "none", sizeof(_mqtt_prefs.mqtt_slot_preset[2]) - 1); - } - - // Save migrated prefs in the versioned format - saveMQTTPrefs(fs); - } - } else if (file_size > 0 && file_size <= sizeof(ThreeSlotMQTTPrefs)) { - // 3-slot format → compact 6-slot migration - // Array sizes changed from [3] to [6], shifting all field offsets. - // Read into old layout struct and field-copy to new layout. - ThreeSlotMQTTPrefs old3; - memset(&old3, 0, sizeof(old3)); - size_t bytes_to_read = file_size < sizeof(old3) ? file_size : sizeof(old3); - size_t bytes_read = file.read((uint8_t *)&old3, bytes_to_read); - file.close(); - - if (bytes_read > 0) { - MESH_DEBUG_PRINTLN("MQTT: Migrating 3-slot prefs to versioned layout"); - - // Copy non-slot fields (identical layout) - memcpy(_mqtt_prefs.mqtt_origin, old3.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin)); - memcpy(_mqtt_prefs.mqtt_iata, old3.mqtt_iata, sizeof(_mqtt_prefs.mqtt_iata)); - _mqtt_prefs.mqtt_status_enabled = old3.mqtt_status_enabled; - _mqtt_prefs.mqtt_packets_enabled = old3.mqtt_packets_enabled; - _mqtt_prefs.mqtt_raw_enabled = old3.mqtt_raw_enabled; - _mqtt_prefs.mqtt_tx_enabled = old3.mqtt_tx_enabled; - _mqtt_prefs.mqtt_status_interval = old3.mqtt_status_interval; - memcpy(_mqtt_prefs.wifi_ssid, old3.wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid)); - memcpy(_mqtt_prefs.wifi_password, old3.wifi_password, sizeof(_mqtt_prefs.wifi_password)); - _mqtt_prefs.wifi_power_save = old3.wifi_power_save; - memcpy(_mqtt_prefs.timezone_string, old3.timezone_string, sizeof(_mqtt_prefs.timezone_string)); - _mqtt_prefs.timezone_offset = old3.timezone_offset; - - // Copy slot fields for indices 0-2 from old layout - for (int i = 0; i < 3; i++) { - memcpy(_mqtt_prefs.mqtt_slot_preset[i], old3.mqtt_slot_preset[i], sizeof(_mqtt_prefs.mqtt_slot_preset[i])); - memcpy(_mqtt_prefs.mqtt_slot_host[i], old3.mqtt_slot_host[i], sizeof(_mqtt_prefs.mqtt_slot_host[i])); - _mqtt_prefs.mqtt_slot_port[i] = old3.mqtt_slot_port[i]; - memcpy(_mqtt_prefs.mqtt_slot_username[i], old3.mqtt_slot_username[i], sizeof(_mqtt_prefs.mqtt_slot_username[i])); - memcpy(_mqtt_prefs.mqtt_slot_password[i], old3.mqtt_slot_password[i], sizeof(_mqtt_prefs.mqtt_slot_password[i])); - memcpy(_mqtt_prefs.mqtt_slot_token[i], old3.mqtt_slot_token[i], sizeof(_mqtt_prefs.mqtt_slot_token[i])); - memcpy(_mqtt_prefs.mqtt_slot_topic[i], old3.mqtt_slot_topic[i], sizeof(_mqtt_prefs.mqtt_slot_topic[i])); - } - // Slots 3-5 keep defaults ("none") from setMQTTPrefsDefaults() - - // Copy shared auth fields - memcpy(_mqtt_prefs.mqtt_owner_public_key, old3.mqtt_owner_public_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); - memcpy(_mqtt_prefs.mqtt_email, old3.mqtt_email, sizeof(_mqtt_prefs.mqtt_email)); - - // Save migrated prefs in the versioned format - saveMQTTPrefs(fs); - } - } else if (file_size > 0) { - // Headerless 6-slot layout as shipped on mqtt-bridge-implementation-flex - // (the deployed fleet). Same field order as the compact struct but with the - // vestigial `_legacy_*` block mid-struct and no observer tail — so read it - // into Legacy6SlotMQTTPrefs and field-copy across, dropping `_legacy_*`. - Legacy6SlotMQTTPrefs old6; - memset(&old6, 0, sizeof(old6)); - size_t bytes_to_read = file_size < sizeof(old6) ? file_size : sizeof(old6); - size_t bytes_read = file.read((uint8_t *)&old6, bytes_to_read); - file.close(); - - if (bytes_read > 0) { - MESH_DEBUG_PRINTLN("MQTT: Migrating headerless 6-slot prefs to versioned layout"); - - memcpy(_mqtt_prefs.mqtt_origin, old6.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin)); - memcpy(_mqtt_prefs.mqtt_iata, old6.mqtt_iata, sizeof(_mqtt_prefs.mqtt_iata)); - _mqtt_prefs.mqtt_status_enabled = old6.mqtt_status_enabled; - _mqtt_prefs.mqtt_packets_enabled = old6.mqtt_packets_enabled; - _mqtt_prefs.mqtt_raw_enabled = old6.mqtt_raw_enabled; - _mqtt_prefs.mqtt_tx_enabled = old6.mqtt_tx_enabled; - _mqtt_prefs.mqtt_status_interval = old6.mqtt_status_interval; - memcpy(_mqtt_prefs.wifi_ssid, old6.wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid)); - memcpy(_mqtt_prefs.wifi_password, old6.wifi_password, sizeof(_mqtt_prefs.wifi_password)); - _mqtt_prefs.wifi_power_save = old6.wifi_power_save; - memcpy(_mqtt_prefs.timezone_string, old6.timezone_string, sizeof(_mqtt_prefs.timezone_string)); - _mqtt_prefs.timezone_offset = old6.timezone_offset; - memcpy(_mqtt_prefs.mqtt_slot_preset, old6.mqtt_slot_preset, sizeof(_mqtt_prefs.mqtt_slot_preset)); - memcpy(_mqtt_prefs.mqtt_slot_host, old6.mqtt_slot_host, sizeof(_mqtt_prefs.mqtt_slot_host)); - memcpy(_mqtt_prefs.mqtt_slot_port, old6.mqtt_slot_port, sizeof(_mqtt_prefs.mqtt_slot_port)); - memcpy(_mqtt_prefs.mqtt_slot_username, old6.mqtt_slot_username, sizeof(_mqtt_prefs.mqtt_slot_username)); - memcpy(_mqtt_prefs.mqtt_slot_password, old6.mqtt_slot_password, sizeof(_mqtt_prefs.mqtt_slot_password)); - memcpy(_mqtt_prefs.mqtt_owner_public_key, old6.mqtt_owner_public_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); - memcpy(_mqtt_prefs.mqtt_email, old6.mqtt_email, sizeof(_mqtt_prefs.mqtt_email)); - // `_legacy_*` fields are intentionally dropped here. - memcpy(_mqtt_prefs.mqtt_slot_token, old6.mqtt_slot_token, sizeof(_mqtt_prefs.mqtt_slot_token)); - memcpy(_mqtt_prefs.mqtt_slot_topic, old6.mqtt_slot_topic, sizeof(_mqtt_prefs.mqtt_slot_topic)); - memcpy(_mqtt_prefs.mqtt_slot_audience, old6.mqtt_slot_audience, sizeof(_mqtt_prefs.mqtt_slot_audience)); - _mqtt_prefs.mqtt_rx_enabled = old6.mqtt_rx_enabled; - memcpy(_mqtt_prefs.mqtt_ntp_server, old6.mqtt_ntp_server, sizeof(_mqtt_prefs.mqtt_ntp_server)); - // Observer tail (snmp/watchdog/alert) keeps defaults; if this device is - // also upgrading across the NodePrefs split, loadPrefsInt captured those - // values from /com_prefs and they are applied below. - - saveMQTTPrefs(fs); - } - } else { - file.close(); - setMQTTPrefsDefaults(&_mqtt_prefs); - } + return false; + } + if (action == MQTTPrefsRecovery::Action::PromoteTemp) { + if (fs->rename("/mqtt_prefs.tmp", "/mqtt_prefs")) { + // A usable temp is now the committed primary. Its backup is necessarily + // a stale transaction artifact, even if this firmware cannot decode it. + if (temp == MQTTPrefsRecovery::FileState::Usable && + backup != MQTTPrefsRecovery::FileState::Missing) { + fs->remove("/mqtt_prefs.bak"); } + MESH_DEBUG_PRINTLN("MQTT: recovered /mqtt_prefs from transaction temp"); + return false; + } + MESH_DEBUG_PRINTLN("MQTT: could not recover /mqtt_prefs temp; files preserved"); + return true; + } + if (action == MQTTPrefsRecovery::Action::PromoteBackup) { + if (fs->rename("/mqtt_prefs.bak", "/mqtt_prefs")) { + // Symmetric case: a usable backup is now primary, so any interrupted + // temp is no longer authoritative and must not block a later save. + if (backup == MQTTPrefsRecovery::FileState::Usable && + temp != MQTTPrefsRecovery::FileState::Missing) { + fs->remove("/mqtt_prefs.tmp"); + } + MESH_DEBUG_PRINTLN("MQTT: recovered /mqtt_prefs from transaction backup"); + return false; + } + MESH_DEBUG_PRINTLN("MQTT: could not recover /mqtt_prefs backup; files preserved"); + return true; + } + return false; +} + +// Filesystem adapter for MQTTPrefsAtomicStore. It writes the new image to +// /mqtt_prefs.tmp and verifies its size. Publishing is a recoverable SPIFFS +// transaction: primary -> .bak, then tmp -> primary, then best-effort backup +// cleanup. A power loss at every boundary leaves at least one recoverable file. +class MQTTPrefsFileStore { +public: + explicit MQTTPrefsFileStore(FILESYSTEM* fs) : _fs(fs) {} + + bool begin() { + _finished = false; + _open = false; + _owns_temp = false; + _bytes_written = 0; + // Recovery owns stale artifacts. Do not delete them here: a failed commit + // may have moved the old primary to .bak and left a verified temp that the + // next boot must choose between. Refusing the save is safer than erasing an + // image this firmware cannot decode. + if (_fs->exists("/mqtt_prefs.tmp") || _fs->exists("/mqtt_prefs.bak")) return false; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + _file = _fs->open("/mqtt_prefs.tmp", FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + _file = _fs->open("/mqtt_prefs.tmp", "w"); +#else + _file = _fs->open("/mqtt_prefs.tmp", "w", true); +#endif + _open = _file; + _owns_temp = _open; + return _open; + } + + size_t write(const uint8_t* bytes, size_t size) { + if (!_open) return 0; + const size_t written = _file.write(bytes, size); + _bytes_written += written; + return written; + } + + bool finish() { + if (!_open) return false; + _file.close(); + _open = false; +#if defined(RP2040_PLATFORM) + File verify = _fs->open("/mqtt_prefs.tmp", "r"); +#else + File verify = _fs->open("/mqtt_prefs.tmp"); +#endif + if (!verify) return false; + const bool complete = verify.size() == _bytes_written; + verify.close(); + if (!complete) return false; + _finished = true; + return true; + } + + bool commit() { + if (!_finished) return false; + // SPIFFS refuses rename(tmp, existing_dest). Move the existing image to a + // recoverable backup first, then publish temp into the now-empty primary. + // Never remove either image after a failed boundary; boot recovery selects + // the completed temp or restores the backup. + if (_fs->exists("/mqtt_prefs.bak")) return false; + if (_fs->exists("/mqtt_prefs") && !_fs->rename("/mqtt_prefs", "/mqtt_prefs.bak")) { + return false; + } + if (!_fs->rename("/mqtt_prefs.tmp", "/mqtt_prefs")) return false; + // Cleanup failure is non-fatal: the new primary is published and recovery + // will remove a known-good stale backup on a later boot. + if (_fs->exists("/mqtt_prefs.bak")) _fs->remove("/mqtt_prefs.bak"); + return true; + } + + void abort() { + if (_open) _file.close(); + _open = false; + // Once finish() has verified the temp, commit may already have moved the + // primary to .bak. Keep the temp on a commit failure so recovery can + // publish it (or fall back to .bak) after reset. + if (_owns_temp && !_finished && _fs->exists("/mqtt_prefs.tmp")) { + _fs->remove("/mqtt_prefs.tmp"); + } + _finished = false; + _owns_temp = false; + } + +private: + FILESYSTEM* _fs; + File _file; + bool _open = false; + bool _finished = false; + bool _owns_temp = false; + size_t _bytes_written = 0; +}; + +#endif // WITH_MQTT_BRIDGE + +#ifdef WITH_MQTT_BRIDGE + +static const char* mqttPrefsSaveResultName(MQTTPrefsAtomicStore::Result result) { + switch (result) { + case MQTTPrefsAtomicStore::Result::BeginFailed: return "begin"; + case MQTTPrefsAtomicStore::Result::HeaderWriteFailed: return "header write"; + case MQTTPrefsAtomicStore::Result::PayloadWriteFailed: return "payload write"; + case MQTTPrefsAtomicStore::Result::FinishFailed: return "close"; + case MQTTPrefsAtomicStore::Result::CommitFailed: return "rename"; + case MQTTPrefsAtomicStore::Result::Committed: return "committed"; + } + return "unknown"; +} + +void CommonCLI::loadMQTTPrefs( + FILESYSTEM* fs, MQTTPrefsAtomicStore::LegacyUpgradeGate* legacy_upgrade) { + setMQTTPrefsDefaults(&_mqtt_prefs); + // Complete or preserve an interrupted SPIFFS transaction before decoding. + // A failed recovery leaves the artifacts untouched and blocks this boot from + // replacing them with defaults through a later CLI save. + _mqtt_prefs_hold = recoverMqttPrefsFiles(fs); + bool has_observer_fields = false; + bool mqtt_rewrite_pending = false; + bool migrated_legacy_mqtt = false; + + if (fs->exists("/mqtt_prefs")) { + File file = openMqttPrefsRead(fs); + if (file) { + const size_t file_size = file.size(); + uint8_t prefix[sizeof(MQTTPrefsHeader)] = {}; + const size_t prefix_size = file_size < sizeof(prefix) ? file_size : sizeof(prefix); + const size_t prefix_read = file.read(prefix, prefix_size); + file.close(); + + const MQTTPrefsCodec::DecodePlan plan = + MQTTPrefsCodec::classify(prefix, prefix_read, file_size); + if (plan.preserve_file) { + _mqtt_prefs_hold = true; + MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs is unsupported or corrupt, using defaults (file preserved)"); + } else if (plan.source == MQTTPrefsCodec::Source::Current) { + file = openMqttPrefsRead(fs); + MQTTPrefsHeader header; + if (!file || file.read((uint8_t *)&header, sizeof(header)) != sizeof(header) || + file.read((uint8_t *)&_mqtt_prefs, plan.payload_len) != plan.payload_len) { + setMQTTPrefsDefaults(&_mqtt_prefs); + _mqtt_prefs_hold = true; + MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs read failed, using defaults (file preserved)"); + } else { + has_observer_fields = plan.observer_fields_present; + // Written by a later build with appended fields. Everything this + // binary knows loaded normally; say so, because the next `set` will + // rewrite the file at this length and drop the newer settings. + if (file_size - sizeof(MQTTPrefsHeader) > plan.payload_len) { + MESH_DEBUG_PRINTLN( + "MQTT: /mqtt_prefs written by newer firmware (%u > %u bytes); " + "config loaded, newer settings ignored and dropped on next save", + (unsigned)(file_size - sizeof(MQTTPrefsHeader)), + (unsigned)plan.payload_len); + } + } + if (file) file.close(); + } else if (plan.rewrite_legacy) { + bool migrated = false; + file = openMqttPrefsRead(fs); + if (file) { + switch (plan.source) { + case MQTTPrefsCodec::Source::LegacyPreSlot: { + union { + OldMQTTPrefs post_wifi_power; + PreWifiPowerOldMQTTPrefs pre_wifi_power; + } old_prefs = {}; + if (file.read((uint8_t *)&old_prefs, sizeof(old_prefs)) == sizeof(old_prefs)) { + if (MQTTPrefsCodec::isPlausibleLegacy(plan.source, + (const uint8_t *)&old_prefs, sizeof(old_prefs))) { + if (MQTTPrefsCodec::looksLikePreWifiPower((uint8_t *)&old_prefs, sizeof(old_prefs))) { + MQTTPrefsCodec::migratePreWifiPower(old_prefs.pre_wifi_power, &_mqtt_prefs); + } else { + MQTTPrefsCodec::migratePreSlot(old_prefs.post_wifi_power, &_mqtt_prefs); + } + migrated = true; + } else { + MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs legacy content failed plausibility checks"); + } + } + break; + } + case MQTTPrefsCodec::Source::LegacyThreeSlotBase: { + ThreeSlotBaseMQTTPrefs old_prefs = {}; + if (file.read((uint8_t *)&old_prefs, sizeof(old_prefs)) == sizeof(old_prefs)) { + if (MQTTPrefsCodec::isPlausibleLegacy(plan.source, + (const uint8_t *)&old_prefs, sizeof(old_prefs))) { + MQTTPrefsCodec::migrateThreeSlot(old_prefs, &_mqtt_prefs); + migrated = true; + } else { + MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs legacy content failed plausibility checks"); + } + } + break; + } + case MQTTPrefsCodec::Source::LegacyThreeSlot: { + ThreeSlotMQTTPrefs old_prefs = {}; + if (file.read((uint8_t *)&old_prefs, sizeof(old_prefs)) == sizeof(old_prefs)) { + if (MQTTPrefsCodec::isPlausibleLegacy(plan.source, + (const uint8_t *)&old_prefs, sizeof(old_prefs))) { + MQTTPrefsCodec::migrateThreeSlot(old_prefs, &_mqtt_prefs); + migrated = true; + } else { + MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs legacy content failed plausibility checks"); + } + } + break; + } + case MQTTPrefsCodec::Source::LegacySixSlotBase: + case MQTTPrefsCodec::Source::LegacySixSlotAudience: + case MQTTPrefsCodec::Source::LegacySixSlotAudienceRx: + case MQTTPrefsCodec::Source::LegacySixSlot: { + Legacy6SlotMQTTPrefs old_prefs = {}; + if (file.read((uint8_t *)&old_prefs, plan.payload_len) == plan.payload_len) { + if (MQTTPrefsCodec::isPlausibleLegacy(plan.source, + (const uint8_t *)&old_prefs, plan.payload_len)) { + MQTTPrefsCodec::migrateLegacySixSlot(old_prefs, plan.source, &_mqtt_prefs); + migrated = true; + } else { + MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs legacy content failed plausibility checks"); + } + } + break; + } + default: + break; + } + file.close(); + } + if (migrated) { + // Do not save yet: a legacy /com_prefs observer tail may still need + // to be overlaid below. Publish the complete v1 image once, after it. + mqtt_rewrite_pending = true; + migrated_legacy_mqtt = true; + } else { + setMQTTPrefsDefaults(&_mqtt_prefs); + _mqtt_prefs_hold = true; + MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs legacy read failed, using defaults (file preserved)"); + } + } + } else { + _mqtt_prefs_hold = true; + MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs could not be opened, using defaults (file preserved)"); } - } else { - // No /mqtt_prefs file — defaults already set. (MQTT slot/WiFi settings from - // pre-/mqtt_prefs-split fork firmware are NOT recovered from /com_prefs — that - // offset-based migration was fragile and was removed; those users re-enter - // their MQTT config. The observer trailing block IS recovered, below.) } - // One-time upgrade path: if loadPrefsInt captured the trailing observer block of - // an old-format /com_prefs and this /mqtt_prefs predates the appended observer - // fields (or doesn't exist), carry the settings over so SNMP, radio-watchdog and - // fault-alert config survive the firmware upgrade. loadPrefs() persists both - // files in the new layout right after this. if (_legacy_tail.valid && !has_observer_fields) { _mqtt_prefs.snmp_enabled = _legacy_tail.snmp_enabled; memcpy(_mqtt_prefs.snmp_community, _legacy_tail.snmp_community, sizeof(_mqtt_prefs.snmp_community)); @@ -631,6 +718,7 @@ void CommonCLI::loadMQTTPrefs(FILESYSTEM* fs) { _mqtt_prefs.alert_min_interval_min = _legacy_tail.alert_min_interval_min; memcpy(_mqtt_prefs.alert_hashtag, _legacy_tail.alert_hashtag, sizeof(_mqtt_prefs.alert_hashtag)); memcpy(_mqtt_prefs.alert_region, _legacy_tail.alert_region, sizeof(_mqtt_prefs.alert_region)); + mqtt_rewrite_pending = true; MESH_DEBUG_PRINTLN("MQTT: Migrated observer settings from legacy /com_prefs trailing block"); } @@ -640,38 +728,60 @@ void CommonCLI::loadMQTTPrefs(FILESYSTEM* fs) { if (_mqtt_prefs.mqtt_neighbors_interval < MQTT_NEIGHBORS_MIN_INTERVAL_MS || _mqtt_prefs.mqtt_neighbors_interval > MQTT_NEIGHBORS_MAX_INTERVAL_MS) { _mqtt_prefs.mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; + // Persist the repair so a corrupt flash value is not re-clamped every boot. + // Skip when hold is set so we never overwrite a deliberately preserved file. + if (!_mqtt_prefs_hold) { + mqtt_rewrite_pending = true; + } MESH_DEBUG_PRINTLN("MQTT: invalid neighbors interval reset to %u hours", (unsigned)MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS); } _legacy_tail.valid = false; + + if (mqtt_rewrite_pending) { + legacy_upgrade->requireMqttRewrite(); + if (migrated_legacy_mqtt) { + MESH_DEBUG_PRINTLN("MQTT: Migrating headerless /mqtt_prefs to versioned layout"); + } else { + MESH_DEBUG_PRINTLN("MQTT: Persisting observer tail into /mqtt_prefs before /com_prefs compaction"); + } + if (saveMQTTPrefs(fs)) { + legacy_upgrade->recordMqttSave(true); + } else { + // The legacy source(s) remain intact because the failed transaction never + // published its temp file. Hold this boot so loadPrefs leaves /com_prefs + // untouched; the next boot can recover the tail and retry the transaction. + _mqtt_prefs_hold = true; + legacy_upgrade->recordMqttSave(false); + MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs migration save failed; legacy files preserved and held"); + } + } } -void CommonCLI::saveMQTTPrefs(FILESYSTEM* fs) { +bool CommonCLI::saveMQTTPrefs(FILESYSTEM* fs) { if (_mqtt_prefs_hold) { - // /mqtt_prefs was written by newer firmware; overwriting it here (v1 header + - // this boot's defaults) would destroy that config. Observer settings changed - // this boot are not persisted until current-or-older firmware is flashed. - MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs from newer firmware, not overwriting"); - return; + // Loading deliberately preserved the source file. Do not replace it with this + // boot's defaults after an unsupported, corrupt, or temporarily failed read. + MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs held, not overwriting"); + return false; } -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - fs->remove("/mqtt_prefs"); - File file = fs->open("/mqtt_prefs", FILE_O_WRITE); -#elif defined(RP2040_PLATFORM) - File file = fs->open("/mqtt_prefs", "w"); -#else - File file = fs->open("/mqtt_prefs", "w", true); -#endif - if (file) { - // Versioned format: 8-byte header followed by the raw MQTTPrefs payload. - MQTTPrefsHeader hdr; - memcpy(hdr.magic, MQTT_PREFS_MAGIC, sizeof(hdr.magic)); - hdr.version = MQTT_PREFS_VERSION; - hdr.payload_len = (uint16_t)sizeof(_mqtt_prefs); - file.write((uint8_t *)&hdr, sizeof(hdr)); - file.write((uint8_t *)&_mqtt_prefs, sizeof(_mqtt_prefs)); - file.close(); + + // Write header and payload sequentially so the transaction needs no second + // full-size (2.8 KiB) staging buffer on constrained targets. The length is + // the shortest that still round-trips this config, so a node with default + // packet filters keeps writing a payload older firmware can read. + const size_t payload_len = MQTTPrefsCodec::payloadLenFor(_mqtt_prefs); + const MQTTPrefsHeader header = MQTTPrefsCodec::makeHeader(payload_len); + MQTTPrefsFileStore store(fs); + const MQTTPrefsAtomicStore::Result result = MQTTPrefsAtomicStore::write( + store, (const uint8_t *)&header, sizeof(header), + (const uint8_t *)&_mqtt_prefs, payload_len); + if (!MQTTPrefsAtomicStore::committed(result)) { + MESH_DEBUG_PRINTLN("MQTT: atomic /mqtt_prefs save failed at %s; source preserved", + mqttPrefsSaveResultName(result)); + return false; } + return true; } #endif @@ -734,6 +844,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re strcpy(reply, "ERR: clock cannot go backwards"); } } else if (memcmp(command, "memory", 6) == 0) { +#ifdef ESP_PLATFORM sprintf(reply, "Free: %d, Min: %d, Max: %d, Queue: %d, IntFree: %d, IntMax: %d, PSRAM: %d/%d", ESP.getFreeHeap(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap(), _callbacks->getQueueSize(), @@ -741,6 +852,16 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re (int)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL), (int)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), (int)heap_caps_get_total_size(MALLOC_CAP_SPIRAM)); +#else + // newlib arena stats — the portable equivalent on nRF52/RP2040. There is + // no min-ever-free or largest-free-block counterpart, so those fields are + // left out rather than filled with numbers that mean something different. + // Frags is the free-chunk count, the closest available fragmentation hint. + struct mallinfo mi = mallinfo(); + sprintf(reply, "Free: %d, Used: %d, Arena: %d, Frags: %d, Queue: %d", + (int)mi.fordblks, (int)mi.uordblks, (int)mi.arena, (int)mi.ordblks, + _callbacks->getQueueSize()); +#endif } else if (memcmp(command, "start ota", 9) == 0) { // Manual OTA: bring up the ElegantOTA web UI for a hand-uploaded binary. // Plain "start ota" serves on the station IP when joined to WiFi, else @@ -1099,13 +1220,37 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->disable_fwd = memcmp(&config[7], "off", 3) == 0; savePrefs(); strcpy(reply, _prefs->disable_fwd ? "OK - repeat is now OFF" : "OK - repeat is now ON"); -#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1110) } else if (memcmp(config, "radio.rxgain ", 13) == 0) { - _prefs->rx_boosted_gain = memcmp(&config[13], "on", 2) == 0; - strcpy(reply, "OK"); + bool enabled = memcmp(&config[13], "on", 2) == 0; + _prefs->rx_boosted_gain = enabled; savePrefs(); - _callbacks->setRxBoostedGain(_prefs->rx_boosted_gain); -#endif + if (_callbacks->setRxBoostedGain(enabled)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: unsupported"); + } + } else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) { + if (!_board->canControlLoRaFemLna()) { + strcpy(reply, "Error: unsupported"); + } else if (memcmp(&config[17], "on", 2) == 0) { + if (_board->setLoRaFemLnaEnabled(true)) { + _prefs->radio_fem_rxgain = 1; + savePrefs(); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (memcmp(&config[17], "off", 3) == 0) { + if (_board->setLoRaFemLnaEnabled(false)) { + _prefs->radio_fem_rxgain = 0; + savePrefs(); + strcpy(reply, "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } } else if (memcmp(config, "radio ", 6) == 0) { strcpy(tmp, &config[6]); const char *parts[4]; @@ -1304,8 +1449,30 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } } else { _prefs->adc_multiplier = 0.0f; - strcpy(reply, "Error: unsupported by this board"); + strcpy(reply, "Error: unsupported"); }; + #if defined(USE_LR2021) + } else if (memcmp(config, "extra.sf ", 9) == 0) { + strcpy(tmp, &config[9]); + const char *parts[4]; + uint8_t sideDetSFs[4]; + int num = mesh::Utils::parseTextParts(tmp, parts, 4); + if (num > 3) { + sprintf(reply, "Invalid extra SF config"); + } else { + for (int i = 0; i < num; i++) { + sideDetSFs[i] = atoi(parts[i]); + } + sideDetSFs[num] = 0; + if (_callbacks->configSideDetectors(sideDetSFs, num, _prefs->bw)) { + for (int i = 0; i <= num; i++) _prefs->extra_sf[i] = sideDetSFs[i]; + savePrefs(); + sprintf(reply, "OK - extra SFs set"); + } else { + sprintf(reply, "Invalid extra SF config"); + } + } + #endif } else { sprintf(reply, "unknown config: %s", config); } @@ -1357,10 +1524,14 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lat)); } else if (memcmp(config, "lon", 3) == 0) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lon)); -#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1110) } else if (memcmp(config, "radio.rxgain", 12) == 0) { sprintf(reply, "> %s", _prefs->rx_boosted_gain ? "on" : "off"); -#endif + } else if (memcmp(config, "radio.fem.rxgain", 16) == 0) { + if (!_board->canControlLoRaFemLna()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", _board->isLoRaFemLnaEnabled() ? "on" : "off"); + } } else if (memcmp(config, "radio", 5) == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(_prefs->freq)); @@ -1445,12 +1616,12 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "> unknown"); } #else - strcpy(reply, "ERROR: unsupported"); + strcpy(reply, "Error: unsupported"); #endif } else if (memcmp(config, "adc.multiplier", 14) == 0) { float adc_mult = _board->getAdcMultiplier(); if (adc_mult == 0.0f) { - strcpy(reply, "Error: unsupported by this board"); + strcpy(reply, "Error: unsupported"); } else { sprintf(reply, "> %.3f", adc_mult); } @@ -1468,19 +1639,23 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "ERROR: Power management not supported"); #endif } else if (memcmp(config, "pwrmgt.bootreason", 17) == 0) { -#ifdef NRF52_POWER_MANAGEMENT sprintf(reply, "> Reset: %s; Shutdown: %s", _board->getResetReasonString(_board->getResetReason()), _board->getShutdownReasonString(_board->getShutdownReason())); -#else - strcpy(reply, "ERROR: Power management not supported"); -#endif } else if (memcmp(config, "pwrmgt.bootmv", 13) == 0) { #ifdef NRF52_POWER_MANAGEMENT sprintf(reply, "> %u mV", _board->getBootVoltage()); #else strcpy(reply, "ERROR: Power management not supported"); #endif + } else if (memcmp(config, "extra.sf", 8) == 0) { + char* tmp = reply; + for (int i = 0; i < 3 && _prefs->extra_sf[i] != 0; i++) { + tmp += sprintf(tmp, "%s%d", (i == 0) ? "" : ",", _prefs->extra_sf[i]); + } + if (tmp == reply) { + sprintf(reply, "No extra SF configured"); + } } else { sprintf(reply, "??: %s", config); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 255ea0bc1..7bee051b3 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -6,6 +6,7 @@ #include #include // For MAX_MQTT_SLOTS (used in NodePrefs struct layout) #include +#include #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 +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 " ok|fail" list). virtual bool runMqttNtpDiag(char* reply, size_t reply_size, bool verbose) { return false; // WITH_MQTT_BRIDGE builds override }; - virtual void setRxBoostedGain(bool enable) { - // no op by default + virtual bool setRxBoostedGain(bool enable) { + return false; // CommonCLI reports unsupported if not overridden by wrapper }; + #if defined(USE_LR2021) + virtual bool configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { + return false; // Override in wrapper + } + #endif + // Fault-alert channel hooks (see NodePrefs::alert_*). The default no-op // implementations keep CLI commands harmless on builds that don't wire up // an AlertReporter. @@ -388,6 +340,12 @@ public: } }; +#ifdef WITH_MQTT_BRIDGE +namespace MQTTPrefsAtomicStore { +class LegacyUpgradeGate; +} +#endif + class CommonCLI { mesh::RTCClock* _rtc; NodePrefs* _prefs; @@ -400,19 +358,18 @@ class CommonCLI { #ifdef WITH_MQTT_BRIDGE MQTTPrefs _mqtt_prefs; LegacyObserverTail _legacy_tail; - // /mqtt_prefs carries a version newer than this firmware understands (a downgrade). - // The in-memory prefs run on defaults and saveMQTTPrefs() must not overwrite the - // file, or the first `set` command would destroy the newer config. + // /mqtt_prefs is newer, corrupt, or temporarily unreadable. The in-memory prefs + // run on defaults and saveMQTTPrefs() must not overwrite the source file. bool _mqtt_prefs_hold = false; #endif - bool _com_prefs_needs_upgrade = false; // old-format /com_prefs detected; rewrite once after load + bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load mesh::RTCClock* getRTCClock() { return _rtc; } void savePrefs(); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); #ifdef WITH_MQTT_BRIDGE - void loadMQTTPrefs(FILESYSTEM* fs); - void saveMQTTPrefs(FILESYSTEM* fs); + void loadMQTTPrefs(FILESYSTEM* fs, MQTTPrefsAtomicStore::LegacyUpgradeGate* legacy_upgrade); + bool saveMQTTPrefs(FILESYSTEM* fs); #endif void handleRegionCmd(char* command, char* reply); @@ -434,7 +391,7 @@ public: : _board(&board), _rtc(&rtc), _sensors(&sensors), _region_map(®ion_map), _acl(&acl), _prefs(prefs), _callbacks(callbacks) { } void loadPrefs(FILESYSTEM* _fs); - void savePrefs(FILESYSTEM* _fs); + bool savePrefs(FILESYSTEM* _fs, bool save_mqtt = true); void handleCommand(uint32_t sender_timestamp, char* command, char* reply); mesh::MainBoard* getBoard() { return _board; } uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data); diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 12c403dc3..c1b7e74a1 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -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 #include "CommonCLI.h" #include "TxtDataHelpers.h" #include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]() +#include "MQTTObserverValidation.h" // pure input validators (host-testable) #include #ifdef ESP_PLATFORM #include @@ -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; } diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp new file mode 100644 index 000000000..adff147f4 --- /dev/null +++ b/src/helpers/ConfigSerializer.cpp @@ -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 + +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 + } + } +} diff --git a/src/helpers/ConfigSerializer.h b/src/helpers/ConfigSerializer.h new file mode 100644 index 000000000..7e6d6f2a6 --- /dev/null +++ b/src/helpers/ConfigSerializer.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +#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); +}; diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 9893c2c73..120203ab0 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -1,6 +1,7 @@ #ifdef ESP_PLATFORM #include "ESP32Board.h" +#include #if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only #include @@ -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 diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index eb6c3a9d4..3ecf6735e 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -14,6 +14,7 @@ #include #include "soc/rtc.h" #include "esp_system.h" +#include 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 { diff --git a/src/helpers/ExternalWatchdogManager.h b/src/helpers/ExternalWatchdogManager.h new file mode 100644 index 000000000..cb4f49236 --- /dev/null +++ b/src/helpers/ExternalWatchdogManager.h @@ -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() { } +}; diff --git a/src/helpers/JWTHelper.cpp b/src/helpers/JWTHelper.cpp index 0b73d87ec..4e411e12b 100644 --- a/src/helpers/JWTHelper.cpp +++ b/src/helpers/JWTHelper.cpp @@ -1,3 +1,8 @@ +// MQTT-only translation unit. 22 variants re-glob helpers/*.cpp past the +// arduino_base exclusion, so the contents are guarded here rather than in +// the build filter — same idiom as helpers/esp32/WebConfigServer.cpp. +#ifdef WITH_MQTT_BRIDGE + #include "JWTHelper.h" #include #include @@ -196,3 +201,4 @@ size_t JWTHelper::createPayload( return base64UrlEncode((uint8_t*)jsonBuffer, len, output, outputSize); } +#endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTConnectionPolicy.h b/src/helpers/MQTTConnectionPolicy.h new file mode 100644 index 000000000..0ab4be12b --- /dev/null +++ b/src/helpers/MQTTConnectionPolicy.h @@ -0,0 +1,199 @@ +#pragma once + +#include + +// Pure timing and state-transition policy used by MQTTBridge's connection +// maintenance loop. Keeping these decisions independent of Arduino, WiFi, and +// the MQTT client lets host tests exercise the exact production policy with a +// deterministic clock. +namespace MQTTConnectionPolicy { + +static const uint32_t kReconnectGuardMs = 15000UL; +static const uint32_t kStableResetMs = 120000UL; +static const uint32_t kCircuitBreakerProbeMs = 1800000UL; +static const uint32_t kRenewalThrottleMs = 60000UL; +static const uint32_t kSlotStaggerMs = 3000UL; +static const uint8_t kMaxFailuresAtMaxBackoff = 3; +static const uint32_t kDefaultJwtLifetimeSecs = 86400UL; +static const uint32_t kMaxJwtStaggerSecs = 300UL; +static const uint32_t kMinimumValidEpoch = 1000000000UL; +static const uint32_t kJwtClockThreshold = 1735689600UL; // 2025-01-01 UTC +// A wall clock at or past this instant was set from a real source (NTP or an +// admin); anything earlier is the firmware's unset-clock default (1715770351, +// 15 May 2024), so a delta spanning the sync is meaningless. +static const uint32_t kSyncedClockEpoch = kJwtClockThreshold; + +// Unsigned subtraction is intentionally used: it is the standard millis() +// idiom and remains correct across a single 32-bit counter rollover. +static inline uint32_t elapsedMs(uint32_t now, uint32_t then) { + return now - then; +} + +static inline bool reconnectGuardActive(uint32_t now, uint32_t last_reconnect) { + return elapsedMs(now, last_reconnect) < kReconnectGuardMs; +} + +static inline bool stableConnection(uint32_t now, uint32_t connected_at) { + return connected_at != 0 && elapsedMs(now, connected_at) >= kStableResetMs; +} + +static inline uint32_t reconnectBackoffMs(uint8_t reconnect_backoff) { + static const uint32_t kBackoffMs[] = { + 10000UL, 30000UL, 60000UL, 120000UL, 300000UL + }; + const uint8_t index = reconnect_backoff < 5 ? reconnect_backoff : 4; + return kBackoffMs[index]; +} + +static inline uint32_t reconnectDelayMs(uint8_t reconnect_backoff, uint8_t slot_index) { + return reconnectBackoffMs(reconnect_backoff) + + static_cast(slot_index) * kSlotStaggerMs; +} + +static inline bool reconnectDue(uint32_t now, uint32_t last_attempt, + uint8_t reconnect_backoff, uint8_t slot_index) { + return elapsedMs(now, last_attempt) >= reconnectDelayMs(reconnect_backoff, slot_index); +} + +struct BackoffAdvance { + uint8_t reconnect_backoff; + uint8_t max_backoff_failures; + bool circuit_breaker_tripped; + bool should_reconnect; +}; + +// Advance the ladder immediately before a due reconnect. The first visit to +// the 300-second rung changes level 4 to the saturated marker 5. Three later +// failures at that rung trip the breaker; the third does not launch another +// connection attempt. +static inline BackoffAdvance advanceBackoff(uint8_t reconnect_backoff, + uint8_t max_backoff_failures) { + BackoffAdvance result = { + reconnect_backoff, max_backoff_failures, false, true + }; + if (result.reconnect_backoff < 5) { + result.reconnect_backoff++; + return result; + } + + if (result.max_backoff_failures < UINT8_MAX) { + result.max_backoff_failures++; + } + if (result.max_backoff_failures >= kMaxFailuresAtMaxBackoff) { + result.circuit_breaker_tripped = true; + result.should_reconnect = false; + } + return result; +} + +static inline bool circuitBreakerProbeDue(uint32_t now, uint32_t last_attempt) { + return elapsedMs(now, last_attempt) >= kCircuitBreakerProbeMs; +} + +// WiFi station reconnect backoff. The bridge drives its own STA reconnect loop +// separate from the per-slot MQTT reconnects, with a slightly longer first rung +// (15 s vs the slot ladder's 10 s). Extracted from handleWiFiConnection() so the +// ladder and its wrap-safe timing are exercised by host tests instead of a +// second inline copy of the backoff math. +static inline uint32_t wifiReconnectBackoffMs(uint8_t attempt) { + static const uint32_t kBackoffMs[] = { + 15000UL, 30000UL, 60000UL, 120000UL, 300000UL + }; + const uint8_t index = attempt < 5 ? attempt : 4; + return kBackoffMs[index]; +} + +// A reconnect is due only once the link has been down for the current rung AND +// no attempt has been made within that rung (both measured wrap-safely). This +// mirrors the two-part guard the bridge applied inline. +static inline bool wifiReconnectDue(uint32_t now, uint32_t disconnected_since, + uint32_t last_attempt, uint8_t attempt) { + const uint32_t delay = wifiReconnectBackoffMs(attempt); + return elapsedMs(now, disconnected_since) >= delay && + elapsedMs(now, last_attempt) >= delay; +} + +// The attempt counter climbs to 5 and then saturates; the index clamp in +// wifiReconnectBackoffMs() holds it at the 300 s rung. +static inline uint8_t nextWifiBackoffAttempt(uint8_t attempt) { + return attempt < 5 ? static_cast(attempt + 1) : attempt; +} + +// Each later slot expires up to five percent of the base lifetime earlier, +// capped at five minutes per slot. Runtime slot indexes are bounded by the +// persisted MQTT slot count; the final clamp also prevents underflow if this +// helper is used with unexpected input. +static inline uint32_t jwtLifetimeSecs(uint32_t base_lifetime, uint8_t slot_index) { + uint32_t per_slot_stagger = base_lifetime / 20UL; + if (per_slot_stagger > kMaxJwtStaggerSecs) { + per_slot_stagger = kMaxJwtStaggerSecs; + } + uint64_t stagger = static_cast(slot_index) * per_slot_stagger; + if (stagger > base_lifetime) { + stagger = base_lifetime; + } + return base_lifetime - static_cast(stagger); +} + +static inline uint32_t renewalBufferSecs(uint32_t lifetime_secs) { + uint32_t buffer = lifetime_secs / 10UL; + if (buffer < 60UL) buffer = 60UL; + if (buffer > 300UL) buffer = 300UL; + return buffer; +} + +static inline bool tokenNeedsRenewal(bool time_synced, uint32_t current_time, + uint32_t token_expires_at, + uint32_t renewal_buffer_secs) { + if (!time_synced) { + return token_expires_at == 0; + } + if (token_expires_at < kMinimumValidEpoch) { + return true; + } + if (current_time >= token_expires_at) { + return true; + } + return current_time >= token_expires_at - renewal_buffer_secs; +} + +static inline bool renewalAttemptAllowed(uint32_t now, uint32_t last_attempt) { + return elapsedMs(now, last_attempt) >= kRenewalThrottleMs; +} + +static inline bool jwtClockAvailable(bool ntp_synced, uint32_t current_time) { + return ntp_synced || current_time >= kJwtClockThreshold; +} + +// How a given MQTT slot fares at bridge setup on this hardware. MQTTBridge's +// setup loop iterates runtime slots in index order and connects the first +// `max_active` *enabled* slots, skipping the rest (each WSS/TLS link needs +// ~40 KB internal heap, so non-PSRAM caps at 2 concurrent, PSRAM at 5). Slots at +// or beyond the runtime array size (`slot_count`, e.g. 3 on non-PSRAM) are never +// iterated at all. Extracted so the CLI can tell the operator, at +// `set mqttN.preset` time, whether a slot will actually come up — and so the +// exact rule is host-tested rather than hand-reasoned (it is easy to conflate +// slot_count with max_active). +enum class SlotActivation : uint8_t { + Connects, // enabled and within the concurrent-connection budget + Disabled, // slot has no preset ("none") — not attempted + BeyondArray, // index >= slot_count: outside the runtime slot array here + OverActiveCap, // enabled, but lower-numbered slots already fill the budget +}; + +// `enabled` must have at least `slot_count` entries; `slot` is 0-based. Mirrors +// the bridge's first-come-by-index activation order exactly. +static inline SlotActivation classifySlotActivation(int slot, const bool* enabled, + int slot_count, int max_active) { + if (slot < 0) return SlotActivation::Disabled; + if (slot >= slot_count) return SlotActivation::BeyondArray; + if (enabled == nullptr || !enabled[slot]) return SlotActivation::Disabled; + int rank = 0; // this slot's position among enabled slots, counting by index + for (int i = 0; i <= slot; i++) { + if (enabled[i]) rank++; + } + return (rank <= max_active) ? SlotActivation::Connects + : SlotActivation::OverActiveCap; +} + +} // namespace MQTTConnectionPolicy diff --git a/src/helpers/MQTTDefaults.h b/src/helpers/MQTTDefaults.h index 72c58b1ae..5072e4229 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -5,6 +5,7 @@ #include #include #include "CommonCLI.h" +#include "MQTTPacketFilter.h" #include "MQTTPresets.h" // Compile-time defaults for fresh /mqtt_prefs (override via platformio build_flags). @@ -68,8 +69,6 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { prefs->mqtt_tx_enabled = 2; prefs->mqtt_rx_enabled = 1; prefs->mqtt_status_interval = 300000; - prefs->mqtt_neighbors_enabled = 0; - prefs->mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; prefs->wifi_power_save = 1; mqttDefaultSlotPreset(prefs->mqtt_slot_preset[0], sizeof(prefs->mqtt_slot_preset[0]), @@ -84,6 +83,9 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { MQTT_DEFAULT_SLOT5_PRESET); mqttDefaultSlotPreset(prefs->mqtt_slot_preset[5], sizeof(prefs->mqtt_slot_preset[5]), MQTT_DEFAULT_SLOT6_PRESET); + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { + prefs->mqtt_slot_packet_filter[i] = MQTTPacketFilter::kAllPacketTypes; + } if (MQTT_DEFAULT_IATA[0] != '\0') { strncpy(prefs->mqtt_iata, MQTT_DEFAULT_IATA, sizeof(prefs->mqtt_iata) - 1); @@ -102,6 +104,11 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { prefs->alert_wifi_minutes = 30; prefs->alert_mqtt_minutes = 240; prefs->alert_min_interval_min = 60; + + // Neighbors publishing defaults off; a defaulted tail is a valid 24h interval + // (not 0) so an in-lineage upgrade from a pre-neighbors payload is sane. + prefs->mqtt_neighbors_enabled = 0; + prefs->mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; } #endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTLifecycle.h b/src/helpers/MQTTLifecycle.h new file mode 100644 index 000000000..d85aafa9d --- /dev/null +++ b/src/helpers/MQTTLifecycle.h @@ -0,0 +1,328 @@ +#pragma once + +#include + +// Fork-owned, dependency-free MQTT bridge lifecycle state machine and the +// narrow dependency seam used to drive it deterministically in host tests. +// +// This is the Phase 4 "ownership and teardown test seam" from +// STABILITY_TESTABILITY_HANDOFF.md. It is intentionally pure (no Arduino, +// FreeRTOS, WiFi, or PsychicMqttClient dependencies) so the exact +// start/stop/restart contract can be exercised with a fake clock and a +// recording Ops double, the same way MQTTConnectionPolicy.h and +// MQTTRuntimeBufferLifecycle.h are tested. +// +// Scope boundary (Phase 4 vs Phase 5): this header is the SPEC and the test +// seam. It is not yet wired into MQTTBridge. Phase 5 ("Implement cooperative +// MQTT shutdown") supplies FreeRTOS/PsychicMqttClient-backed Ops and replaces +// the abrupt vTaskDelete in MQTTBridge::end() with this cooperative lifecycle. +// See MQTT_OWNERSHIP.md for the ownership model and the migration plan. +// +// Behavior source (Phase 0 discipline): every transition and invariant encoded +// here is derived from the current MQTTBridge.cpp control flow (begin()/end(), +// mqttTaskLoop(), the volatile handshakes) — not from a hardware soak. Values +// that require on-hardware characterization (the concrete stop timeout, exact +// mbedTLS teardown timing) are called out with "Phase 0 TODO" and left as +// injectable parameters rather than guessed constants. +namespace MQTTLifecycle { + +// The lifecycle proposed by the handoff: +// Stopped -> Starting -> Running -> StopRequested -> Stopping -> Stopped +// +// StopRequested: a stop has been requested and delivered through the ownership +// channel, but the MQTT task has not yet begun its ordered shutdown. +// Stopping: the MQTT task is performing its ordered client/service +// shutdown. A StopBegan signal is optional; a task may ack directly from +// StopRequested if it does not report the intermediate step. +enum class State : uint8_t { + Stopped = 0, + Starting, + Running, + StopRequested, + Stopping, +}; + +// Events driven either by the owner (loop task) or by the MQTT task reporting +// its own progress. StopTimedOut is synthesized by the Coordinator when a stop +// is not acknowledged within the bounded timeout (the reviewed fallback). +enum class Event : uint8_t { + StartRequested = 0, // owner asked the bridge to start + StartCompleted, // MQTT task signalled init complete (StartAck) + StartFailed, // init failed on the task (partial-init rollback) + StopRequested, // owner (or OTA barrier) asked the bridge to stop + StopBegan, // MQTT task began its ordered shutdown (optional) + StopAcknowledged, // MQTT task signalled ordered shutdown complete (StopAck) + StopTimedOut, // bounded timeout expired with no StopAck (fallback) +}; + +// Side effects a transition asks the caller to perform. Naming WHO does WHAT +// keeps the Phase 5 production wiring and the host fakes on one contract: +// - create_task: owner creates/pins the MQTT task. +// - deliver_stop: owner delivers the stop request through the channel. +// - release_resources: owner may now free the queue, runtime buffers, and the +// task handle. Fires only after a completed/forced stop +// or an init-failure rollback — never mid-run. +// - ota_release: the OTA barrier's completion acknowledgment is now +// available (a stop reached a terminal state). +struct Effects { + bool create_task = false; + bool deliver_stop = false; + bool release_resources = false; + bool ota_release = false; +}; + +struct Result { + State next; + Effects effects; + bool accepted; // false => the event was a no-op in this state (idempotency) +}; + +// Unsigned subtraction is the standard millis() idiom and stays correct across +// a single 32-bit rollover (mirrors MQTTConnectionPolicy::elapsedMs). +inline uint32_t elapsedMs(uint32_t now, uint32_t then) { return now - then; } + +// Pure transition function. For a rejected/no-op event the result reports the +// unchanged state, no effects, and accepted == false. +inline Result apply(State s, Event e) { + Result r{s, Effects{}, false}; + switch (s) { + case State::Stopped: + // Only a start is meaningful. A stop while already stopped is a no-op + // (idempotent stop). StartFailed/StopAck cannot occur here. + if (e == Event::StartRequested) { + r.next = State::Starting; + r.effects.create_task = true; + r.accepted = true; + } + break; + + case State::Starting: + switch (e) { + case Event::StartCompleted: + r.next = State::Running; + r.accepted = true; + break; + case Event::StartFailed: + // Partial-init rollback: release only what the attempt acquired. + r.next = State::Stopped; + r.effects.release_resources = true; + r.accepted = true; + break; + case Event::StopRequested: + // Stop before full initialization: accept it and let the task ack. + r.next = State::StopRequested; + r.effects.deliver_stop = true; + r.accepted = true; + break; + default: + break; + } + break; + + case State::Running: + if (e == Event::StopRequested) { + r.next = State::StopRequested; + r.effects.deliver_stop = true; + r.accepted = true; + } + // A duplicate StartRequested/StartCompleted while Running is a no-op. + break; + + case State::StopRequested: + switch (e) { + case Event::StopBegan: + r.next = State::Stopping; + r.accepted = true; + break; + case Event::StopAcknowledged: + case Event::StopTimedOut: + r.next = State::Stopped; + r.effects.release_resources = true; + r.effects.ota_release = true; + r.accepted = true; + break; + default: + // Duplicate StopRequested is a no-op (idempotent stop). + break; + } + break; + + case State::Stopping: + switch (e) { + case Event::StopAcknowledged: + case Event::StopTimedOut: + r.next = State::Stopped; + r.effects.release_resources = true; + r.effects.ota_release = true; + r.accepted = true; + break; + default: + break; + } + break; + } + return r; +} + +// New connects/publishes/retries/reconfigurations are permitted only while the +// bridge is actively running (or still coming up). Once a stop is requested, +// all new work must cease (handoff: "Cessation of new connects, publishes, +// retries, and reconfigurations"). +inline bool acceptsNewWork(State s) { + return s == State::Starting || s == State::Running; +} + +// A late/stale client callback may touch owner-released resources ONLY before +// the owner has freed them. After the terminal Stopped state the queue, +// buffers, and clients may be gone, so a callback arriving then must be a +// no-op. (Callbacks that fire during StopRequested/Stopping run before +// release_resources and are still safe.) +inline bool mayTouchOwnedState(State s) { return s != State::Stopped; } + +// A restart (begin()) is safe only from a completed stop. +inline bool mayRestart(State s) { return s == State::Stopped; } + +inline bool isStopInProgress(State s) { + return s == State::StopRequested || s == State::Stopping; +} + +inline const char* stateName(State s) { + switch (s) { + case State::Stopped: return "Stopped"; + case State::Starting: return "Starting"; + case State::Running: return "Running"; + case State::StopRequested: return "StopRequested"; + case State::Stopping: return "Stopping"; + } + return "?"; +} + +inline const char* eventName(Event e) { + switch (e) { + case Event::StartRequested: return "StartRequested"; + case Event::StartCompleted: return "StartCompleted"; + case Event::StartFailed: return "StartFailed"; + case Event::StopRequested: return "StopRequested"; + case Event::StopBegan: return "StopBegan"; + case Event::StopAcknowledged: return "StopAcknowledged"; + case Event::StopTimedOut: return "StopTimedOut"; + } + return "?"; +} + +// Narrow dependency seam. These are the only dependencies the lifecycle needs +// to be driven deterministically (handoff: "for only the dependencies needed"). +// Phase 5 implements this over FreeRTOS + PsychicMqttClient; host tests +// implement it as a recording double with a settable clock. +// +// Dependencies enumerated by the handoff and where they land: +// - Clock/timer -> nowMs() +// - Task start/stop/ack/timeout -> startTask()/deliverStop() + the +// onTaskStarted()/onTaskStopped() callbacks +// into Coordinator, and tick() for timeout +// - Runtime allocator + queue -> folded into releaseResources() here; the +// allocate/free symmetry itself is already +// covered by MQTTRuntimeBufferLifecycle.h, +// and queue behavior by Phase 6 +// - MQTT client connect/disconnect + delayed callbacks -> modeled by the +// mayTouchOwnedState() guard (a callback +// decides whether it may touch owned state) +// - OTA coordinator/barrier -> onStopComplete(clean) + mayBeginFlash() +struct Ops { + virtual ~Ops() = default; + virtual uint32_t nowMs() = 0; // monotonic ms (millis()) + virtual void startTask() = 0; // create/pin the MQTT task + virtual void deliverStop() = 0; // signal stop through the channel + virtual void releaseResources() = 0; // free queue/buffers/task (post-stop) + // Unblock the OTA barrier's waiter. clean == true after a StopAcknowledged; + // clean == false after a StopTimedOut, so OTA aborts rather than flashing + // under uncertain ownership (handoff OTA barrier: "MQTT stop times out: OTA + // aborts safely rather than writing under uncertain ownership"). + virtual void onStopComplete(bool clean) = 0; +}; + +// Drives the state machine against injected Ops and hosts the bounded stop +// timeout. Idempotent start/stop; safe restart after a completed stop. +class Coordinator { + public: + // stop_timeout_ms is the bound on how long a requested stop may run before the + // reviewed force-kill fallback fires. It is injected (not a constant here) and + // may be updated per stop via setStopTimeoutMs(): Phase 0 hardware + // characterization (2026-07-19) showed real mbedTLS/wss teardown scales with + // the number of connected slots (~5-6 s each, sequential), so the owner sizes + // it to the current slot count before each stop. See MQTTBridge::end(). + Coordinator(Ops& ops, uint32_t stop_timeout_ms) + : _ops(ops), _stop_timeout_ms(stop_timeout_ms) {} + + State state() const { return _state; } + bool stopTimedOut() const { return _stop_timed_out; } + + bool acceptsNewWork() const { return MQTTLifecycle::acceptsNewWork(_state); } + bool mayTouchOwnedState() const { + return MQTTLifecycle::mayTouchOwnedState(_state); + } + bool isStopInProgress() const { + return MQTTLifecycle::isStopInProgress(_state); + } + // A restart is safe from a completed stop. A stop that reached Stopped via + // the timeout fallback still allows restart (the bridge is down); only OTA + // flashing is withheld after a dirty stop. + bool mayRestart() const { return MQTTLifecycle::mayRestart(_state); } + // OTA erase/write is permitted only after a CLEAN stop. A timed-out stop + // leaves ownership uncertain, so flashing stays blocked until a clean + // start/stop cycle clears the latch. + bool mayBeginFlash() const { + return _state == State::Stopped && !_stop_timed_out; + } + + // Update the stop-timeout bound. Call before requestStop() to size the window + // to the current slot count (see MQTTBridge::end()); the value is read by + // tick() against _stop_request_ms, which requestStop() arms afterwards. + void setStopTimeoutMs(uint32_t ms) { _stop_timeout_ms = ms; } + uint32_t stopTimeoutMs() const { return _stop_timeout_ms; } + + bool requestStart() { return dispatch(Event::StartRequested); } + bool requestStop() { return dispatch(Event::StopRequested); } + bool onTaskStarted() { return dispatch(Event::StartCompleted); } + bool onTaskStartFailed() { return dispatch(Event::StartFailed); } + bool onStopBegan() { return dispatch(Event::StopBegan); } + bool onTaskStopped() { return dispatch(Event::StopAcknowledged); } + + // Call periodically from the owner. Fires the reviewed timeout fallback if a + // requested stop has not been acknowledged within stop_timeout_ms. + void tick() { + if (MQTTLifecycle::isStopInProgress(_state) && + elapsedMs(_ops.nowMs(), _stop_request_ms) >= _stop_timeout_ms) { + dispatch(Event::StopTimedOut); + } + } + + private: + bool dispatch(Event e) { + const Result r = apply(_state, e); + if (!r.accepted) return false; + + _state = r.next; + if (e == Event::StartRequested) { + _stop_timed_out = false; // a fresh start clears the dirty-stop latch + } else if (e == Event::StopRequested) { + _stop_request_ms = _ops.nowMs(); // arm the timeout window + } else if (e == Event::StopTimedOut) { + _stop_timed_out = true; + } + + if (r.effects.create_task) _ops.startTask(); + if (r.effects.deliver_stop) _ops.deliverStop(); + if (r.effects.release_resources) _ops.releaseResources(); + if (r.effects.ota_release) _ops.onStopComplete(e == Event::StopAcknowledged); + return true; + } + + Ops& _ops; + uint32_t _stop_timeout_ms; + State _state = State::Stopped; + uint32_t _stop_request_ms = 0; + bool _stop_timed_out = false; +}; + +} // namespace MQTTLifecycle diff --git a/src/helpers/MQTTMessageBuilder.cpp b/src/helpers/MQTTMessageBuilder.cpp index a49f7f06f..3ec13277e 100644 --- a/src/helpers/MQTTMessageBuilder.cpp +++ b/src/helpers/MQTTMessageBuilder.cpp @@ -1,4 +1,10 @@ +// MQTT-only translation unit. 22 variants re-glob helpers/*.cpp past the +// arduino_base exclusion, so the contents are guarded here rather than in +// the build filter — same idiom as helpers/esp32/WebConfigServer.cpp. +#ifdef WITH_MQTT_BRIDGE + #include "MQTTMessageBuilder.h" +#include "MQTTPayloadBuilder.h" #include #include #include @@ -53,66 +59,11 @@ int MQTTMessageBuilder::buildStatusMessage( int packets_received, const char* repeat ) { - // doc is provided by the caller (heap-allocated DynamicJsonDocument in MQTTBridge), - // keeping this 768-byte scratch space off the MQTT task stack. - doc.clear(); - JsonObject root = doc.to(); - - root["status"] = status; - root["timestamp"] = timestamp; - root["origin"] = origin; - root["origin_id"] = origin_id; - root["model"] = model; - root["firmware_version"] = firmware_version; - root["radio"] = radio; - root["client_version"] = client_version; - if (repeat != nullptr) { - root["repeat"] = repeat; - } - - // Add stats object if any stats are provided - if (battery_mv >= 0 || uptime_secs >= 0 || errors >= 0 || queue_len >= 0 || - noise_floor > -999 || tx_air_secs >= 0 || rx_air_secs >= 0 || recv_errors >= 0 || - internal_heap >= 0 || packets_sent >= 0 || packets_received >= 0) { - JsonObject stats = root.createNestedObject("stats"); - - if (battery_mv >= 0) { - stats["battery_mv"] = battery_mv; - } - if (uptime_secs >= 0) { - stats["uptime_secs"] = uptime_secs; - } - if (packets_sent >= 0) { - stats["packets_sent"] = packets_sent; - } - if (packets_received >= 0) { - stats["packets_received"] = packets_received; - } - if (errors >= 0) { - stats["errors"] = errors; - } - if (queue_len >= 0) { - stats["queue_len"] = queue_len; - } - if (noise_floor > -999) { - stats["noise_floor"] = noise_floor; - } - if (tx_air_secs >= 0) { - stats["tx_air_secs"] = tx_air_secs; - } - if (rx_air_secs >= 0) { - stats["rx_air_secs"] = rx_air_secs; - } - if (recv_errors >= 0) { - stats["recv_errors"] = recv_errors; - } - if (internal_heap >= 0) { - stats["internal_heap"] = internal_heap; - } - } - - size_t len = serializeJson(root, buffer, buffer_size); - return (len > 0 && len < buffer_size) ? len : 0; + return MQTTPayloadBuilder::buildStatusMessage( + doc, origin, origin_id, model, firmware_version, radio, client_version, + status, timestamp, buffer, buffer_size, battery_mv, uptime_secs, errors, + queue_len, noise_floor, tx_air_secs, rx_air_secs, recv_errors, internal_heap, + packets_sent, packets_received, repeat); } int MQTTMessageBuilder::buildPacketMessage( @@ -138,71 +89,10 @@ int MQTTMessageBuilder::buildPacketMessage( char* buffer, size_t buffer_size ) { - // doc is provided by the caller (heap-allocated DynamicJsonDocument in MQTTBridge), - // keeping this 2048-byte scratch space off the MQTT task stack. - doc.clear(); - JsonObject root = doc.to(); - - // Format numeric values as strings to avoid String object allocations - char len_str[16]; - char packet_type_str[16]; - char payload_len_str[16]; - char snr_str[16]; - char rssi_str[16]; - char score_str[16]; - - snprintf(len_str, sizeof(len_str), "%d", len); - snprintf(packet_type_str, sizeof(packet_type_str), "%d", packet_type); - snprintf(payload_len_str, sizeof(payload_len_str), "%d", payload_len); - snprintf(snr_str, sizeof(snr_str), "%.1f", snr); - snprintf(rssi_str, sizeof(rssi_str), "%d", rssi); - - root["timestamp"] = timestamp; - root["hash"] = hash; - root["origin"] = origin; - root["type"] = "PACKET"; - root["direction"] = direction; - root["time"] = time; - root["date"] = date; - root["len"] = len_str; - root["packet_type"] = packet_type_str; - root["route"] = route; - root["payload_len"] = payload_len_str; - root["raw"] = raw; - root["origin_id"] = origin_id; - // SNR and RSSI are only meaningful for RX packets (received from radio) - if (strcmp(direction, "rx") == 0) { - root["SNR"] = snr_str; - root["RSSI"] = rssi_str; - // Firmware's rebroadcast "score" for this RX packet, scaled x1000 to match the - // integer form printed in the serial RX log (see Dispatcher::checkRecv()). - if (!isnan(score)) { - snprintf(score_str, sizeof(score_str), "%d", (int)(score * 1000)); - root["score"] = score_str; - } - } - - // Routing path as an array of lowercase hex hop tokens, one element per hop - // (e.g. ["aa","bb","cc"], or ["aaaa","bbbb"] for multi-byte hashes). This matches - // meshcore-packet-capture's _split_path_hops() representation. - if (path_bytes && path_hop_count > 0 && path_hash_size > 0) { - JsonArray path_arr = root.createNestedArray("path"); - char hop_hex[2 * 4 + 1]; // hop hash is 1-4 bytes -> up to 8 hex chars + null - for (int i = 0; i < path_hop_count; i++) { - size_t pos = 0; - for (int b = 0; b < path_hash_size && b < 4; b++) { - size_t idx = (size_t)i * path_hash_size + b; - if (idx >= MAX_PATH_SIZE) break; - snprintf(hop_hex + pos, 3, "%02x", path_bytes[idx]); - pos += 2; - } - hop_hex[pos] = '\0'; - path_arr.add(hop_hex); // char[] (non-const) -> ArduinoJson copies the string - } - } - - size_t json_len = serializeJson(root, buffer, buffer_size); - return (json_len > 0 && json_len < buffer_size) ? json_len : 0; + return MQTTPayloadBuilder::buildPacketMessage( + doc, origin, origin_id, timestamp, direction, time, date, len, packet_type, + route, payload_len, raw, snr, rssi, score, hash, path_bytes, path_hop_count, + path_hash_size, MAX_PATH_SIZE, buffer, buffer_size); } int MQTTMessageBuilder::buildRawMessage( @@ -213,18 +103,8 @@ int MQTTMessageBuilder::buildRawMessage( char* buffer, size_t buffer_size ) { - // Use StaticJsonDocument to avoid heap fragmentation (fixed-size stack allocation) - StaticJsonDocument<512> doc; - JsonObject root = doc.to(); - - root["origin"] = origin; - root["origin_id"] = origin_id; - root["timestamp"] = timestamp; - root["type"] = "RAW"; - root["data"] = raw; - - size_t len = serializeJson(root, buffer, buffer_size); - return (len > 0 && len < buffer_size) ? len : 0; + return MQTTPayloadBuilder::buildRawMessage( + origin, origin_id, timestamp, raw, buffer, buffer_size); } int MQTTMessageBuilder::buildNeighborsMessage( @@ -233,45 +113,38 @@ int MQTTMessageBuilder::buildNeighborsMessage( const char* origin_id, const char* timestamp, const char* self_scopes, + const char* self_default_scope, const NeighborsMessageEntry* neighbors, int neighbor_count, char* buffer, - size_t buffer_size + size_t buffer_size, + int total_neighbors, + int queried_neighbors, + bool truncated ) { - if (!buffer || buffer_size == 0) return 0; + return MQTTPayloadBuilder::buildNeighborsMessage( + doc, origin, origin_id, timestamp, self_scopes, self_default_scope, + neighbors, neighbor_count, buffer, buffer_size, total_neighbors, + queried_neighbors, truncated); +} - doc.clear(); - JsonObject root = doc.to(); +size_t MQTTMessageBuilder::measureNeighborsMessageBase( + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const char* self_default_scope, + int total_neighbors +) { + return MQTTPayloadBuilder::measureNeighborsMessageBase( + origin, origin_id, timestamp, self_scopes, self_default_scope, + total_neighbors); +} - root["timestamp"] = timestamp; - root["origin"] = origin; - root["origin_id"] = origin_id; - - JsonObject self = root.createNestedObject("self"); - self["scopes"] = self_scopes ? self_scopes : ""; - - JsonArray arr = root.createNestedArray("neighbors"); - if (measureJson(root) >= buffer_size) return 0; - - for (int i = 0; i < neighbor_count; i++) { - JsonObject nb = arr.createNestedObject(); - nb["pubkey"] = neighbors[i].pubkey_hex; - nb["snr"] = neighbors[i].snr; - nb["heard_secs_ago"] = neighbors[i].heard_secs_ago; - nb["scopes"] = neighbors[i].scopes ? neighbors[i].scopes : ""; - nb["status"] = neighbors[i].status; - - // Callers order neighbors from most to least useful (newest, then - // strongest). Stop as soon as the next entry would fill the fixed publish - // buffer, keeping document growth bounded and dropping the remaining tail. - if (measureJson(root) >= buffer_size) { - arr.remove(arr.size() - 1); - break; - } - } - - size_t len = serializeJson(root, buffer, buffer_size); - return (len > 0 && len < buffer_size) ? len : 0; +size_t MQTTMessageBuilder::measureNeighborsMessageEntry( + const NeighborsMessageEntry& neighbor +) { + return MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbor); } int MQTTMessageBuilder::buildPacketJSON( @@ -458,6 +331,11 @@ const char* MQTTMessageBuilder::getRouteTypeString(int route_type) { } void MQTTMessageBuilder::bytesToHex(const uint8_t* data, size_t len, char* hex, size_t hex_size) { + if (hex == nullptr || hex_size == 0) return; + // Guarantee a valid (empty) string even if we bail out below, so a caller's + // uninitialized stack buffer is never serialized into the JSON raw/hash fields + // when the buffer is too small (A6). + hex[0] = '\0'; if (hex_size < len * 2 + 1) return; // Nibble lookup instead of a per-byte snprintf("%02X"): same uppercase hex @@ -471,6 +349,11 @@ void MQTTMessageBuilder::bytesToHex(const uint8_t* data, size_t len, char* hex, } void MQTTMessageBuilder::packetToHex(mesh::Packet* packet, char* hex, size_t hex_size) { + if (hex == nullptr || hex_size == 0) return; + // Empty string on any early-out below (serialization returned nothing, or the + // hex buffer is too small) so an uninitialized raw_hex[] never reaches the + // published JSON (A6). + hex[0] = '\0'; // Serialize full on-air/wire format using Packet::writeTo() // This includes header, transport codes (if present), path_len, path, and payload uint8_t raw_buf[512]; @@ -483,3 +366,5 @@ void MQTTMessageBuilder::packetToHex(mesh::Packet* packet, char* hex, size_t hex // Convert serialized packet to hex bytesToHex(raw_buf, raw_len, hex, hex_size); } + +#endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTMessageBuilder.h b/src/helpers/MQTTMessageBuilder.h index c230741ff..2d1fc8051 100644 --- a/src/helpers/MQTTMessageBuilder.h +++ b/src/helpers/MQTTMessageBuilder.h @@ -2,6 +2,7 @@ #include "MeshCore.h" #include +#include "MQTTPayloadBuilder.h" #include #include @@ -153,28 +154,34 @@ public: size_t buffer_size ); - struct NeighborsMessageEntry { - const char* pubkey_hex; - float snr; - uint32_t heard_secs_ago; - const char* scopes; - const char* status; - }; - - /** - * Build neighbors table JSON for meshcore/{iata}/{device}/neighbors topic. - */ + // Neighbors table entry + JSON builder. The layout logic lives in the pure, + // host-tested MQTTPayloadBuilder; this is the firmware-facing alias/delegate, + // matching the status/packet/raw builders. + using NeighborsMessageEntry = MQTTPayloadBuilder::NeighborsMessageEntry; static int buildNeighborsMessage( JsonDocument& doc, const char* origin, const char* origin_id, const char* timestamp, const char* self_scopes, + const char* self_default_scope, const NeighborsMessageEntry* neighbors, int neighbor_count, char* buffer, - size_t buffer_size + size_t buffer_size, + int total_neighbors = -1, + int queried_neighbors = -1, + bool truncated = false ); + static size_t measureNeighborsMessageBase( + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const char* self_default_scope, + int total_neighbors + ); + static size_t measureNeighborsMessageEntry(const NeighborsMessageEntry& neighbor); /** * Convert packet to JSON message diff --git a/src/helpers/MQTTObserverValidation.h b/src/helpers/MQTTObserverValidation.h new file mode 100644 index 000000000..2042f2267 --- /dev/null +++ b/src/helpers/MQTTObserverValidation.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include + +// Pure, dependency-free validators for the observer's CLI/web configuration +// inputs. Factored out of CommonCLI_Observer.cpp so the exact logic the setters +// enforce can be unit-tested on the host (see test/test_observer_validation) +// rather than only through the full CLI object. + +// IATA region code: exactly three ASCII alphanumerics. The value is placed +// directly into MQTT topic paths (meshcore/{iata}/...), so anything else (wrong +// length, spaces, topic separators) is rejected. Case is preserved here; the +// setter uppercases after validation. +static inline bool mqttIataValid(const char* s) { + if (!s || strlen(s) != 3) return false; + for (int i = 0; i < 3; i++) { + char c = s[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))) { + return false; + } + } + return true; +} + +// Owner public key: exactly 64 hex characters (a 32-byte Ed25519 key), any case. +static inline bool mqttOwnerKeyValid(const char* s) { + if (!s || strlen(s) != 64) return false; + for (int i = 0; i < 64; i++) { + char c = s[i]; + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) { + return false; + } + } + return true; +} + +// NTP hostname: non-empty, <= 63 chars, made of letters/digits/'.'/'-', with no +// leading or trailing dot. ("none" is handled as a clear by the caller.) +static inline bool mqttNtpHostnameValid(const char* host) { + if (!host || host[0] == '\0') return false; + size_t len = strlen(host); + if (len > 63) return false; + if (host[0] == '.' || host[len - 1] == '.') return false; + for (size_t i = 0; i < len; i++) { + char c = host[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '.' || c == '-')) { + return false; + } + } + return true; +} + +// A value fits its fixed destination buffer, which must hold the string plus a +// NUL terminator (so the usable length is bufsize - 1). Used to reject an +// over-long submission up front instead of silently truncating it. +static inline bool mqttValueFits(const char* s, size_t bufsize) { + return s != NULL && bufsize > 0 && strlen(s) < bufsize; +} diff --git a/src/helpers/MQTTPacketFilter.h b/src/helpers/MQTTPacketFilter.h new file mode 100644 index 000000000..df8118282 --- /dev/null +++ b/src/helpers/MQTTPacketFilter.h @@ -0,0 +1,226 @@ +#pragma once + +#include +#include +#include + +// Pure per-broker packet-type allowlist helpers. MeshCore payload types occupy +// the low four bits of the packet header, so a uint16_t stores the complete +// 0..15 allowlist without dynamic allocation. +namespace MQTTPacketFilter { + +static const uint8_t kMinPacketType = 0; +static const uint8_t kMaxPacketType = 15; +static const uint16_t kAllPacketTypes = 0xFFFFu; +// "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15" plus the terminator. +static const size_t kFilterTextSize = 38; + +inline bool isAsciiSpace(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} + +inline bool tokenEquals(const char* begin, size_t len, const char* token) { + return token != nullptr && strlen(token) == len && memcmp(begin, token, len) == 0; +} + +// Payload-type spellings accepted alongside the decimal form, mirroring the +// PAYLOAD_TYPE_* names in src/Packet.h. Types 12-14 are reserved upstream and +// have no name, so they stay reachable only by number. +struct NamedPacketType { + const char* name; + uint8_t type; +}; + +inline const NamedPacketType* namedPacketTypes(size_t* count_out) { + static const NamedPacketType kNames[] = { + {"req", 0}, {"response", 1}, {"txt_msg", 2}, {"ack", 3}, + {"advert", 4}, {"grp_txt", 5}, {"grp_data", 6}, {"anon_req", 7}, + {"path", 8}, {"trace", 9}, {"multipart", 10}, {"control", 11}, + {"raw_custom", 15}, + }; + if (count_out != nullptr) *count_out = sizeof(kNames) / sizeof(kNames[0]); + return kNames; +} + +// Resolve one already-trimmed list entry to a payload type. Decimal and named +// spellings are interchangeable within a list; "all"/"none" are whole-value +// keywords and deliberately do not resolve here, so "all,2" stays invalid. +inline bool resolveToken(const char* begin, size_t len, uint8_t* type_out) { + if (begin == nullptr || type_out == nullptr || len == 0) return false; + + if (*begin >= '0' && *begin <= '9') { + unsigned value = 0; + for (size_t i = 0; i < len; ++i) { + if (begin[i] < '0' || begin[i] > '9') return false; + value = value * 10u + static_cast(begin[i] - '0'); + if (value > kMaxPacketType) return false; + } + *type_out = static_cast(value); + return true; + } + + size_t name_count = 0; + const NamedPacketType* names = namedPacketTypes(&name_count); + for (size_t i = 0; i < name_count; ++i) { + if (tokenEquals(begin, len, names[i].name)) { + *type_out = names[i].type; + return true; + } + } + return false; +} + +// Parse an allowlist value. Empty input means "all" so WebConfig can clear a +// field and retain the same backwards-compatible default as an older +// /mqtt_prefs file. Keywords and type names are deliberately lowercase; "all" +// and "none" cannot be mixed into a list. Entries may be decimal (0..15) or +// named, may carry surrounding ASCII whitespace, and may be repeated. +inline bool parse(const char* input, uint16_t* mask_out) { + if (input == nullptr || mask_out == nullptr) return false; + + const char* begin = input; + while (*begin && isAsciiSpace(*begin)) begin++; + const char* end = begin + strlen(begin); + while (end > begin && isAsciiSpace(end[-1])) end--; + + const size_t len = static_cast(end - begin); + if (len == 0 || tokenEquals(begin, len, "all")) { + *mask_out = kAllPacketTypes; + return true; + } + if (tokenEquals(begin, len, "none")) { + *mask_out = 0; + return true; + } + + uint16_t parsed = 0; + const char* cursor = begin; + for (;;) { + const char* comma = cursor; + while (comma < end && *comma != ',') comma++; + + const char* token_begin = cursor; + const char* token_end = comma; + while (token_begin < token_end && isAsciiSpace(*token_begin)) token_begin++; + while (token_end > token_begin && isAsciiSpace(token_end[-1])) token_end--; + + uint8_t type = 0; + if (!resolveToken(token_begin, static_cast(token_end - token_begin), &type)) { + return false; + } + parsed |= static_cast(1u << type); + + if (comma >= end) break; + cursor = comma + 1; + } + + *mask_out = parsed; + return true; +} + +// Format masks deterministically for CLI/API output. Subsets are emitted in +// ascending order; the two useful extremes use concise keywords. +inline bool format(uint16_t mask, char* output, size_t output_size) { + if (output == nullptr || output_size == 0) return false; + output[0] = '\0'; + + const char* keyword = nullptr; + if (mask == kAllPacketTypes) keyword = "all"; + else if (mask == 0) keyword = "none"; + if (keyword != nullptr) { + const size_t len = strlen(keyword); + if (output_size <= len) return false; + memcpy(output, keyword, len + 1); + return true; + } + + char formatted[kFilterTextSize]; + size_t pos = 0; + bool first = true; + for (uint8_t type = kMinPacketType; type <= kMaxPacketType; ++type) { + if ((mask & static_cast(1u << type)) == 0) continue; + if (!first) formatted[pos++] = ','; + if (type >= 10) formatted[pos++] = '1'; + formatted[pos++] = static_cast('0' + (type % 10)); + first = false; + } + formatted[pos] = '\0'; + + if (output_size <= pos) return false; + memcpy(output, formatted, pos + 1); + return true; +} + +// The keyword shortcut above means format() never actually emits the whole +// 0..15 list, but the emit loop is written for every type, so the internal +// buffer must still hold it: 22 digits + 15 separators + NUL. +static_assert(kFilterTextSize >= 38, "filter text buffer must hold the full 0..15 list"); + +inline bool allows(uint16_t mask, uint8_t packet_type) { + return packet_type <= kMaxPacketType && + (mask & static_cast(1u << packet_type)) != 0; +} + +// How many types a mask allows. Lets a length-constrained reply fall back to +// "N/16" instead of a clipped list, which would read as a different allowlist. +inline uint8_t countTypes(uint16_t mask) { + uint8_t total = 0; + for (uint8_t type = kMinPacketType; type <= kMaxPacketType; ++type) { + if ((mask & static_cast(1u << type)) != 0) total++; + } + return total; +} + +// Conservative "could any configured broker want this type?" mask, used to +// reject a packet before it is copied into the publish queue. Disabled slots +// contribute nothing; a slot whose topic style later turns out not to support +// the publication is still counted here, so the gate never drops a packet the +// per-slot pass would have published. +inline uint16_t enabledUnion(const uint16_t* masks, const bool* enabled, size_t count) { + if (masks == nullptr) return 0; + uint16_t combined = 0; + for (size_t i = 0; i < count; ++i) { + if (enabled != nullptr && !enabled[i]) continue; + combined = static_cast(combined | masks[i]); + } + return combined; +} + +// True when every slot still carries the default all-types mask, i.e. nothing +// depends on the packet-filter tail of /mqtt_prefs being written. +inline bool allMasksDefault(const uint16_t* masks, size_t count) { + if (masks == nullptr) return true; + for (size_t i = 0; i < count; ++i) { + if (masks[i] != kAllPacketTypes) return false; + } + return true; +} + +// Stage one of the publish gate: everything decidable before a packet is +// serialised or a topic is built. Cheap enough to run per packet per slot. +inline bool slotCandidate(bool slot_enabled, uint16_t mask, uint8_t packet_type) { + return slot_enabled && allows(mask, packet_type); +} + +// The complete gate, once the slot's topic support is known. Eligibility +// deliberately excludes connection state: a temporarily disconnected broker +// that is configured for this type still requires the shared queue's existing +// bounded retry policy. +// +// eligiblePacketSlots() calls this directly, using slotCandidate() first only +// to skip the topic build for slots the mask already rejects. A slot that +// passes both therefore has its topic built again in the publish loop; that +// second build is the accepted cost of not carrying six 128-byte topics on the +// MQTT task stack. +inline bool slotEligible(bool slot_enabled, bool topic_supported, + uint16_t mask, uint8_t packet_type) { + return slotCandidate(slot_enabled, mask, packet_type) && topic_supported; +} + +// A fully filtered/topic-incompatible packet is intentionally complete. Once +// any eligible target exists, at least one actual publish must succeed. +inline bool publishComplete(bool has_eligible_target, bool any_publish_succeeded) { + return any_publish_succeeded || !has_eligible_target; +} + +} // namespace MQTTPacketFilter diff --git a/src/helpers/MQTTPacketQueuePolicy.h b/src/helpers/MQTTPacketQueuePolicy.h new file mode 100644 index 000000000..4272d4c86 --- /dev/null +++ b/src/helpers/MQTTPacketQueuePolicy.h @@ -0,0 +1,116 @@ +#pragma once + +#include +#include + +// Pure queue/backpressure policy shared by the FreeRTOS and circular-buffer +// MQTT packet queues. Keeping the timing and retry decisions here makes the +// production behavior deterministic under host tests without mocking either +// queue implementation or the MQTT client. +namespace MQTTPacketQueuePolicy { + +static const uint32_t kDisconnectedStaleMs = 300000UL; +static const size_t kBacklogThreshold = 5; +static const uint8_t kGentleDrainCount = 1; +static const uint8_t kBurstDrainCount = 5; +static const uint32_t kGentleDrainBudgetMs = 30UL; +static const uint32_t kBurstDrainBudgetMs = 100UL; +static const uint8_t kMaxQos0RetryAttempts = 3; +static const uint32_t kRetryDelayBaseMs = 300UL; +static const uint32_t kRetryDelayJitterMs = 200UL; + +// Unsigned subtraction is the standard millis() idiom and remains correct +// across one 32-bit counter rollover. +static inline uint32_t elapsedMs(uint32_t now, uint32_t then) { + return now - then; +} + +enum class EnqueueAction : uint8_t { + Enqueue, + EvictOldestThenEnqueue, + Reject +}; + +static inline EnqueueAction enqueueAction(size_t queue_count, size_t capacity) { + if (capacity == 0) return EnqueueAction::Reject; + return queue_count >= capacity + ? EnqueueAction::EvictOldestThenEnqueue + : EnqueueAction::Enqueue; +} + +// disconnected_since == 0 means tracking has not started. The bridge records +// the first disconnected observation and asks this helper on later cycles. +static inline bool shouldFlushDisconnected(uint32_t now, + uint32_t disconnected_since, + uint32_t stale_ms = kDisconnectedStaleMs) { + return disconnected_since != 0 && elapsedMs(now, disconnected_since) >= stale_ms; +} + +struct DrainBudget { + uint8_t max_packets; + uint32_t max_time_ms; +}; + +static inline DrainBudget drainBudget(size_t queue_count) { + if (queue_count > kBacklogThreshold) { + return {kBurstDrainCount, kBurstDrainBudgetMs}; + } + return {kGentleDrainCount, kGentleDrainBudgetMs}; +} + +static inline bool drainTimeAvailable(uint32_t now, uint32_t started_at, + uint32_t budget_ms) { + // Preserve the bridge's inclusive boundary: work may begin at exactly the + // configured limit, but not one millisecond later. + return elapsedMs(now, started_at) <= budget_ms; +} + +// retry_attempts distinguishes an unscheduled packet from a scheduled retry +// whose deadline wrapped to exactly zero. Deadlines are always less than +// 500 ms away, so the half-range comparison is unambiguous. +static inline bool retryReady(uint32_t now, uint32_t next_retry_ms, + uint8_t retry_attempts) { + if (retry_attempts == 0) return true; + return elapsedMs(now, next_retry_ms) < 0x80000000UL; +} + +enum class RetryAction : uint8_t { + Complete, + Schedule, + Drop +}; + +struct RetryDecision { + RetryAction action; + uint8_t retry_attempts; + uint32_t delay_ms; + uint32_t next_retry_ms; +}; + +// A queued packet counts as delivered if EITHER its structured-packet publish +// or its raw-frame publish reached at least one slot. Partial success (one +// succeeds while the other fails or was not attempted) is still success — the +// packet completes and is not retried. This is the (packet, raw) outcome pairing +// fed to retryDecision(); naming it keeps the "partial publish = done" contract +// explicit and host-tested rather than inline in the bridge's queue drain. +static inline bool queuedPacketPublished(bool packet_published, + bool raw_published) { + return packet_published || raw_published; +} + +static inline RetryDecision retryDecision(bool any_published, + uint8_t retry_attempts, + uint32_t now) { + if (any_published) { + return {RetryAction::Complete, retry_attempts, 0, 0}; + } + if (retry_attempts >= kMaxQos0RetryAttempts) { + return {RetryAction::Drop, retry_attempts, 0, 0}; + } + + const uint32_t delay = kRetryDelayBaseMs + (now % kRetryDelayJitterMs); + return {RetryAction::Schedule, static_cast(retry_attempts + 1), + delay, now + delay}; +} + +} // namespace MQTTPacketQueuePolicy diff --git a/src/helpers/MQTTPayloadBuilder.cpp b/src/helpers/MQTTPayloadBuilder.cpp new file mode 100644 index 000000000..f1b492236 --- /dev/null +++ b/src/helpers/MQTTPayloadBuilder.cpp @@ -0,0 +1,294 @@ +#include "MQTTPayloadBuilder.h" + +#include +#include +#include + +namespace { + +static int serializeComplete(JsonObject root, char* buffer, size_t buffer_size) { + if (!buffer || buffer_size == 0) return 0; + + size_t written = serializeJson(root, buffer, buffer_size); + // Preserve MQTTMessageBuilder's existing success criterion while clearing + // ArduinoJson's truncated prefix on failure. Callers publish only a positive + // return value, and now a failed buffer cannot be mistaken for complete JSON. + if (written == 0 || written >= buffer_size) { + buffer[0] = '\0'; + return 0; + } + return static_cast(written); +} + +} // namespace + +int MQTTPayloadBuilder::buildStatusMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* model, + const char* firmware_version, + const char* radio, + const char* client_version, + const char* status, + const char* timestamp, + char* buffer, + size_t buffer_size, + int battery_mv, + int uptime_secs, + int errors, + int queue_len, + int noise_floor, + int tx_air_secs, + int rx_air_secs, + int recv_errors, + int internal_heap, + int packets_sent, + int packets_received, + const char* repeat +) { + doc.clear(); + JsonObject root = doc.to(); + + root["status"] = status; + root["timestamp"] = timestamp; + root["origin"] = origin; + root["origin_id"] = origin_id; + root["model"] = model; + root["firmware_version"] = firmware_version; + root["radio"] = radio; + root["client_version"] = client_version; + if (repeat != nullptr) { + root["repeat"] = repeat; + } + + if (battery_mv >= 0 || uptime_secs >= 0 || errors >= 0 || queue_len >= 0 || + noise_floor > -999 || tx_air_secs >= 0 || rx_air_secs >= 0 || recv_errors >= 0 || + internal_heap >= 0 || packets_sent >= 0 || packets_received >= 0) { + JsonObject stats = root["stats"].to(); + + if (battery_mv >= 0) stats["battery_mv"] = battery_mv; + if (uptime_secs >= 0) stats["uptime_secs"] = uptime_secs; + if (packets_sent >= 0) stats["packets_sent"] = packets_sent; + if (packets_received >= 0) stats["packets_received"] = packets_received; + if (errors >= 0) stats["errors"] = errors; + if (queue_len >= 0) stats["queue_len"] = queue_len; + if (noise_floor > -999) stats["noise_floor"] = noise_floor; + if (tx_air_secs >= 0) stats["tx_air_secs"] = tx_air_secs; + if (rx_air_secs >= 0) stats["rx_air_secs"] = rx_air_secs; + if (recv_errors >= 0) stats["recv_errors"] = recv_errors; + if (internal_heap >= 0) stats["internal_heap"] = internal_heap; + } + + return serializeComplete(root, buffer, buffer_size); +} + +int MQTTPayloadBuilder::buildPacketMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* direction, + const char* time, + const char* date, + int len, + int packet_type, + const char* route, + int payload_len, + const char* raw, + float snr, + int rssi, + float score, + const char* hash, + const uint8_t* path_bytes, + int path_hop_count, + int path_hash_size, + size_t max_path_bytes, + char* buffer, + size_t buffer_size +) { + doc.clear(); + JsonObject root = doc.to(); + + char len_str[16]; + char packet_type_str[16]; + char payload_len_str[16]; + char snr_str[16]; + char rssi_str[16]; + char score_str[16]; + + snprintf(len_str, sizeof(len_str), "%d", len); + snprintf(packet_type_str, sizeof(packet_type_str), "%d", packet_type); + snprintf(payload_len_str, sizeof(payload_len_str), "%d", payload_len); + snprintf(snr_str, sizeof(snr_str), "%.1f", snr); + snprintf(rssi_str, sizeof(rssi_str), "%d", rssi); + + root["timestamp"] = timestamp; + root["hash"] = hash; + root["origin"] = origin; + root["type"] = "PACKET"; + root["direction"] = direction; + root["time"] = time; + root["date"] = date; + root["len"] = len_str; + root["packet_type"] = packet_type_str; + root["route"] = route; + root["payload_len"] = payload_len_str; + root["raw"] = raw; + root["origin_id"] = origin_id; + + if (direction && strcmp(direction, "rx") == 0) { + root["SNR"] = snr_str; + root["RSSI"] = rssi_str; + if (!isnan(score)) { + snprintf(score_str, sizeof(score_str), "%d", static_cast(score * 1000)); + root["score"] = score_str; + } + } + + if (path_bytes && path_hop_count > 0 && path_hash_size > 0) { + JsonArray path_arr = root["path"].to(); + char hop_hex[2 * 4 + 1]; + for (int i = 0; i < path_hop_count; i++) { + size_t pos = 0; + for (int b = 0; b < path_hash_size && b < 4; b++) { + size_t idx = static_cast(i) * path_hash_size + b; + if (idx >= max_path_bytes) break; + snprintf(hop_hex + pos, 3, "%02x", path_bytes[idx]); + pos += 2; + } + hop_hex[pos] = '\0'; + path_arr.add(hop_hex); + } + } + + return serializeComplete(root, buffer, buffer_size); +} + +int MQTTPayloadBuilder::buildRawMessage( + const char* origin, + const char* origin_id, + const char* timestamp, + const char* raw, + char* buffer, + size_t buffer_size +) { + JsonDocument doc; + JsonObject root = doc.to(); + + root["origin"] = origin; + root["origin_id"] = origin_id; + root["timestamp"] = timestamp; + root["type"] = "RAW"; + root["data"] = raw; + + return serializeComplete(root, buffer, buffer_size); +} + +static JsonArray buildNeighborsMessageBase( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const char* self_default_scope, + int total_neighbors, + int queried_neighbors, + bool truncated +) { + doc.clear(); + JsonObject root = doc.to(); + root["timestamp"] = timestamp; + root["origin"] = origin; + root["origin_id"] = origin_id; + if (total_neighbors >= 0) { + root["total_neighbors"] = total_neighbors; + root["queried_neighbors"] = queried_neighbors >= 0 ? queried_neighbors : total_neighbors; + root["truncated"] = truncated; + } + + JsonObject self = root["self"].to(); + self["scopes"] = self_scopes ? self_scopes : ""; + self["default_scope"] = self_default_scope ? self_default_scope : ""; + return root["neighbors"].to(); +} + +static void addNeighborsMessageEntry( + JsonArray& arr, + const MQTTPayloadBuilder::NeighborsMessageEntry& neighbor +) { + JsonObject nb = arr.add(); + nb["pubkey"] = neighbor.pubkey_hex; + nb["snr"] = neighbor.snr; + if (neighbor.heard_unknown) { + nb["heard_secs_ago"] = nullptr; // age unknown, not zero + } else { + nb["heard_secs_ago"] = neighbor.heard_secs_ago; + } + nb["scopes"] = neighbor.scopes ? neighbor.scopes : ""; + nb["status"] = neighbor.status; +} + +size_t MQTTPayloadBuilder::measureNeighborsMessageBase( + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const char* self_default_scope, + int total_neighbors +) { + JsonDocument doc; + buildNeighborsMessageBase( + doc, origin, origin_id, timestamp, self_scopes, self_default_scope, + total_neighbors, total_neighbors, false); + return measureJson(doc); +} + +size_t MQTTPayloadBuilder::measureNeighborsMessageEntry( + const NeighborsMessageEntry& neighbor +) { + JsonDocument doc; + JsonArray arr = doc.to(); + addNeighborsMessageEntry(arr, neighbor); + JsonObject entry = arr[0]; + return measureJson(entry); +} + +int MQTTPayloadBuilder::buildNeighborsMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const char* self_default_scope, + const NeighborsMessageEntry* neighbors, + int neighbor_count, + char* buffer, + size_t buffer_size, + int total_neighbors, + int queried_neighbors, + bool truncated +) { + if (!buffer || buffer_size == 0) return 0; + + JsonArray arr = buildNeighborsMessageBase( + doc, origin, origin_id, timestamp, self_scopes, self_default_scope, + total_neighbors, queried_neighbors, truncated); + if (doc.overflowed() || arr.isNull() || measureJson(doc) >= buffer_size) return 0; + + for (int i = 0; i < neighbor_count; i++) { + addNeighborsMessageEntry(arr, neighbors[i]); + if (doc.overflowed()) return 0; + + // Entries arrive ordered most- to least-useful. Stop as soon as the next + // one would fill the fixed publish buffer, dropping the remaining tail so + // document growth stays bounded. + if (measureJson(doc) >= buffer_size) { + arr.remove(arr.size() - 1); + if (total_neighbors >= 0) doc["truncated"] = true; + break; + } + } + + return serializeComplete(doc.as(), buffer, buffer_size); +} diff --git a/src/helpers/MQTTPayloadBuilder.h b/src/helpers/MQTTPayloadBuilder.h new file mode 100644 index 000000000..24e561248 --- /dev/null +++ b/src/helpers/MQTTPayloadBuilder.h @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include + +// Mesh-independent JSON serialization core for MQTT publication payloads. +// MQTTMessageBuilder keeps the firmware-facing API and delegates these three +// deterministic contracts here so they can be exercised by native tests. +class MQTTPayloadBuilder { +public: + static int buildStatusMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* model, + const char* firmware_version, + const char* radio, + const char* client_version, + const char* status, + const char* timestamp, + char* buffer, + size_t buffer_size, + int battery_mv = -1, + int uptime_secs = -1, + int errors = -1, + int queue_len = -1, + int noise_floor = -999, + int tx_air_secs = -1, + int rx_air_secs = -1, + int recv_errors = -1, + int internal_heap = -1, + int packets_sent = -1, + int packets_received = -1, + const char* repeat = nullptr + ); + + static int buildPacketMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* direction, + const char* time, + const char* date, + int len, + int packet_type, + const char* route, + int payload_len, + const char* raw, + float snr, + int rssi, + float score, + const char* hash, + const uint8_t* path_bytes, + int path_hop_count, + int path_hash_size, + size_t max_path_bytes, + char* buffer, + size_t buffer_size + ); + + static int buildRawMessage( + const char* origin, + const char* origin_id, + const char* timestamp, + const char* raw, + char* buffer, + size_t buffer_size + ); + + struct NeighborsMessageEntry { + const char* pubkey_hex; + float snr; + uint32_t heard_secs_ago; + const char* scopes; + const char* status; + // True renders heard_secs_ago as JSON null, for a neighbour whose stored + // stamp cannot yield an age. No default initializer: the struct stays an + // aggregate for the device toolchain, and a zeroed tail means "age known", + // so size-measurement callers keep reserving the widest numeric value. + bool heard_unknown; + }; + + // Build neighbors-table JSON for the meshcore/{iata}/{device}/neighbors topic. + // Callers order entries most- to least-useful; document growth is bounded to + // buffer_size and the remaining tail is dropped once the next entry won't fit. + static int buildNeighborsMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const char* self_default_scope, + const NeighborsMessageEntry* neighbors, + int neighbor_count, + char* buffer, + size_t buffer_size, + int total_neighbors = -1, + int queried_neighbors = -1, + bool truncated = false + ); + + // Exact serialized-size components used by paced neighbor discovery. The + // base reserves the largest progress metadata values for this snapshot; + // callers add each entry size plus one byte for commas after the first. + static size_t measureNeighborsMessageBase( + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const char* self_default_scope, + int total_neighbors + ); + static size_t measureNeighborsMessageEntry(const NeighborsMessageEntry& neighbor); +}; diff --git a/src/helpers/MQTTPrefsAtomicStore.h b/src/helpers/MQTTPrefsAtomicStore.h new file mode 100644 index 000000000..be77bf99e --- /dev/null +++ b/src/helpers/MQTTPrefsAtomicStore.h @@ -0,0 +1,127 @@ +#pragma once + +#include +#include + +// Transactional writer for /mqtt_prefs. The Store interface is intentionally +// narrow so host tests can exercise every failure boundary without an Arduino +// filesystem: begin(), write(), finish(), commit(), and abort(). The caller +// supplies header and payload separately, avoiding a second full-size buffer. +namespace MQTTPrefsAtomicStore { + +enum class Result : uint8_t { + Committed, + BeginFailed, + HeaderWriteFailed, + PayloadWriteFailed, + FinishFailed, + CommitFailed, +}; + +inline bool committed(Result result) { + return result == Result::Committed; +} + +// Generic streaming transaction for structured images such as /com_prefs. +// ImageWriter writes its fields directly to Store and returns false on any +// short write, so no contiguous staging allocation is required. +enum class ImageResult : uint8_t { + Committed, + BeginFailed, + WriteFailed, + FinishFailed, + CommitFailed, +}; + +inline bool imageCommitted(ImageResult result) { + return result == ImageResult::Committed; +} + +template +inline ImageResult writeImage(Store& store, ImageWriter write_image) { + if (!store.begin()) { + store.abort(); + return ImageResult::BeginFailed; + } + if (!write_image(store)) { + store.abort(); + return ImageResult::WriteFailed; + } + if (!store.finish()) { + store.abort(); + return ImageResult::FinishFailed; + } + if (!store.commit()) { + store.abort(); + return ImageResult::CommitFailed; + } + return ImageResult::Committed; +} + +// Coordinates a two-file legacy upgrade. /com_prefs must not be compacted +// until the observer tail it carries has been published into /mqtt_prefs. +// Keeping this state in a tiny pure helper lets host tests cover power-cut +// boundaries without an Arduino filesystem. +class LegacyUpgradeGate { +public: + explicit LegacyUpgradeGate(bool com_prefs_rewrite_pending) + : _com_prefs_rewrite_pending(com_prefs_rewrite_pending) {} + + void requireMqttRewrite() { _mqtt_rewrite_pending = true; } + + void recordMqttSave(bool did_commit) { + if (did_commit) { + _mqtt_rewrite_pending = false; + _mqtt_source_held = false; + } else { + _mqtt_source_held = true; + } + } + + void holdMqttSource() { _mqtt_source_held = true; } + + bool mqttRewritePending() const { return _mqtt_rewrite_pending; } + bool blocksComPrefsRewrite() const { + return _com_prefs_rewrite_pending && (_mqtt_rewrite_pending || _mqtt_source_held); + } + bool mayRewriteComPrefs() const { + return _com_prefs_rewrite_pending && !blocksComPrefsRewrite(); + } + + void recordComPrefsRewrite() { + if (mayRewriteComPrefs()) _com_prefs_rewrite_pending = false; + } + +private: + bool _com_prefs_rewrite_pending; + bool _mqtt_rewrite_pending = false; + bool _mqtt_source_held = false; +}; + +template +inline Result write(Store& store, const uint8_t* header, size_t header_size, + const uint8_t* payload, size_t payload_size) { + if (!store.begin()) { + store.abort(); + return Result::BeginFailed; + } + if (store.write(header, header_size) != header_size) { + store.abort(); + return Result::HeaderWriteFailed; + } + if (store.write(payload, payload_size) != payload_size) { + store.abort(); + return Result::PayloadWriteFailed; + } + if (!store.finish()) { + store.abort(); + return Result::FinishFailed; + } + if (!store.commit()) { + store.abort(); + return Result::CommitFailed; + } + return Result::Committed; +} + +} // namespace MQTTPrefsAtomicStore diff --git a/src/helpers/MQTTPrefsCodec.h b/src/helpers/MQTTPrefsCodec.h new file mode 100644 index 000000000..7bbfe30ae --- /dev/null +++ b/src/helpers/MQTTPrefsCodec.h @@ -0,0 +1,489 @@ +#pragma once + +#include + +#include "MQTTPacketFilter.h" +#include "MQTTPrefsStorage.h" + +#ifdef WITH_MQTT_BRIDGE + +// Pure /mqtt_prefs format classification and field-copy migration. Production +// reads directly into the selected layout; this header never requires a second +// large staging buffer. +namespace MQTTPrefsCodec { + +enum class Source : uint8_t { + Defaults, + Current, + LegacyPreSlot, + LegacyThreeSlotBase, + LegacyThreeSlot, + LegacySixSlotBase, + LegacySixSlotAudience, + LegacySixSlotAudienceRx, + LegacySixSlot, + UnsupportedVersion, + Corrupt, +}; + +struct DecodePlan { + Source source; + bool rewrite_legacy; + bool preserve_file; + // False means the decoded payload stops before snmp_enabled, so production + // may apply a captured observer tail from legacy /com_prefs. + bool observer_fields_present; + size_t payload_len; +}; + +static const size_t kV1PreObserverPayloadSize = MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE; +static const size_t kV1PreNeighborsPayloadSize = MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE; +static const size_t kV1PreFilterPayloadSize = MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE; +static const size_t kV1BaselinePayloadSize = MQTT_PREFS_V1_FULL_PAYLOAD_SIZE; +static const size_t kEncodedSize = sizeof(MQTTPrefsHeader) + kV1BaselinePayloadSize; + +// Shortest payload length that still round-trips this configuration. +// +// The packet-filter tail is the only optional part of the current layout, and +// its default (all types) is exactly what a pre-filter decoder supplies for a +// missing tail. So a device whose filters are all default keeps writing the +// 2864-byte payload that pre-filter firmware can still read. That matters +// because /mqtt_prefs also carries the WiFi credentials: an unrecognised +// longer payload sends older firmware to defaults with no network, and it +// refuses to overwrite the file, so the node cannot be recovered over the air. +// Touching any filter opts that node into the longer payload — a deliberate, +// operator-initiated trade rather than a side effect of upgrading. +inline size_t payloadLenFor(const MQTTPrefs& prefs) { + return MQTTPacketFilter::allMasksDefault(prefs.mqtt_slot_packet_filter, + MQTT_PREFS_SLOT_COUNT) + ? kV1PreFilterPayloadSize + : kV1BaselinePayloadSize; +} + +inline MQTTPrefsHeader makeHeader(size_t payload_len) { + MQTTPrefsHeader header; + memcpy(header.magic, MQTT_PREFS_MAGIC, sizeof(header.magic)); + header.version = MQTT_PREFS_VERSION; + header.payload_len = static_cast(payload_len); + return header; +} + +inline size_t encode(const MQTTPrefs& prefs, uint8_t* output, size_t output_size) { + const size_t payload_len = payloadLenFor(prefs); + const size_t encoded_size = sizeof(MQTTPrefsHeader) + payload_len; + if (output == nullptr || output_size < encoded_size) return 0; + const MQTTPrefsHeader header = makeHeader(payload_len); + memcpy(output, &header, sizeof(header)); + memcpy(output + sizeof(header), &prefs, payload_len); + return encoded_size; +} + +inline bool isMagicPrefix(const uint8_t* input, size_t available) { + if (input == nullptr || available == 0) return false; + const size_t compare_len = available < sizeof(MQTT_PREFS_MAGIC) + ? available : sizeof(MQTT_PREFS_MAGIC); + return memcmp(input, MQTT_PREFS_MAGIC, compare_len) == 0; +} + +inline DecodePlan corruptPlan() { + return {Source::Corrupt, false, true, false, 0}; +} + +// Classify from the first eight bytes and the filesystem-reported file size. +// Headerless layouts are an explicit, audited whitelist. Call +// isPlausibleLegacy() after reading the selected layout and before rewriting: +// size alone cannot distinguish a valid legacy payload from arbitrary bytes. +inline DecodePlan classify(const uint8_t* prefix, size_t prefix_read, size_t file_size) { + if (file_size == 0) return corruptPlan(); + if (prefix == nullptr || prefix_read == 0) return corruptPlan(); + const size_t expected_prefix = file_size < sizeof(MQTTPrefsHeader) + ? file_size : sizeof(MQTTPrefsHeader); + if (prefix_read < expected_prefix) return corruptPlan(); + + if (file_size < sizeof(MQTTPrefsHeader) && isMagicPrefix(prefix, prefix_read)) { + return corruptPlan(); + } + if (file_size >= sizeof(MQTTPrefsHeader)) { + MQTTPrefsHeader header; + memcpy(&header, prefix, sizeof(header)); + if (memcmp(header.magic, MQTT_PREFS_MAGIC, sizeof(header.magic)) == 0) { + if (header.version != MQTT_PREFS_VERSION) { + return {Source::UnsupportedVersion, false, true, false, 0}; + } + const size_t payload_available = file_size - sizeof(header); + if (header.payload_len != payload_available) { + return corruptPlan(); + } + // A longer same-version payload was written by a later build that + // appended fields. Within a version tag the layout is append-only, so + // every byte this binary knows is present and correctly positioned — read + // the baseline prefix and ignore the tail. + // + // This is the downgrade contract, and it is deliberately asymmetric: + // refusing the file would cost the operator WiFi credentials and every + // broker slot (a node with no network and no portal, recoverable only + // over serial), whereas reading it costs only the settings the newer + // build added. Losing a later feature's settings is the acceptable half. + // + // The tail survives until something actually writes: saveMQTTPrefs() + // rewrites at this binary's own length, so a rollback that changes no + // observer setting and is later rolled forward keeps the newer fields + // intact. Only an explicit `set` while downgraded drops them. + // + // A layout change that is NOT a pure append must bump MQTT_PREFS_VERSION; + // the version check above is what makes this rule safe. + if (header.payload_len > kV1BaselinePayloadSize) { + return {Source::Current, false, false, true, kV1BaselinePayloadSize}; + } + if (header.payload_len == kV1BaselinePayloadSize) { + return {Source::Current, false, false, true, kV1BaselinePayloadSize}; + } + if (header.payload_len == kV1PreFilterPayloadSize) { + // Written before the per-slot packet-filter tail. Defaults supply an + // all-types mask for every slot. + return {Source::Current, false, false, true, kV1PreFilterPayloadSize}; + } + if (header.payload_len == kV1PreNeighborsPayloadSize) { + // Written by observer/webconfig firmware before the neighbors tail + // existed. The observer fields ARE present; only the neighbors tail is + // missing, so it loads and keeps its defaults (off / 24h). + return {Source::Current, false, false, true, kV1PreNeighborsPayloadSize}; + } + if (header.payload_len == kV1PreObserverPayloadSize) { + return {Source::Current, false, false, false, kV1PreObserverPayloadSize}; + } + return corruptPlan(); + } + } + + switch (file_size) { + case sizeof(OldMQTTPrefs): + return {Source::LegacyPreSlot, true, false, false, file_size}; + case sizeof(ThreeSlotBaseMQTTPrefs): + return {Source::LegacyThreeSlotBase, true, false, false, file_size}; + case sizeof(ThreeSlotMQTTPrefs): + return {Source::LegacyThreeSlot, true, false, false, file_size}; + case LEGACY6_BASE_SIZE: + return {Source::LegacySixSlotBase, true, false, false, file_size}; + case LEGACY6_AUDIENCE_SIZE: + return {Source::LegacySixSlotAudience, true, false, false, file_size}; + case LEGACY6_AUDIENCE_RX_SIZE: + return {Source::LegacySixSlotAudienceRx, true, false, false, file_size}; + case sizeof(Legacy6SlotMQTTPrefs): + return {Source::LegacySixSlot, true, false, false, file_size}; + default: + return corruptPlan(); + } +} + +inline bool looksLikePreWifiPower(const uint8_t* input, size_t size) { + if (input == nullptr || size != sizeof(OldMQTTPrefs)) return false; + // At byte 144 the newer layout has wifi_power_save (0..2); the older + // layout has timezone_string[0]. A non-empty timezone is unambiguous. + if (input[144] > 2) return true; + // With an empty timezone, byte 177 is the older mqtt_server[0] but the + // newer timezone_offset. If it cannot be an offset, it is also unambiguous. + const int8_t newer_offset = static_cast(input[177]); + // Byte 176 is the older timezone_offset but the final byte of the newer + // timezone string (always NUL for values saved through the CLI). + return input[144] == 0 && + (input[176] != 0 || newer_offset < -12 || newer_offset > 14); +} + +// Headerless files have no checksum or magic, so their integrity cannot be +// proven. These checks intentionally reject obvious random data (unterminated +// strings and impossible flag/range values) without demanding application-level +// values that a real but sparsely configured device may not have set. +inline bool hasTerminatedText(const uint8_t* input, size_t size, size_t offset, size_t field_size) { + if (input == nullptr || offset > size || field_size > size - offset) return false; + for (size_t i = 0; i < field_size; ++i) { + if (input[offset + i] == '\0') return true; + } + return false; +} + +inline bool hasPlausibleCommonFields(const uint8_t* input, size_t size, bool pre_wifi_power) { + if (input == nullptr || size < sizeof(OldMQTTPrefs)) return false; + const size_t timezone_offset = pre_wifi_power + ? offsetof(PreWifiPowerOldMQTTPrefs, timezone_string) + : offsetof(OldMQTTPrefs, timezone_string); + const size_t utc_offset = pre_wifi_power + ? offsetof(PreWifiPowerOldMQTTPrefs, timezone_offset) + : offsetof(OldMQTTPrefs, timezone_offset); + const size_t timezone_size = 32; + const int8_t timezone_hours = static_cast(input[utc_offset]); + + return input[offsetof(OldMQTTPrefs, mqtt_status_enabled)] <= 1 && + input[offsetof(OldMQTTPrefs, mqtt_packets_enabled)] <= 1 && + input[offsetof(OldMQTTPrefs, mqtt_raw_enabled)] <= 1 && + input[offsetof(OldMQTTPrefs, mqtt_tx_enabled)] <= 2 && + (pre_wifi_power || input[offsetof(OldMQTTPrefs, wifi_power_save)] <= 2) && + timezone_hours >= -12 && timezone_hours <= 14 && + hasTerminatedText(input, size, offsetof(OldMQTTPrefs, mqtt_origin), + 32) && + hasTerminatedText(input, size, offsetof(OldMQTTPrefs, mqtt_iata), + 8) && + hasTerminatedText(input, size, offsetof(OldMQTTPrefs, wifi_ssid), + 32) && + hasTerminatedText(input, size, offsetof(OldMQTTPrefs, wifi_password), + 64) && + hasTerminatedText(input, size, timezone_offset, timezone_size); +} + +inline bool hasPlausibleSlotText(const uint8_t* input, size_t size, size_t slot_count, + size_t preset_offset, size_t host_offset, size_t username_offset, + size_t password_offset, size_t token_offset, size_t topic_offset, + size_t audience_offset) { + const size_t no_field = static_cast(-1); + for (size_t i = 0; i < slot_count; ++i) { + if (!hasTerminatedText(input, size, preset_offset + i * 24, 24) || + !hasTerminatedText(input, size, host_offset + i * 64, 64) || + !hasTerminatedText(input, size, username_offset + i * 32, 32) || + !hasTerminatedText(input, size, password_offset + i * 64, 64) || + (token_offset != no_field && !hasTerminatedText(input, size, token_offset + i * 48, 48)) || + (topic_offset != no_field && !hasTerminatedText(input, size, topic_offset + i * 96, 96)) || + (audience_offset != no_field && !hasTerminatedText(input, size, audience_offset + i * 64, 64))) { + return false; + } + } + return true; +} + +inline bool hasPlausibleSharedAuth(const uint8_t* input, size_t size, size_t owner_offset, + size_t email_offset) { + return hasTerminatedText(input, size, owner_offset, 65) && + hasTerminatedText(input, size, email_offset, 64); +} + +inline bool isPlausibleLegacy(Source source, const uint8_t* input, size_t size) { + const size_t no_field = static_cast(-1); + switch (source) { + case Source::LegacyPreSlot: { + if (size != sizeof(OldMQTTPrefs)) return false; + const bool pre_wifi_power = looksLikePreWifiPower(input, size); + const size_t server_offset = pre_wifi_power + ? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_server) + : offsetof(OldMQTTPrefs, mqtt_server); + const size_t username_offset = pre_wifi_power + ? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_username) + : offsetof(OldMQTTPrefs, mqtt_username); + const size_t password_offset = pre_wifi_power + ? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_password) + : offsetof(OldMQTTPrefs, mqtt_password); + const size_t us_enabled = pre_wifi_power + ? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_analyzer_us_enabled) + : offsetof(OldMQTTPrefs, mqtt_analyzer_us_enabled); + const size_t eu_enabled = pre_wifi_power + ? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_analyzer_eu_enabled) + : offsetof(OldMQTTPrefs, mqtt_analyzer_eu_enabled); + return hasPlausibleCommonFields(input, size, pre_wifi_power) && + input[us_enabled] <= 1 && input[eu_enabled] <= 1 && + hasTerminatedText(input, size, server_offset, 64) && + hasTerminatedText(input, size, username_offset, 32) && + hasTerminatedText(input, size, password_offset, 64) && + hasTerminatedText(input, size, pre_wifi_power + ? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_owner_public_key) + : offsetof(OldMQTTPrefs, mqtt_owner_public_key), 65) && + hasTerminatedText(input, size, pre_wifi_power + ? offsetof(PreWifiPowerOldMQTTPrefs, mqtt_email) + : offsetof(OldMQTTPrefs, mqtt_email), 64); + } + case Source::LegacyThreeSlotBase: + return size == sizeof(ThreeSlotBaseMQTTPrefs) && + hasPlausibleCommonFields(input, size, false) && + hasPlausibleSharedAuth(input, size, + offsetof(ThreeSlotBaseMQTTPrefs, mqtt_owner_public_key), + offsetof(ThreeSlotBaseMQTTPrefs, mqtt_email)) && + hasPlausibleSlotText(input, size, 3, + offsetof(ThreeSlotBaseMQTTPrefs, mqtt_slot_preset), + offsetof(ThreeSlotBaseMQTTPrefs, mqtt_slot_host), + offsetof(ThreeSlotBaseMQTTPrefs, mqtt_slot_username), + offsetof(ThreeSlotBaseMQTTPrefs, mqtt_slot_password), + no_field, no_field, no_field); + case Source::LegacyThreeSlot: + return size == sizeof(ThreeSlotMQTTPrefs) && + hasPlausibleCommonFields(input, size, false) && + hasPlausibleSharedAuth(input, size, + offsetof(ThreeSlotMQTTPrefs, mqtt_owner_public_key), + offsetof(ThreeSlotMQTTPrefs, mqtt_email)) && + hasPlausibleSlotText(input, size, 3, + offsetof(ThreeSlotMQTTPrefs, mqtt_slot_preset), + offsetof(ThreeSlotMQTTPrefs, mqtt_slot_host), + offsetof(ThreeSlotMQTTPrefs, mqtt_slot_username), + offsetof(ThreeSlotMQTTPrefs, mqtt_slot_password), + offsetof(ThreeSlotMQTTPrefs, mqtt_slot_token), + offsetof(ThreeSlotMQTTPrefs, mqtt_slot_topic), no_field); + case Source::LegacySixSlotBase: + case Source::LegacySixSlotAudience: + case Source::LegacySixSlotAudienceRx: + case Source::LegacySixSlot: { + const size_t expected_size = source == Source::LegacySixSlotBase ? LEGACY6_BASE_SIZE + : source == Source::LegacySixSlotAudience ? LEGACY6_AUDIENCE_SIZE + : source == Source::LegacySixSlotAudienceRx ? LEGACY6_AUDIENCE_RX_SIZE + : sizeof(Legacy6SlotMQTTPrefs); + const size_t audience_offset = source == Source::LegacySixSlotBase + ? no_field : offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_audience); + const bool has_rx = source == Source::LegacySixSlotAudienceRx || + source == Source::LegacySixSlot; + const bool has_ntp = source == Source::LegacySixSlot; + return size == expected_size && hasPlausibleCommonFields(input, size, false) && + hasPlausibleSharedAuth(input, size, + offsetof(Legacy6SlotMQTTPrefs, mqtt_owner_public_key), + offsetof(Legacy6SlotMQTTPrefs, mqtt_email)) && + (!has_rx || input[offsetof(Legacy6SlotMQTTPrefs, mqtt_rx_enabled)] <= 1) && + (!has_ntp || hasTerminatedText(input, size, + offsetof(Legacy6SlotMQTTPrefs, mqtt_ntp_server), 64)) && + hasPlausibleSlotText(input, size, MQTT_PREFS_SLOT_COUNT, + offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_preset), + offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_host), + offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_username), + offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_password), + offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_token), + offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_topic), audience_offset); + } + default: + return false; + } +} + +inline void migratePreSlot(const OldMQTTPrefs& old_prefs, MQTTPrefs* prefs) { + memcpy(prefs->mqtt_origin, old_prefs.mqtt_origin, sizeof(prefs->mqtt_origin)); + memcpy(prefs->mqtt_iata, old_prefs.mqtt_iata, sizeof(prefs->mqtt_iata)); + prefs->mqtt_status_enabled = old_prefs.mqtt_status_enabled; + prefs->mqtt_packets_enabled = old_prefs.mqtt_packets_enabled; + prefs->mqtt_raw_enabled = old_prefs.mqtt_raw_enabled; + prefs->mqtt_tx_enabled = old_prefs.mqtt_tx_enabled; + prefs->mqtt_status_interval = old_prefs.mqtt_status_interval; + memcpy(prefs->wifi_ssid, old_prefs.wifi_ssid, sizeof(prefs->wifi_ssid)); + memcpy(prefs->wifi_password, old_prefs.wifi_password, sizeof(prefs->wifi_password)); + prefs->wifi_power_save = old_prefs.wifi_power_save; + memcpy(prefs->timezone_string, old_prefs.timezone_string, sizeof(prefs->timezone_string)); + prefs->timezone_offset = old_prefs.timezone_offset; + memcpy(prefs->mqtt_owner_public_key, old_prefs.mqtt_owner_public_key, + sizeof(prefs->mqtt_owner_public_key)); + memcpy(prefs->mqtt_email, old_prefs.mqtt_email, sizeof(prefs->mqtt_email)); + strncpy(prefs->mqtt_slot_preset[0], old_prefs.mqtt_analyzer_us_enabled == 1 + ? "analyzer-us" : "none", sizeof(prefs->mqtt_slot_preset[0]) - 1); + strncpy(prefs->mqtt_slot_preset[1], old_prefs.mqtt_analyzer_eu_enabled == 1 + ? "analyzer-eu" : "none", sizeof(prefs->mqtt_slot_preset[1]) - 1); + if (old_prefs.mqtt_server[0] != '\0' && old_prefs.mqtt_port > 0) { + strncpy(prefs->mqtt_slot_preset[2], "custom", sizeof(prefs->mqtt_slot_preset[2]) - 1); + strncpy(prefs->mqtt_slot_host[2], old_prefs.mqtt_server, + sizeof(prefs->mqtt_slot_host[2]) - 1); + prefs->mqtt_slot_port[2] = old_prefs.mqtt_port; + strncpy(prefs->mqtt_slot_username[2], old_prefs.mqtt_username, + sizeof(prefs->mqtt_slot_username[2]) - 1); + strncpy(prefs->mqtt_slot_password[2], old_prefs.mqtt_password, + sizeof(prefs->mqtt_slot_password[2]) - 1); + } else { + strncpy(prefs->mqtt_slot_preset[2], "none", sizeof(prefs->mqtt_slot_preset[2]) - 1); + } +} + +inline void migratePreWifiPower(const PreWifiPowerOldMQTTPrefs& old_prefs, MQTTPrefs* prefs) { + OldMQTTPrefs normalized = {}; + memcpy(normalized.mqtt_origin, old_prefs.mqtt_origin, sizeof(normalized.mqtt_origin)); + memcpy(normalized.mqtt_iata, old_prefs.mqtt_iata, sizeof(normalized.mqtt_iata)); + normalized.mqtt_status_enabled = old_prefs.mqtt_status_enabled; + normalized.mqtt_packets_enabled = old_prefs.mqtt_packets_enabled; + normalized.mqtt_raw_enabled = old_prefs.mqtt_raw_enabled; + normalized.mqtt_tx_enabled = old_prefs.mqtt_tx_enabled; + normalized.mqtt_status_interval = old_prefs.mqtt_status_interval; + memcpy(normalized.wifi_ssid, old_prefs.wifi_ssid, sizeof(normalized.wifi_ssid)); + memcpy(normalized.wifi_password, old_prefs.wifi_password, sizeof(normalized.wifi_password)); + normalized.wifi_power_save = prefs->wifi_power_save; // field did not exist yet + memcpy(normalized.timezone_string, old_prefs.timezone_string, sizeof(normalized.timezone_string)); + normalized.timezone_offset = old_prefs.timezone_offset; + memcpy(normalized.mqtt_server, old_prefs.mqtt_server, sizeof(normalized.mqtt_server)); + normalized.mqtt_port = old_prefs.mqtt_port; + memcpy(normalized.mqtt_username, old_prefs.mqtt_username, sizeof(normalized.mqtt_username)); + memcpy(normalized.mqtt_password, old_prefs.mqtt_password, sizeof(normalized.mqtt_password)); + normalized.mqtt_analyzer_us_enabled = old_prefs.mqtt_analyzer_us_enabled; + normalized.mqtt_analyzer_eu_enabled = old_prefs.mqtt_analyzer_eu_enabled; + memcpy(normalized.mqtt_owner_public_key, old_prefs.mqtt_owner_public_key, + sizeof(normalized.mqtt_owner_public_key)); + memcpy(normalized.mqtt_email, old_prefs.mqtt_email, sizeof(normalized.mqtt_email)); + migratePreSlot(normalized, prefs); +} + +template +inline void migrateThreeSlotCommon(const T& old_prefs, MQTTPrefs* prefs) { + memcpy(prefs->mqtt_origin, old_prefs.mqtt_origin, sizeof(prefs->mqtt_origin)); + memcpy(prefs->mqtt_iata, old_prefs.mqtt_iata, sizeof(prefs->mqtt_iata)); + prefs->mqtt_status_enabled = old_prefs.mqtt_status_enabled; + prefs->mqtt_packets_enabled = old_prefs.mqtt_packets_enabled; + prefs->mqtt_raw_enabled = old_prefs.mqtt_raw_enabled; + prefs->mqtt_tx_enabled = old_prefs.mqtt_tx_enabled; + prefs->mqtt_status_interval = old_prefs.mqtt_status_interval; + memcpy(prefs->wifi_ssid, old_prefs.wifi_ssid, sizeof(prefs->wifi_ssid)); + memcpy(prefs->wifi_password, old_prefs.wifi_password, sizeof(prefs->wifi_password)); + prefs->wifi_power_save = old_prefs.wifi_power_save; + memcpy(prefs->timezone_string, old_prefs.timezone_string, sizeof(prefs->timezone_string)); + prefs->timezone_offset = old_prefs.timezone_offset; + for (int i = 0; i < 3; i++) { + memcpy(prefs->mqtt_slot_preset[i], old_prefs.mqtt_slot_preset[i], sizeof(prefs->mqtt_slot_preset[i])); + memcpy(prefs->mqtt_slot_host[i], old_prefs.mqtt_slot_host[i], sizeof(prefs->mqtt_slot_host[i])); + prefs->mqtt_slot_port[i] = old_prefs.mqtt_slot_port[i]; + memcpy(prefs->mqtt_slot_username[i], old_prefs.mqtt_slot_username[i], sizeof(prefs->mqtt_slot_username[i])); + memcpy(prefs->mqtt_slot_password[i], old_prefs.mqtt_slot_password[i], sizeof(prefs->mqtt_slot_password[i])); + } + memcpy(prefs->mqtt_owner_public_key, old_prefs.mqtt_owner_public_key, sizeof(prefs->mqtt_owner_public_key)); + memcpy(prefs->mqtt_email, old_prefs.mqtt_email, sizeof(prefs->mqtt_email)); +} + +inline void migrateThreeSlot(const ThreeSlotBaseMQTTPrefs& old_prefs, MQTTPrefs* prefs) { + migrateThreeSlotCommon(old_prefs, prefs); +} + +inline void migrateThreeSlot(const ThreeSlotMQTTPrefs& old_prefs, MQTTPrefs* prefs) { + migrateThreeSlotCommon(old_prefs, prefs); + for (int i = 0; i < 3; i++) { + memcpy(prefs->mqtt_slot_token[i], old_prefs.mqtt_slot_token[i], sizeof(prefs->mqtt_slot_token[i])); + memcpy(prefs->mqtt_slot_topic[i], old_prefs.mqtt_slot_topic[i], sizeof(prefs->mqtt_slot_topic[i])); + } +} + +inline void migrateLegacySixSlotCommon(const Legacy6SlotMQTTPrefs& old_prefs, MQTTPrefs* prefs) { + memcpy(prefs->mqtt_origin, old_prefs.mqtt_origin, sizeof(prefs->mqtt_origin)); + memcpy(prefs->mqtt_iata, old_prefs.mqtt_iata, sizeof(prefs->mqtt_iata)); + prefs->mqtt_status_enabled = old_prefs.mqtt_status_enabled; + prefs->mqtt_packets_enabled = old_prefs.mqtt_packets_enabled; + prefs->mqtt_raw_enabled = old_prefs.mqtt_raw_enabled; + prefs->mqtt_tx_enabled = old_prefs.mqtt_tx_enabled; + prefs->mqtt_status_interval = old_prefs.mqtt_status_interval; + memcpy(prefs->wifi_ssid, old_prefs.wifi_ssid, sizeof(prefs->wifi_ssid)); + memcpy(prefs->wifi_password, old_prefs.wifi_password, sizeof(prefs->wifi_password)); + prefs->wifi_power_save = old_prefs.wifi_power_save; + memcpy(prefs->timezone_string, old_prefs.timezone_string, sizeof(prefs->timezone_string)); + prefs->timezone_offset = old_prefs.timezone_offset; + memcpy(prefs->mqtt_slot_preset, old_prefs.mqtt_slot_preset, sizeof(prefs->mqtt_slot_preset)); + memcpy(prefs->mqtt_slot_host, old_prefs.mqtt_slot_host, sizeof(prefs->mqtt_slot_host)); + memcpy(prefs->mqtt_slot_port, old_prefs.mqtt_slot_port, sizeof(prefs->mqtt_slot_port)); + memcpy(prefs->mqtt_slot_username, old_prefs.mqtt_slot_username, sizeof(prefs->mqtt_slot_username)); + memcpy(prefs->mqtt_slot_password, old_prefs.mqtt_slot_password, sizeof(prefs->mqtt_slot_password)); + memcpy(prefs->mqtt_owner_public_key, old_prefs.mqtt_owner_public_key, sizeof(prefs->mqtt_owner_public_key)); + memcpy(prefs->mqtt_email, old_prefs.mqtt_email, sizeof(prefs->mqtt_email)); + memcpy(prefs->mqtt_slot_token, old_prefs.mqtt_slot_token, sizeof(prefs->mqtt_slot_token)); + memcpy(prefs->mqtt_slot_topic, old_prefs.mqtt_slot_topic, sizeof(prefs->mqtt_slot_topic)); +} + +inline void migrateLegacySixSlot(const Legacy6SlotMQTTPrefs& old_prefs, Source source, + MQTTPrefs* prefs) { + migrateLegacySixSlotCommon(old_prefs, prefs); + if (source == Source::LegacySixSlotAudience || source == Source::LegacySixSlotAudienceRx || + source == Source::LegacySixSlot) { + memcpy(prefs->mqtt_slot_audience, old_prefs.mqtt_slot_audience, + sizeof(prefs->mqtt_slot_audience)); + } + if (source == Source::LegacySixSlotAudienceRx || source == Source::LegacySixSlot) { + prefs->mqtt_rx_enabled = old_prefs.mqtt_rx_enabled; + } + if (source == Source::LegacySixSlot) { + memcpy(prefs->mqtt_ntp_server, old_prefs.mqtt_ntp_server, + sizeof(prefs->mqtt_ntp_server)); + } +} + +} // namespace MQTTPrefsCodec + +#endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPrefsRecovery.h b/src/helpers/MQTTPrefsRecovery.h new file mode 100644 index 000000000..7534678eb --- /dev/null +++ b/src/helpers/MQTTPrefsRecovery.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +// Pure recovery policy for the three MQTT preference transaction files. The +// writer first moves the old primary to .bak, then moves the verified .tmp to +// the primary name. On a reset, the loader uses this policy before decoding +// /mqtt_prefs. "Preserve" is deliberately distinct from "Usable": it covers +// an unsupported newer layout, corruption, or an unreadable file and must +// never be replaced by an older image. +namespace MQTTPrefsRecovery { + +enum class FileState : uint8_t { + Missing, + Usable, + Preserve, +}; + +enum class Action : uint8_t { + None, + KeepPrimary, + PromoteTemp, + PromoteBackup, +}; + +inline Action select(FileState primary, FileState temp, FileState backup) { + // A primary of any kind owns the name. In particular, do not roll a newer + // or corrupt primary back to an older backup just because it cannot be read + // by this firmware. + if (primary != FileState::Missing) return Action::KeepPrimary; + + // A completed temp is the new image and wins over the old backup. + if (temp == FileState::Usable) return Action::PromoteTemp; + + // If temp is opaque but a known-good backup exists, boot from the backup. + // The caller may discard the opaque temp once that usable backup has become + // primary. Otherwise, rename the opaque temp into the empty primary name so + // the normal loader can hold it. + if (temp == FileState::Preserve) { + return backup == FileState::Usable ? Action::PromoteBackup : Action::PromoteTemp; + } + + // No temp survived. The backup is the only recoverable image, even when it + // is a newer layout that this firmware must preserve rather than decode. + if (backup != FileState::Missing) return Action::PromoteBackup; + return Action::None; +} + +} // namespace MQTTPrefsRecovery diff --git a/src/helpers/MQTTPrefsStorage.h b/src/helpers/MQTTPrefsStorage.h new file mode 100644 index 000000000..e3c6a098b --- /dev/null +++ b/src/helpers/MQTTPrefsStorage.h @@ -0,0 +1,320 @@ +#pragma once + +#include +#include + +// /mqtt_prefs is a raw binary persistence format. Keep the layout-only types +// independent from CommonCLI so the migration decoder can be tested on the host +// without pulling in Arduino, filesystem, or radio dependencies. +#ifdef WITH_MQTT_BRIDGE + +// Must match MAX_MQTT_SLOTS in MQTTPresets.h. CommonCLI.h enforces that link on +// firmware builds; keeping this header standalone avoids importing preset data +// into host migration tests. +static const int MQTT_PREFS_SLOT_COUNT = 6; + +// Old MQTT preferences layout (pre-slot firmware) — used only for migration detection. +struct OldMQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + char wifi_ssid[32]; + char wifi_password[64]; + uint8_t wifi_power_save; + char timezone_string[32]; + int8_t timezone_offset; + char mqtt_server[64]; + uint16_t mqtt_port; + char mqtt_username[32]; + char mqtt_password[64]; + uint8_t mqtt_analyzer_us_enabled; + uint8_t mqtt_analyzer_eu_enabled; + char mqtt_owner_public_key[65]; + char mqtt_email[64]; +}; + +// The pre-WiFi-power pre-slot layout has the same frozen size as +// OldMQTTPrefs, but timezone/server start one byte earlier. A conservative +// classifier distinguishes meaningful configurations before migration. +struct PreWifiPowerOldMQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + char wifi_ssid[32]; + char wifi_password[64]; + char timezone_string[32]; + int8_t timezone_offset; + char mqtt_server[64]; + uint16_t mqtt_port; + char mqtt_username[32]; + char mqtt_password[64]; + uint8_t mqtt_analyzer_us_enabled; + uint8_t mqtt_analyzer_eu_enabled; + char mqtt_owner_public_key[65]; + char mqtt_email[64]; +}; + +// MQTT preferences stored separately from NodePrefs to avoid upstream layout +// conflicts. The full layout is the frozen v1 payload baseline. The prefix +// before observer settings is also an explicitly supported v1 payload: it was +// used before the observer fields were appended. +struct MQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + + char wifi_ssid[32]; + char wifi_password[64]; + uint8_t wifi_power_save; + + char timezone_string[32]; + int8_t timezone_offset; + + char mqtt_slot_preset[MQTT_PREFS_SLOT_COUNT][24]; + char mqtt_slot_host[MQTT_PREFS_SLOT_COUNT][64]; + uint16_t mqtt_slot_port[MQTT_PREFS_SLOT_COUNT]; + char mqtt_slot_username[MQTT_PREFS_SLOT_COUNT][32]; + char mqtt_slot_password[MQTT_PREFS_SLOT_COUNT][64]; + + char mqtt_owner_public_key[65]; + char mqtt_email[64]; + + char mqtt_slot_token[MQTT_PREFS_SLOT_COUNT][48]; + char mqtt_slot_topic[MQTT_PREFS_SLOT_COUNT][96]; + char mqtt_slot_audience[MQTT_PREFS_SLOT_COUNT][64]; + + uint8_t mqtt_rx_enabled; + char mqtt_ntp_server[64]; + + uint8_t snmp_enabled; + char snmp_community[24]; + uint8_t radio_watchdog_minutes; + uint8_t alert_enabled; + char alert_psk_hex[33]; + uint16_t alert_wifi_minutes; + uint16_t alert_mqtt_minutes; + uint16_t alert_min_interval_min; + char alert_hashtag[24]; + char alert_region[31]; + + // Neighbors publishing (PSRAM boards only). Appended at the end of the + // observer tail so a shorter (pre-neighbors) /mqtt_prefs payload from earlier + // firmware still loads with these defaulting off/24h; keeps the format at + // VERSION 1. Field order and sizes are kept byte-identical to the flex + // neighbors build so a /mqtt_prefs written by either firmware is + // interchangeable (see the offsetof static_asserts below). + uint8_t mqtt_neighbors_enabled; + uint32_t mqtt_neighbors_interval; + + // Per-slot payload-type allow masks. Bit N controls MeshCore packet type N + // for both packets and raw MQTT topics. Appended so older v1 payloads load + // with the default all-types masks intact. + uint16_t mqtt_slot_packet_filter[MQTT_PREFS_SLOT_COUNT]; +}; + +// Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose +// signed-delta comparison requires intervals below INT32_MAX ms. The 336h +// (two-week) cap stays comfortably inside that range. +static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_HOURS = 12; +static const uint32_t MQTT_NEIGHBORS_MAX_INTERVAL_HOURS = 336; +static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS = 24; +static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_MS = MQTT_NEIGHBORS_MIN_INTERVAL_HOURS * 3600000UL; +static const uint32_t MQTT_NEIGHBORS_MAX_INTERVAL_MS = MQTT_NEIGHBORS_MAX_INTERVAL_HOURS * 3600000UL; +static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS = MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS * 3600000UL; + +// Version-1 has four payload layouts this firmware can decode. Never infer a +// compatible payload from an arbitrary SHORTER size: raw prefs have no +// checksum, so a short length has to match a boundary that was really shipped. +// +// A LONGER v1 payload is different and is always readable: within a version tag +// the layout is append-only, so a later build's file still starts with this +// binary's exact baseline. classify() reads that prefix and ignores the tail +// rather than rejecting the file — see the downgrade contract there. Any change +// that is not a pure append MUST bump MQTT_PREFS_VERSION instead. +// - PRE_OBSERVER (2736): stops before the observer tail (snmp_*/alert_*). +// - PRE_NEIGHBORS (2860): full observer tail, no neighbors fields yet. +// - PRE_FILTER (2864): neighbors tail, no per-slot packet filters. +// - FULL (2876): current baseline, with six uint16_t filter masks. +// +// FULL is the maximum written, not the default: MQTTPrefsCodec::payloadLenFor() +// keeps emitting PRE_FILTER while every slot holds the all-types default, so a +// node that never touches a filter stays readable by pre-filter firmware. See +// the rollback note there — /mqtt_prefs also carries the WiFi credentials. +static const size_t MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE = 2736; +static const size_t MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE = 2860; +static const size_t MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE = 2864; +static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2876; + +// /mqtt_prefs starts with a self-describing 8-byte header. Headerless files +// are deployed legacy layouts and continue to be distinguished by size. +static const uint8_t MQTT_PREFS_MAGIC[4] = {0xF5, 'M', 'Q', 'P'}; +static const uint16_t MQTT_PREFS_VERSION = 1; + +struct MQTTPrefsHeader { + uint8_t magic[4]; + uint16_t version; + uint16_t payload_len; +}; + +// 3-slot MQTTPrefs layout. Array dimensions changed in the current format, so +// it must be field-copied rather than read into MQTTPrefs directly. +struct ThreeSlotMQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + char wifi_ssid[32]; + char wifi_password[64]; + uint8_t wifi_power_save; + char timezone_string[32]; + int8_t timezone_offset; + char mqtt_slot_preset[3][24]; + char mqtt_slot_host[3][64]; + uint16_t mqtt_slot_port[3]; + char mqtt_slot_username[3][32]; + char mqtt_slot_password[3][64]; + char mqtt_owner_public_key[65]; + char mqtt_email[64]; + uint8_t _legacy_analyzer_us_enabled; + uint8_t _legacy_analyzer_eu_enabled; + char _legacy_mqtt_server[64]; + uint16_t _legacy_mqtt_port; + char _legacy_mqtt_username[32]; + char _legacy_mqtt_password[64]; + char mqtt_slot_token[3][48]; + char mqtt_slot_topic[3][96]; +}; + +// The earlier 3-slot format preceded token/topic fields. +struct ThreeSlotBaseMQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + char wifi_ssid[32]; + char wifi_password[64]; + uint8_t wifi_power_save; + char timezone_string[32]; + int8_t timezone_offset; + char mqtt_slot_preset[3][24]; + char mqtt_slot_host[3][64]; + uint16_t mqtt_slot_port[3]; + char mqtt_slot_username[3][32]; + char mqtt_slot_password[3][64]; + char mqtt_owner_public_key[65]; + char mqtt_email[64]; + uint8_t _legacy_analyzer_us_enabled; + uint8_t _legacy_analyzer_eu_enabled; + char _legacy_mqtt_server[64]; + uint16_t _legacy_mqtt_port; + char _legacy_mqtt_username[32]; + char _legacy_mqtt_password[64]; +}; + +// Headerless 6-slot layout shipped to the deployed flex fleet. It retains the +// removed `_legacy_*` block, so it too is field-copied into MQTTPrefs. +struct Legacy6SlotMQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + char wifi_ssid[32]; + char wifi_password[64]; + uint8_t wifi_power_save; + char timezone_string[32]; + int8_t timezone_offset; + char mqtt_slot_preset[MQTT_PREFS_SLOT_COUNT][24]; + char mqtt_slot_host[MQTT_PREFS_SLOT_COUNT][64]; + uint16_t mqtt_slot_port[MQTT_PREFS_SLOT_COUNT]; + char mqtt_slot_username[MQTT_PREFS_SLOT_COUNT][32]; + char mqtt_slot_password[MQTT_PREFS_SLOT_COUNT][64]; + char mqtt_owner_public_key[65]; + char mqtt_email[64]; + uint8_t _legacy_analyzer_us_enabled; + uint8_t _legacy_analyzer_eu_enabled; + char _legacy_mqtt_server[64]; + uint16_t _legacy_mqtt_port; + char _legacy_mqtt_username[32]; + char _legacy_mqtt_password[64]; + char mqtt_slot_token[MQTT_PREFS_SLOT_COUNT][48]; + char mqtt_slot_topic[MQTT_PREFS_SLOT_COUNT][96]; + char mqtt_slot_audience[MQTT_PREFS_SLOT_COUNT][64]; + uint8_t mqtt_rx_enabled; + char mqtt_ntp_server[64]; +}; + +// Historical headerless 6-slot variants. They share a common prefix but only +// later files contain the appended audience, RX, and NTP fields. +static const size_t LEGACY6_BASE_SIZE = 2452; +static const size_t LEGACY6_AUDIENCE_SIZE = 2836; +static const size_t LEGACY6_AUDIENCE_RX_SIZE = 2840; + +// Frozen on-flash layouts; every firmware and native fixture build checks them. +static_assert(sizeof(MQTTPrefsHeader) == 8, "versioned /mqtt_prefs header must stay 8 bytes"); +static_assert(offsetof(MQTTPrefs, snmp_enabled) == MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE, + "v1 pre-observer /mqtt_prefs boundary changed"); +static_assert(sizeof(MQTTPrefs) == MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, + "v1 /mqtt_prefs payload layout changed"); +// Lock the neighbors tail to the flex neighbors build's layout so a /mqtt_prefs +// written by either firmware is byte-for-byte interchangeable. The enable flag +// lands in the old struct's zeroed trailing padding (offset 2857), and the +// interval begins exactly at the pre-neighbors payload size (2860) so a +// pre-neighbors read stops right before it and the interval keeps its default. +static_assert(offsetof(MQTTPrefs, mqtt_neighbors_enabled) == 2857, + "neighbors enable flag must sit at the flex-compatible offset"); +static_assert(offsetof(MQTTPrefs, mqtt_neighbors_interval) == MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE, + "neighbors interval offset must equal the pre-neighbors payload size"); +static_assert(offsetof(MQTTPrefs, mqtt_slot_packet_filter) == MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE, + "packet filters must begin at the pre-filter payload boundary"); +static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed"); +static_assert(sizeof(PreWifiPowerOldMQTTPrefs) == 472, "frozen pre-WiFi-power /mqtt_prefs layout changed"); +static_assert(offsetof(OldMQTTPrefs, wifi_power_save) == 144, + "frozen post-WiFi-power discriminator offset changed"); +static_assert(offsetof(OldMQTTPrefs, timezone_string) == 145, + "frozen post-WiFi-power timezone offset changed"); +static_assert(offsetof(OldMQTTPrefs, timezone_offset) == 177, + "frozen post-WiFi-power UTC offset changed"); +static_assert(offsetof(OldMQTTPrefs, mqtt_server) == 178, + "frozen post-WiFi-power server offset changed"); +static_assert(offsetof(PreWifiPowerOldMQTTPrefs, timezone_string) == 144, + "frozen pre-WiFi-power timezone offset changed"); +static_assert(offsetof(PreWifiPowerOldMQTTPrefs, timezone_offset) == 176, + "frozen pre-WiFi-power UTC offset changed"); +static_assert(offsetof(PreWifiPowerOldMQTTPrefs, mqtt_server) == 177, + "frozen pre-WiFi-power server offset changed"); +static_assert(sizeof(ThreeSlotBaseMQTTPrefs) == 1032, "frozen early 3-slot /mqtt_prefs layout changed"); +static_assert(sizeof(ThreeSlotMQTTPrefs) == 1464, "frozen 3-slot /mqtt_prefs layout changed"); +static_assert(offsetof(ThreeSlotMQTTPrefs, mqtt_slot_token) == 1030, + "frozen 3-slot token offset changed"); +static_assert(offsetof(ThreeSlotMQTTPrefs, mqtt_slot_topic) == 1174, + "frozen 3-slot topic offset changed"); +static_assert(offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_audience) == LEGACY6_BASE_SIZE, + "frozen early 6-slot /mqtt_prefs prefix changed"); +static_assert(offsetof(Legacy6SlotMQTTPrefs, mqtt_rx_enabled) == LEGACY6_AUDIENCE_SIZE, + "frozen audience 6-slot /mqtt_prefs prefix changed"); +static_assert(offsetof(Legacy6SlotMQTTPrefs, mqtt_ntp_server) == 2837, + "frozen RX 6-slot /mqtt_prefs prefix changed"); +static_assert(sizeof(Legacy6SlotMQTTPrefs) == 2904, "frozen deployed-fleet /mqtt_prefs layout changed"); + +#endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPresets.h b/src/helpers/MQTTPresets.h index 20e36876e..b5f342c8b 100644 --- a/src/helpers/MQTTPresets.h +++ b/src/helpers/MQTTPresets.h @@ -1,5 +1,8 @@ #pragma once +#include +#include // strcmp/memcmp used by the inline preset helpers below + // Maximum number of configurable MQTT connection slots (available to all builds for struct layout). // Used in NodePrefs/MQTTPrefs for persistent storage — do NOT change without migration. static const int MAX_MQTT_SLOTS = 6; @@ -23,7 +26,7 @@ enum MQTTAuthType : uint8_t { enum MQTTTopicStyle : uint8_t { MQTT_TOPIC_MESHCORE, // meshcore/{iata}/{device_id}/{status|packets|raw} - MQTT_TOPIC_MESHRANK, // meshrank/uplink/{token}/{device_id}/{status|packets|raw|neighbors} + MQTT_TOPIC_MESHRANK, // meshrank/uplink/{token}/{device_id}/{type} (no raw) }; struct MQTTPresetDef { @@ -131,7 +134,7 @@ static const int MQTT_PRESET_COUNT = 34; // Built-in preset definitions (stored in flash) static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = { - // name url server rootCA auth topic keepalive tls enabled interval user pass + // name url audience rootCA auth topic tokenLife retain keepAlive user pass { "analyzer-us", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", "mqtt-us-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "analyzer-eu", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", "mqtt-eu-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "nz-analyzer", "wss://meshcore-mqtt-1.baird.io:443", "meshcore-mqtt-1.baird.io", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, diff --git a/src/helpers/MQTTReplyFormat.h b/src/helpers/MQTTReplyFormat.h new file mode 100644 index 000000000..bdb343d70 --- /dev/null +++ b/src/helpers/MQTTReplyFormat.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +// Bounded, clamping printf-append for the fixed-size CLI reply buffers used by +// MQTTBridge's status/stats/diag formatters. Factored out of MQTTBridge so the +// bound is provable on the host instead of holding "by input-size accident" +// (see the A1 out-of-bounds-write finding, 2026-07-19 MQTT observer review). +// +// Appends `fmt...` to `buf` starting at offset `*pos`, then advances `*pos` by +// the number of characters actually written, CLAMPED to [0, bufsize-1]. Because +// snprintf returns the *would-have-written* length, the naive +// `*pos += snprintf(buf + *pos, bufsize - *pos, ...)` idiom can push `*pos` past +// `bufsize` after a truncated append; the next append then computes +// `bufsize - *pos` as a huge size_t and `buf + *pos` past the end, writing out of +// bounds. Clamping `*pos` here makes every subsequent append a safe no-op once +// the buffer is full. +// +// buf is always left NUL-terminated (vsnprintf guarantees this for bufsize > 0). +// No-ops on null buf/pos or bufsize == 0. A negative incoming *pos is treated as +// 0. Typical use: `int pos = 0;` then a sequence of replyAppendf() calls. +static inline void replyAppendf(char* buf, size_t bufsize, int* pos, const char* fmt, ...) { + if (!buf || !pos || bufsize == 0) return; + if (*pos < 0) *pos = 0; + // Full: no room for anything but the terminator. Keep buf NUL-terminated and + // leave *pos pinned at the last writable index. + if ((size_t)*pos >= bufsize - 1) { + *pos = (int)bufsize - 1; + buf[*pos] = '\0'; + return; + } + size_t remaining = bufsize - (size_t)*pos; + va_list args; + va_start(args, fmt); + int n = vsnprintf(buf + *pos, remaining, fmt, args); + va_end(args); + // Encoding error: vsnprintf still NUL-terminated buf + *pos; leave *pos as-is. + if (n < 0) return; + *pos += n; + if ((size_t)*pos >= bufsize) *pos = (int)bufsize - 1; // clamp truncated append +} diff --git a/src/helpers/MQTTRuntimeBufferLifecycle.h b/src/helpers/MQTTRuntimeBufferLifecycle.h new file mode 100644 index 000000000..bb061a9ef --- /dev/null +++ b/src/helpers/MQTTRuntimeBufferLifecycle.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +// Small ownership helpers for MQTT runtime buffers. They intentionally keep +// each buffer independent: a failed allocation leaves that buffer null (so its +// caller can use its stack fallback) without discarding the other buffers. +namespace MQTTRuntimeBufferLifecycle { + +template +inline void* allocateIfMissing(void* buffer, size_t size, Allocator allocate) { + return buffer != nullptr ? buffer : allocate(size); +} + +template +inline void* release(void* buffer, Deallocator deallocate) { + if (buffer != nullptr) { + deallocate(buffer); + } + return nullptr; +} + +} // namespace MQTTRuntimeBufferLifecycle diff --git a/src/helpers/MQTTTopicRouter.h b/src/helpers/MQTTTopicRouter.h new file mode 100644 index 000000000..4dd4b1b8f --- /dev/null +++ b/src/helpers/MQTTTopicRouter.h @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include + +#include "MQTTObserverValidation.h" +#include "MQTTTopicTemplate.h" + +// Pure MQTT publication-topic policy shared by MQTTBridge and the native tests. +// Keep these values aligned with MQTTBridge::MQTTMessageType; the bridge passes +// its enum value as an int so this helper stays independent of ESP/Arduino types. +enum MQTTPublicationType { + MQTT_PUBLICATION_STATUS = 0, + MQTT_PUBLICATION_PACKETS = 1, + MQTT_PUBLICATION_RAW = 2, + MQTT_PUBLICATION_NEIGHBORS = 3, +}; + +enum MQTTTopicRouteStyle { + MQTT_ROUTE_MESHCORE, + MQTT_ROUTE_MESHRANK, + MQTT_ROUTE_CUSTOM, +}; + +static inline bool mqttTopicSlotIndexValid(int index, size_t slot_count) { + return index >= 0 && (size_t)index < slot_count; +} + +static inline const char* mqttPublicationTypeName(int type) { + switch (type) { + case MQTT_PUBLICATION_STATUS: return "status"; + case MQTT_PUBLICATION_PACKETS: return "packets"; + case MQTT_PUBLICATION_RAW: return "raw"; + case MQTT_PUBLICATION_NEIGHBORS: return "neighbors"; + default: return NULL; + } +} + +static inline bool mqttWriteTopic(char* buf, size_t buf_size, const char* format, + const char* first, const char* second, + const char* third) { + if (!buf || buf_size == 0 || !format || !first || !second || !third) return false; + buf[0] = '\0'; + int written = snprintf(buf, buf_size, format, first, second, third); + return written > 0 && (size_t)written < buf_size; +} + +// Build the complete topic for one publication. MeshRank takes status, packets, +// and neighbors under meshrank/uplink/{token}/{device}/, using the same type +// suffixes as the MeshCore layout, and requires a per-slot token rather than an +// IATA. MeshCore routes require a configured IATA and device id. Custom +// templates may omit either placeholder, so their individual values are allowed +// to be empty. +static inline bool mqttBuildPublicationTopic(MQTTTopicRouteStyle style, int type, + const char* custom_template, + const char* iata, const char* device, + const char* token, + char* buf, size_t buf_size) { + if (!buf || buf_size == 0) return false; + buf[0] = '\0'; + + const char* type_name = mqttPublicationTypeName(type); + if (!type_name) return false; + + switch (style) { + case MQTT_ROUTE_MESHCORE: + if (!mqttIataValid(iata) || strcmp(iata, "XXX") == 0 || !device || device[0] == '\0') { + return false; + } + return mqttWriteTopic(buf, buf_size, "meshcore/%s/%s/%s", iata, device, type_name); + + case MQTT_ROUTE_MESHRANK: + // Raw is deliberately withheld: highest-volume topic, and the broker does + // not consume it. observer-firmware still sends it — keep this on merge. + if (type == MQTT_PUBLICATION_RAW || !token || token[0] == '\0' || + !device || device[0] == '\0') { + return false; + } + return mqttWriteTopic(buf, buf_size, "meshrank/uplink/%s/%s/%s", + token, device, type_name); + + case MQTT_ROUTE_CUSTOM: + return mqttSubstituteTopic(custom_template, iata, device, token, type_name, + buf, buf_size); + + default: + return false; + } +} + diff --git a/src/helpers/MQTTTopicTemplate.h b/src/helpers/MQTTTopicTemplate.h new file mode 100644 index 000000000..e0af4d76c --- /dev/null +++ b/src/helpers/MQTTTopicTemplate.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +// Expand the {iata} {device} {token} {type} placeholders in a custom MQTT topic +// template. Factored out of MQTTBridge::substituteTopicTemplate so the (bounded) +// string expansion can be unit-tested on the host; the bridge passes its cached +// _iata / _device_id, the slot token, and the message-type string. +// +// Returns false on buffer overflow or an empty result. buf is always +// NUL-terminated. A null value substitutes as empty; an unknown "{...}" token is +// copied through verbatim. +static inline bool mqttSubstituteTopic(const char* tmpl, const char* iata, + const char* device, const char* token, + const char* type_str, char* buf, size_t buf_size) { + if (!buf || buf_size == 0) return false; + if (!iata) iata = ""; + if (!device) device = ""; + if (!token) token = ""; + if (!type_str) type_str = ""; + + size_t out = 0; + const char* p = tmpl ? tmpl : ""; + while (*p && out < buf_size - 1) { + const char* sub = NULL; + size_t adv = 0; + if (strncmp(p, "{iata}", 6) == 0) { + sub = iata; adv = 6; + } else if (strncmp(p, "{device}", 8) == 0) { + sub = device; adv = 8; + } else if (strncmp(p, "{token}", 7) == 0) { + sub = token; adv = 7; + } else if (strncmp(p, "{type}", 6) == 0) { + sub = type_str; adv = 6; + } + if (sub) { + size_t len = strlen(sub); + if (out + len >= buf_size) { + buf[out] = '\0'; // keep buf terminated even on the overflow path + return false; + } + memcpy(buf + out, sub, len); + out += len; + p += adv; + } else { + buf[out++] = *p++; + } + } + buf[out] = '\0'; + // The loop also stops when the output buffer is full. If input remains, + // report overflow just as we do for an oversized placeholder substitution; + // callers must never publish a silently truncated topic. + if (*p) return false; + return out > 0; +} diff --git a/src/helpers/MeshadventurerBoard.h b/src/helpers/MeshadventurerBoard.h index 65e111029..0325161d5 100644 --- a/src/helpers/MeshadventurerBoard.h +++ b/src/helpers/MeshadventurerBoard.h @@ -15,8 +15,6 @@ #include "ESP32Board.h" -#include - class MeshadventurerBoard : public ESP32Board { public: @@ -35,34 +33,6 @@ public: } } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are held on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void powerOff() override { - // TODO: re-enable this when there is a definite wake-up source pin: - // enterDeepSleep(0); - } - uint16_t getBattMilliVolts() override { analogReadResolution(12); diff --git a/src/helpers/MultiSerialInterface.h b/src/helpers/MultiSerialInterface.h new file mode 100644 index 000000000..f7742b24c --- /dev/null +++ b/src/helpers/MultiSerialInterface.h @@ -0,0 +1,200 @@ +#pragma once + +#include "BaseSerialInterface.h" + +#ifndef MAX_INTERFACES + // ble, usb, wifi, ethernet + #define MAX_INTERFACES 4 +#endif + +enum class InterfaceType : uint8_t { + NONE, + Bluetooth, + USB, + WiFi, + Ethernet, + HardwareSerial +}; + +class MultiSerialInterface : public BaseSerialInterface { +private: + + struct RegisteredInterface { + InterfaceType type = InterfaceType::NONE; + BaseSerialInterface* instance = nullptr; + }; + + bool _enabled = false; + RegisteredInterface _interfaces[MAX_INTERFACES] = {}; + +public: + bool addInterface(InterfaceType type, BaseSerialInterface* iface) { + // make sure an interface was provided + if(iface == nullptr){ + return false; + } + + // put it in the first free slot + for(int i = 0; i < MAX_INTERFACES; i++){ + if(_interfaces[i].instance == nullptr){ + _interfaces[i].instance = iface; + _interfaces[i].type = type; + return true; + } + } + + // no free slots available + return false; + } + + bool removeInterface(BaseSerialInterface* iface) { + // make sure an interface was provided + if(iface == nullptr){ + return false; + } + + // find and remove interface + for(int i = 0; i < MAX_INTERFACES; i++){ + if(_interfaces[i].instance == iface){ + _interfaces[i] = {}; + return true; + } + } + + // interface not found + return false; + } + + void enableBluetooth() { + for(auto iface : _interfaces){ + if(iface.instance && iface.type == InterfaceType::Bluetooth){ + iface.instance->enable(); + } + } + } + + void disableBluetooth() { + for(auto iface : _interfaces){ + if(iface.instance && iface.type == InterfaceType::Bluetooth){ + iface.instance->disable(); + } + } + } + + bool isBluetoothEnabled() { + for(auto iface : _interfaces){ + if(iface.instance && iface.type == InterfaceType::Bluetooth){ + return iface.instance->isEnabled(); + } + } + return false; + } + + // enable all interfaces + void enable() override { + _enabled = true; + for(auto iface : _interfaces){ + if(iface.instance){ + iface.instance->enable(); + } + } + } + + // disable all interfaces + void disable() override { + _enabled = false; + for(auto iface : _interfaces){ + if(iface.instance){ + iface.instance->disable(); + } + } + } + + bool isEnabled() const override { + return _enabled; + } + + bool isConnected() const override { + // not connected when disabled + if(!_enabled){ + return false; + } + + // check if any interface is connected + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isConnected()) { + return true; + } + } + + // nothing connected + return false; + } + + // loop all interfaces + void loop() override { + for(auto iface : _interfaces){ + if(iface.instance){ + iface.instance->loop(); + } + } + } + + bool isWriteBusy() const override { + // not busy when disabled + if(!_enabled){ + return false; + } + + // check if any interface is busy + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isEnabled() && iface.instance->isWriteBusy()){ + return true; + } + } + + // nothing busy + return false; + } + + size_t writeFrame(const uint8_t src[], size_t len) override { + // don't write when disabled or nothing provided + if(!_enabled || len == 0){ + return 0; + } + + // write frame to all enabled interfaces + bool allSuccessful = true; + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isEnabled()){ + if(iface.instance->writeFrame(src, len) != len){ + allSuccessful = false; + } + } + } + + // report success if all writes completed successfully + return allSuccessful ? len : 0; + } + + size_t checkRecvFrame(uint8_t dest[]) override { + // don't read when disabled + if(!_enabled){ + return 0; + } + + // try to read a frame from any enabled interface + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isEnabled()){ + size_t frameSize = iface.instance->checkRecvFrame(dest); + if(frameSize > 0){ + return frameSize; + } + } + } + + // no frame received + return 0; + } + +}; diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 84bdcb3db..d37a2279c 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -1,5 +1,6 @@ #if defined(NRF52_PLATFORM) #include "NRF52Board.h" +#include #include #include @@ -279,16 +280,29 @@ void NRF52Board::sleep(uint32_t secs) { // Temperature from NRF52 MCU float NRF52Board::getMCUTemperature() { - NRF_TEMP->TASKS_START = 1; // Start temperature measurement - - long startTime = millis(); - while (NRF_TEMP->EVENTS_DATARDY == 0) { // Wait for completion. Should complete in 50us - if(millis() - startTime > 5) { // To wait 5ms just in case - NRF_TEMP->TASKS_STOP = 1; + uint8_t sd_enabled = 0; + sd_softdevice_is_enabled(&sd_enabled); + if (sd_enabled) { + uint32_t err_code; + int32_t temp; + err_code = sd_temp_get(&temp); + if (err_code == NRF_SUCCESS) { + return (float)temp * 0.25f; + } else { return NAN; } + } else { + NRF_TEMP->TASKS_START = 1; // Start temperature measurement + + long startTime = millis(); + while (NRF_TEMP->EVENTS_DATARDY == 0) { // Wait for completion. Should complete in 50us + if(millis() - startTime > 5) { // To wait 5ms just in case + NRF_TEMP->TASKS_STOP = 1; + return NAN; + } + } } - + NRF_TEMP->EVENTS_DATARDY = 0; // Clear event flag int32_t temp = NRF_TEMP->TEMP; // In 0.25 *C units @@ -297,6 +311,65 @@ float NRF52Board::getMCUTemperature() { return temp * 0.25f; // Convert to *C } +void NRF52Board::shutdownPeripherals() { + // Power off the display if any +#ifdef DISPLAY_CLASS + if (display.isOn()) { + display.turnOff(); + } +#endif + // Prep LoRa radio for power down + #ifdef P_LORA_RESET + digitalWrite(P_LORA_RESET, HIGH); // preload OUT latch so pinMode can't glitch NRESET low + pinMode(P_LORA_RESET, OUTPUT); + digitalWrite(P_LORA_RESET, LOW); // deliberate hardware reset (datasheet: >=100us) + delayMicroseconds(200); + digitalWrite(P_LORA_RESET, HIGH); + #endif + #if defined(P_LORA_SCLK) && defined(P_LORA_MISO) && defined(P_LORA_MOSI) + SPI.setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); + SPI.begin(); // SPI may not be started on some shutdown paths, need it to shut down radio + #endif + #ifdef P_LORA_BUSY + pinMode(P_LORA_BUSY, INPUT); + uint32_t started_at = millis(); + while (digitalRead(P_LORA_BUSY) && millis() - started_at < 10) {} //wait for radio to be ready + #endif + #ifdef P_LORA_NSS + pinMode(P_LORA_NSS, OUTPUT); + digitalWrite(P_LORA_NSS, HIGH); + #endif + // Power off LoRa + radio_driver.powerOff(); + + // Keep LoRa inactive during deepsleep + #ifdef P_LORA_NSS + digitalWrite(P_LORA_NSS, HIGH); + #endif + + // Power off GPS if any + if(sensors.getLocationProvider() != NULL) { + sensors.getLocationProvider()->stop(); + } + + // Flush serial buffers + Serial.flush(); + delay(100); +} + +void NRF52Board::powerOff() { + shutdownPeripherals(); + + // Enter SYSTEMOFF + uint8_t sd_enabled = 0; + sd_softdevice_is_enabled(&sd_enabled); + if (sd_enabled) { // SoftDevice is enabled + sd_power_system_off(); + } else { // SoftDevice is not enable + NRF_POWER->SYSTEMOFF = POWER_SYSTEMOFF_SYSTEMOFF_Enter; + } +} + bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) { static const char BOOTLOADER_MARKER[] = "UF2 Bootloader "; const uint8_t* flash = (const uint8_t*)0x000FB000; // earliest known info.txt location is 0xFB90B, latest is 0xFCC4B @@ -323,7 +396,8 @@ bool NRF52Board::startOTAUpdate(const char *id, char reply[], bool force_ap) { Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16); - Bluefruit.begin(1, 0); + if (!Bluefruit.begin(1, 0)) return false; + // Set max power. Accepted values are: -40, -30, -20, -16, -12, -8, -4, 0, 4 Bluefruit.setTxPower(4); // Set the BLE device name diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index c7d1e99e6..de716855c 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -50,6 +50,8 @@ public: virtual uint8_t getStartupReason() const override { return startup_reason; } virtual float getMCUTemperature() override; virtual void reboot() override { NVIC_SystemReset(); } + virtual void shutdownPeripherals(); + virtual void powerOff() override; virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override; virtual void sleep(uint32_t secs) override; diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 7b8399e26..4667e0038 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -93,13 +93,15 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) { while (num_regions < MAX_REGION_ENTRIES) { auto r = ®ions[num_regions]; - success = file.read((uint8_t *) &r->id, sizeof(r->id)) == sizeof(r->id); + int n = file.read((uint8_t *) &r->id, sizeof(r->id)); + if (n == 0) break; // clean EOF + success = (n == sizeof(r->id)); success = success && file.read((uint8_t *) &r->parent, sizeof(r->parent)) == sizeof(r->parent); success = success && file.read((uint8_t *) r->name, sizeof(r->name)) == sizeof(r->name); success = success && file.read((uint8_t *) &r->flags, sizeof(r->flags)) == sizeof(r->flags); success = success && file.read(pad, sizeof(pad)) == sizeof(pad); - if (!success) break; // EOF + if (!success) break; // partial read or corruption if (r->id >= next_id) { // make sure next_id is valid next_id = r->id + 1; @@ -108,7 +110,7 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) { } } file.close(); - return true; + return success; } } return false; // failed @@ -139,7 +141,7 @@ bool RegionMap::save(FILESYSTEM* _fs, const char* path) { } } file.close(); - return true; + return success; } return false; // failed } diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 0b79cfb42..f0e6ca12a 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -4,6 +4,16 @@ #ifdef ESP32 #include + // TFT_eSPI (pulled in by the tracker variants' display driver) defines + // FS_NO_GLOBALS, which suppresses FS.h's own `using fs::File`. Without this, + // File never reaches global scope and every TU that reaches FS.h through + // TFT_eSPI first fails with "'File' has not been declared" — here and in + // simple_repeater/MyMesh.h. Restore exactly what FS.h would have done. + // Cannot use fs::File explicitly instead: File is also the global type on the + // nRF52/RP2040 paths, which have no fs namespace. + #if defined(FS_NO_GLOBALS) + using fs::File; + #endif #endif #define MAX_PACKET_HASHES (128+32) @@ -31,27 +41,31 @@ public: } #endif - bool hasSeen(const mesh::Packet* packet) override { + bool wasSeen(const mesh::Packet* packet) override { uint8_t hash[MAX_HASH_SIZE]; packet->calculatePacketHash(hash); const uint8_t* sp = _hashes; for (int i = 0; i < MAX_PACKET_HASHES; i++, sp += MAX_HASH_SIZE) { - if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { + if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { if (packet->isRouteDirect()) { - _direct_dups++; // keep some stats + _direct_dups++; } else { _flood_dups++; } return true; } } - - memcpy(&_hashes[_next_idx*MAX_HASH_SIZE], hash, MAX_HASH_SIZE); - _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; // cyclic table return false; } + void markSeen(const mesh::Packet* packet) override { + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + memcpy(&_hashes[_next_idx * MAX_HASH_SIZE], hash, MAX_HASH_SIZE); + _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; + } + void clear(const mesh::Packet* packet) override { uint8_t hash[MAX_HASH_SIZE]; packet->calculatePacketHash(hash); diff --git a/src/helpers/UTF8Helpers.h b/src/helpers/UTF8Helpers.h new file mode 100644 index 000000000..e06cf4a62 --- /dev/null +++ b/src/helpers/UTF8Helpers.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +namespace mesh { + +inline bool isUtf8Continuation(uint8_t byte) { + return (byte & 0xC0) == 0x80; +} + +inline size_t validUtf8PrefixLength(const char* text, size_t max_bytes) { + if (text == nullptr) return 0; + + size_t offset = 0; + while (text[offset] != '\0') { + const uint8_t first = static_cast(text[offset]); + size_t sequence_length = 0; + + if (first <= 0x7F) { + sequence_length = 1; + } else if (first >= 0xC2 && first <= 0xDF) { + sequence_length = 2; + } else if (first >= 0xE0 && first <= 0xEF) { + sequence_length = 3; + } else if (first >= 0xF0 && first <= 0xF4) { + sequence_length = 4; + } else { + break; + } + + if (offset + sequence_length > max_bytes) break; + + bool complete = true; + for (size_t i = 1; i < sequence_length; i++) { + if (text[offset + i] == '\0' || !isUtf8Continuation(static_cast(text[offset + i]))) { + complete = false; + break; + } + } + if (!complete) break; + + if (sequence_length == 3) { + const uint8_t second = static_cast(text[offset + 1]); + if ((first == 0xE0 && second < 0xA0) || (first == 0xED && second > 0x9F)) break; + } else if (sequence_length == 4) { + const uint8_t second = static_cast(text[offset + 1]); + if ((first == 0xF0 && second < 0x90) || (first == 0xF4 && second > 0x8F)) break; + } + + offset += sequence_length; + } + return offset; +} + +} // namespace mesh diff --git a/src/helpers/WebConfigBatch.h b/src/helpers/WebConfigBatch.h new file mode 100644 index 000000000..5532bbad2 --- /dev/null +++ b/src/helpers/WebConfigBatch.h @@ -0,0 +1,310 @@ +#pragma once + +#include // size_t / NULL for the reply classifiers below +#include + +// Fork-owned, dependency-free spec for the WebConfig "config batch / reboot / +// stop" decision + timing core, plus its host tests (test/test_webconfig_batch/). +// +// This is the Phase 6 counterpart of MQTTLifecycle.h: a PURE state machine that +// captures exactly what src/helpers/esp32/WebConfigServer.cpp decides today, so +// the POST-accept / drain / result-read / reboot / stop transitions can be +// exercised deterministically without AsyncWebServer, ArduinoJson, WiFi, or the +// FreeRTOS mutex/refcount. +// +// WIRED: WebConfigServer.cpp calls these functions directly, so they are +// load-bearing and the host tests cover production behavior rather than a +// parallel copy of it. MAX_BATCH and STOP_WARN_MS in WebConfigServer.h alias +// kMaxBatch/kStopWarnMs here, so the constants cannot drift either. +// +// Two deliberate asymmetries remain between this spec and its caller: +// +// 1. finishRebootAt() returns 0 for "no reboot scheduled", but the caller must +// only ASSIGN _reboot_at when the result is non-zero. _reboot_at is not +// solely batch-owned — the manual /api/reboot route arms it from the +// async_tcp task, possibly while a batch is still draining — so an +// unconditional assign would silently cancel a manual reboot. +// 2. classifyPost() is consulted in two phases by handleConfigPost, because the +// change count is only known after the `set` map is parsed, and parsing must +// not precede the Replay/Busy answer (a replayed POST carrying a bad key +// must still receive its 202). +// +// The file:line references below point at the behavior each function mirrors. +// +// Behavior source (all line refs against WebConfigServer.{h,cpp} at the time of +// writing): constants at .h:90-96,155-161; POST accept at .cpp:610-719; drain at +// .cpp:289-334; result read at .cpp:721-791; reboot fire at .cpp:262-265; +// isRebootPending at .cpp:70-74; stop gating at .cpp:185-255. +namespace WebConfigBatch { + +// Constants, verbatim from WebConfigServer. +static const int kMaxBatch = 24; // .h:90 MAX_BATCH +static const uint32_t kDrainPacingMs = 25; // .cpp:296 inter-command gap +static const uint32_t kRebootFallbackMs = 30000; // .cpp:331 drain-finish fallback +static const uint32_t kRebootConfirmMs = 3000; // .cpp:784 first result-read arm +static const uint32_t kStopWarnMs = 10000; // .h:95 STOP_WARN_MS + +// The batch lifecycle. A fresh POST moves Idle->Pending; the drainer moves +// Pending->Done; Done stays re-readable until the next POST claims the slot; +// finalizeTeardown() resets to Idle. +enum class State : uint8_t { + Idle = 0, + Pending, + Done, +}; + +// millis() idioms. elapsedMs uses unsigned wraparound (correct across one 32-bit +// rollover). scheduleAt mirrors the production wrap-guard: every _reboot_at / +// _stop_warn_at assignment does `if (t == 0) t = 1;` so 0 keeps meaning +// "unscheduled" even when the deadline lands exactly on the rollover boundary. +static inline uint32_t elapsedMs(uint32_t now, uint32_t then) { return now - then; } +static inline uint32_t scheduleAt(uint32_t now, uint32_t delay) { + const uint32_t t = now + delay; + return t == 0 ? 1u : t; +} +// Signed wrap-safe "deadline reached", matching the production +// `(int32_t)(now - deadline) >= 0` comparisons. +static inline bool deadlineReached(uint32_t now, uint32_t deadline) { + return (int32_t)(now - deadline) >= 0; +} + +// -------------------------------------------------------------------------- +// POST accept classification (.cpp:637-718). Precedence, verbatim from the +// source: an in-flight/finished batch with the SAME reqid is an idempotent +// replay (commands are NOT re-applied); a DIFFERENT reqid while a batch is still +// PENDING is rejected as busy; otherwise a batch with no changes and no reboot +// is a no-op, and anything else is accepted. Note the asymmetry: a different +// reqid while DONE is NOT busy — the new batch overwrites the DONE slot. +// +// Assumes the request already passed reqid grammar (WebConfigKeys::wcIsValidReqId) +// and per-key allowlist/secret validation, which are covered by test_webconfig_keys. +// -------------------------------------------------------------------------- +enum class PostOutcome : uint8_t { + Replay, // 202; reqid matches the current batch, commands not re-applied + Busy, // 409; a different batch is still PENDING + Accept, // 202; a new batch is accepted (from Idle, or overwriting a Done slot) + NoChanges, // 400; nothing to do (no changes and no reboot requested) +}; + +static inline PostOutcome classifyPost(State state, bool reqid_matches_current, + int change_count, bool reboot_after) { + if (state != State::Idle && reqid_matches_current) return PostOutcome::Replay; + if (state == State::Pending) return PostOutcome::Busy; // reqid differs (match handled above) + if (change_count <= 0 && !reboot_after) return PostOutcome::NoChanges; + return PostOutcome::Accept; +} + +// The state string a Replay body reports mirrors the batch state (.cpp:640): +// "done" when the matched batch already finished, else "pending". +static inline const char* replayStateName(State state) { + return state == State::Done ? "done" : "pending"; +} + +// -------------------------------------------------------------------------- +// Drain (.cpp:289-333). One command per tick. +// -------------------------------------------------------------------------- + +// The drainer waits only BETWEEN commands: never before the first (batch_next +// == 0 runs immediately and fires onConfigBatchStart), never after the last, and +// otherwise until the 25 ms pacing gap elapses. The pacing compare is SIGNED to +// mirror the source verbatim (.cpp:296 `(int32_t)(now - _batch_last_cmd) < 25`), +// matching deadlineReached()'s signedness; for all reachable inputs (elapsed +// 0..25 ms) it is identical to the unsigned form. +static inline bool drainMustWait(int batch_next, int batch_count, + uint32_t now, uint32_t last_cmd_ms) { + return batch_next > 0 && batch_next < batch_count && + (int32_t)(now - last_cmd_ms) < (int32_t)kDrainPacingMs; +} + +// all_ok is a sticky AND across command replies; a reply counts as ok iff it +// begins with "OK" (.cpp:314). Once false it stays false. +static inline bool nextAllOk(bool prev_all_ok, bool reply_is_ok) { + return prev_all_ok && reply_is_ok; +} + +// The batch is finished once the post-increment drain index reaches the count +// (.cpp:319-321). +static inline bool drainFinished(int batch_next_after_increment, int batch_count) { + return batch_next_after_increment >= batch_count; +} + +// On finish, a reboot-requested + all-ok batch arms the 30 s fallback deadline; +// a partially-failed batch (all_ok == false) never reboots (.cpp:324-333). +// Returns the reboot_at deadline, or 0 for "no reboot scheduled". +static inline uint32_t finishRebootAt(bool batch_reboot, bool batch_all_ok, uint32_t now) { + return (batch_reboot && batch_all_ok) ? scheduleAt(now, kRebootFallbackMs) : 0u; +} + +// -------------------------------------------------------------------------- +// Result read (.cpp:738-786). +// -------------------------------------------------------------------------- +enum class ResultOutcome : uint8_t { + Idle, // 200 "idle" — no batch; any valid reqid is echoed + Unknown, // 404 — a batch exists but the reqid does not match it + Pending, // 200 "pending" + Done, // 200 "done" (+ per-command results) +}; + +static inline ResultOutcome classifyResult(State state, bool reqid_matches_current) { + if (state == State::Idle) return ResultOutcome::Idle; // no reqid check while idle + if (!reqid_matches_current) return ResultOutcome::Unknown; + return state == State::Pending ? ResultOutcome::Pending : ResultOutcome::Done; +} + +// The "reboot" flag reported in a Done body (.cpp:767): only a fully-OK, +// reboot-requested batch advertises a pending reboot. +static inline bool doneReportsReboot(bool batch_reboot, bool batch_all_ok) { + return batch_reboot && batch_all_ok; +} + +// The first Done read arms the confirmed (3 s) reboot exactly once (.cpp:777-786): +// the !already_armed guard makes later reads idempotent, so polling cannot push +// the deadline out. When this returns true the caller sets armed = true and +// reboot_at = confirmRebootAt(now). +static inline bool shouldArmConfirmReboot(State state, bool batch_reboot, + bool batch_all_ok, bool already_armed) { + return state == State::Done && batch_reboot && batch_all_ok && !already_armed; +} +static inline uint32_t confirmRebootAt(uint32_t now) { + return scheduleAt(now, kRebootConfirmMs); +} + +// -------------------------------------------------------------------------- +// CLI sequences (/api/cli). The terminal shares this one deferred-command slot +// with config saves rather than owning a second MAX_BATCH array: both drain on +// the loop task, both are single-slot, and a duplicate would cost ~8 KB of +// permanently resident RAM. Sharing also makes a save and a CLI run mutually +// exclusive, which they must be. +// +// Two things differ from a config save: +// 1. results stream. A save's results appear only when the whole batch is +// Done; a CLI read hands back whatever has executed so far, so a pasted +// sequence fills the terminal command by command. +// 2. the reboot is not requested by a `reboot` flag on the request but by the +// word `reboot` appearing in the sequence. It is deferred rather than +// executed, because Board::reboot() does not return and would take the node +// down before the client could read a single result. +// -------------------------------------------------------------------------- + +// Results returned by one read. Bounds the JSON document built on the +// async_tcp task; a longer sequence pages across successive reads. +static const int kCliResultPage = 8; + +static inline int cliPageCount(int from, int produced, int page) { + const int pending = produced - from; + if (pending <= 0) return 0; + return pending > page ? page : pending; +} + +// "done" means the client has been handed every result, not merely that +// execution finished: the last page may still be unread, and a client that +// stops polling at "done" would lose it. +static inline bool cliReadIsFinal(State state, int from, int page_count, int total) { + return state == State::Done && from + page_count >= total; +} + +// A trailing `reboot` is withheld when any command in the sequence failed, +// exactly as a config save's is. The operator asked for the reboot, but +// rebooting into a half-applied config — over a link they may not get back — +// is the worse failure, and the result body reports the refusal. +static inline bool cliRebootAllowed(bool has_reboot, bool all_ok) { + return has_reboot && all_ok; +} + +// CommonCLI has no single failure convention. Testing only for an "Err" prefix +// let five other shapes through as success — including "Unknown command" and +// "unknown config: x", the two an operator hits most — which coloured them +// green AND let a queued reboot proceed after them. +// +// Every shape below is a literal from CommonCLI.cpp / CommonCLI_Observer.cpp. +// This list is the fragile part of the design: a new failure string added there +// is silently a success here. Which is exactly why it must not be what decides +// whether to reboot — see cliReplyConfirmsWrite. +static inline bool cliReplyIsFailure(const char* r) { + if (r == NULL || r[0] == 0) return false; // empty is normalised to "OK" + static const char* const kPrefixes[] = { + "Err", "ERR", "err", // "Err - ", "ERR: ", "Error: " + "(ERR", // "(ERR: clock cannot go backwards)" + "Unknown command", + "unknown config", + "??", // "??: " from the get fallthrough + "Can't find", // "Can't find GPS" + }; + for (size_t i = 0; i < sizeof(kPrefixes) / sizeof(kPrefixes[0]); i++) { + const char* p = kPrefixes[i]; + size_t n = 0; + while (p[n]) n++; + bool match = true; + for (size_t j = 0; j < n; j++) { + if (r[j] != p[j]) { match = false; break; } + } + if (match) return true; + } + // "File system erase: Err" reports the failure at the END of the reply. + for (size_t i = 0; r[i]; i++) { + if (r[i] == ':' && r[i + 1] == ' ' && r[i + 2] == 'E' && r[i + 3] == 'r' && + r[i + 4] == 'r') return true; + } + return false; +} + +// Whether a command's reply is allowed to gate the deferred reboot. +// +// Only writes are, and only writes have a reply convention worth trusting: +// every setter answers with an "OK" prefix. Diagnostics do not — `memory` +// answers "Free: ...", a getter answers "> value" — so letting them gate would +// mean guessing, and guessing wrong here either strands the operator (a +// harmless `memory` blocks their reboot) or reboots into a config that did not +// apply. The question the gate exists to answer is narrower than "did anything +// fail": it is "did every setting I asked for actually take". +static inline bool cliReplyGatesReboot(const char* cmd) { + if (cmd == NULL) return false; + const char* set = "set "; + const char* pwd = "password "; + bool is_set = true, is_pwd = true; + for (int i = 0; i < 4; i++) if (cmd[i] != set[i]) { is_set = false; break; } + for (int i = 0; i < 9; i++) if (cmd[i] != pwd[i]) { is_pwd = false; break; } + return is_set || is_pwd; +} + +// A write took effect iff its reply starts with "OK" — the one convention every +// setter in CommonCLI actually keeps. +static inline bool cliWriteSucceeded(const char* reply) { + return reply != NULL && reply[0] == 'O' && reply[1] == 'K'; +} + +// -------------------------------------------------------------------------- +// Reboot fire (.cpp:262-265) and isRebootPending (.cpp:70-74). +// -------------------------------------------------------------------------- +static inline bool rebootDue(uint32_t reboot_at, uint32_t now) { + return reboot_at != 0 && deadlineReached(now, reboot_at); +} + +// isRebootPending() reports true only for a config-save reboot in the Done +// state, so the manual /api/reboot path (batch_reboot == false) is deliberately +// NOT reported as pending even though _reboot_at is set. +static inline bool isConfigRebootPending(uint32_t reboot_at, bool batch_reboot, State state) { + return reboot_at != 0 && batch_reboot && state == State::Done; +} + +// -------------------------------------------------------------------------- +// Stop gating (.cpp:244-255). Teardown waits indefinitely for in-flight async +// handlers to drain (refs == 0); the STOP_WARN_MS timer only triggers a one-time +// diagnostic — it never forces teardown. +// -------------------------------------------------------------------------- +enum class StopAction : uint8_t { + Finalize, // refs == 0: finalizeTeardown() may run now + Warn, // refs > 0 and the warn deadline passed, not yet warned: log once + Wait, // refs > 0: keep the session alive and wait +}; + +static inline StopAction stopStep(uint32_t handler_refs, bool already_warned, + uint32_t stop_warn_at, uint32_t now) { + if (handler_refs == 0) return StopAction::Finalize; + if (!already_warned && stop_warn_at != 0 && deadlineReached(now, stop_warn_at)) { + return StopAction::Warn; + } + return StopAction::Wait; +} + +} // namespace WebConfigBatch diff --git a/src/helpers/WebConfigKeys.h b/src/helpers/WebConfigKeys.h new file mode 100644 index 000000000..47fd41494 --- /dev/null +++ b/src/helpers/WebConfigKeys.h @@ -0,0 +1,120 @@ +#pragma once + +#include +#include "MQTTPresets.h" // MAX_MQTT_SLOTS + +// Classification of the config keys the web portal is allowed to drive through +// the CLI `set` handlers. Factored out of WebConfigServer.cpp so the allowlist +// and the (attacker-facing) key parsing can be unit-tested on the host without +// pulling in the whole ESP32 web server (see test/test_webconfig_keys). +// +// Everything here is pure string logic. The functions are `static inline` so +// each translation unit that includes this gets its own copy (there are only +// two: WebConfigServer.cpp and the test), avoiding any ODR concern. + +// Keys mapping to CLI `set ` handlers. Everything not listed here +// is rejected, so a crafted request can't reach arbitrary commands (`erase`, +// etc.) through the batch. The portal's admin-password field is classified +// separately, see wcIsAdminPasswordKey below. +static const char* const WC_ALLOWED_SET_KEYS[] = { + // NodePrefs (radio / node) + "name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay", + "cad", "radio.rxgain", "repeat", "advert.interval", "flood.advert.interval", + "flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect", + // MQTTPrefs (WiFi / MQTT / misc observer) + "wifi.ssid", "wifi.pwd", "wifi.powersave", + "mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw", + "mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.neighbors", "mqtt.neighbors.interval", + "mqtt.ntp", "mqtt.owner", "mqtt.email", + "timezone", "timezone.offset", "snmp", "snmp.community", +}; +static const char* const WC_ALLOWED_SLOT_KEYS[] = { + "preset", "server", "port", "username", "password", "token", "topic", "audience", + "filter", +}; + +// True when `key` is a well-formed per-slot key ("mqttN." with N in +// 1..MAX_MQTT_SLOTS). The shortest such key is "mqttN.x" (7 chars), and this +// probes key[4..6], so the length guard must come first — an attacker-supplied +// "mqtt" or "m" would otherwise read past the terminator. +static inline bool wcIsSlotKeyPrefix(const char* key) { + return strlen(key) >= 7 && memcmp(key, "mqtt", 4) == 0 + && key[4] >= '1' && key[4] <= ('0' + MAX_MQTT_SLOTS) && key[5] == '.'; +} + +static inline bool wcIsAllowedSetKey(const char* key) { + for (size_t i = 0; i < sizeof(WC_ALLOWED_SET_KEYS) / sizeof(WC_ALLOWED_SET_KEYS[0]); i++) { + if (strcmp(key, WC_ALLOWED_SET_KEYS[i]) == 0) return true; + } + // mqtt<1-6>. + if (wcIsSlotKeyPrefix(key)) { + for (size_t i = 0; i < sizeof(WC_ALLOWED_SLOT_KEYS) / sizeof(WC_ALLOWED_SLOT_KEYS[0]); i++) { + if (strcmp(&key[6], WC_ALLOWED_SLOT_KEYS[i]) == 0) return true; + } + } + return false; +} + +// The admin password maps to the top-level `password` command, not a setter, so +// it is classified apart from the `set` allowlist. It is the only key that gets +// this treatment, which is what keeps the allowlist the sole route to `set` and +// leaves no general path from a batch to arbitrary top-level CLI commands. +static inline bool wcIsAdminPasswordKey(const char* key) { + return strcmp(key, "password") == 0; +} + +static inline bool wcIsValidAdminPassword(const char* value) { + if (value == NULL) return false; + const size_t len = strlen(value); + if (len == 0 || len > 15) return false; // NodePrefs::password[16], including NUL + for (size_t i = 0; i < len; i++) { + if (value[i] == '\r' || value[i] == '\n') return false; // reject, never silently strip + } + return true; +} + +// Keys carrying a secret whose stored value is masked with the placeholder in +// the UI; a POST echoing the placeholder for one of these is dropped (unchanged). +static inline bool wcIsSecretKey(const char* key) { + if (strcmp(key, "wifi.pwd") == 0) return true; + if (wcIsSlotKeyPrefix(key) + && (strcmp(&key[6], "password") == 0 || strcmp(&key[6], "token") == 0)) return true; + return false; +} + +// CommonCLI answers a secret getter in plaintext only for the serial console +// (sender_timestamp 0) and masks it for remote callers. The web CLI executes +// with sender_timestamp 0 — that is what makes `erase`, `stats-*` and `set freq` +// reachable — so it would otherwise inherit the serial console's plaintext +// answers for an HTTP request. This says which `get` commands must be masked +// back down, restoring the distinction for a caller not at the serial port. +// +// Writing these has always been possible from the portal; reading them never +// was, because handleConfigGet masks them (wcIsSecretKey). The two are different +// capabilities: replacing a WiFi password does not reveal the current one, and +// replacing an identity does not reveal the existing private key. +static inline bool wcIsSecretReadCommand(const char* cmd) { + if (strncmp(cmd, "get ", 4) != 0) return false; + const char* key = cmd + 4; + while (*key == ' ') key++; + if (strcmp(key, "prv.key") == 0) return true; // this node's identity + if (strcmp(key, "guest.password") == 0) return true; + if (strcmp(key, "alert.psk") == 0) return true; + if (strcmp(key, "bridge.secret") == 0) return true; + return wcIsSecretKey(key); // wifi.pwd, mqttN.password, mqttN.token +} + +// Browser-generated request IDs are exactly eight random bytes encoded as +// hexadecimal. Keeping the grammar deliberately small makes the ID safe to +// echo in JSON/logs and prevents an empty or truncated ID from weakening the +// save/result correlation contract. +static inline bool wcIsValidReqId(const char* reqid) { + if (reqid == NULL || strlen(reqid) != 16) return false; + for (size_t i = 0; i < 16; i++) { + char c = reqid[i]; + if (!((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'))) return false; + } + return true; +} diff --git a/src/helpers/bridges/BridgeBase.cpp b/src/helpers/bridges/BridgeBase.cpp index d2e2e5e00..8093d3cb5 100644 --- a/src/helpers/bridges/BridgeBase.cpp +++ b/src/helpers/bridges/BridgeBase.cpp @@ -39,7 +39,8 @@ void BridgeBase::handleReceivedPacket(mesh::Packet *packet) { return; } - if (!_seen_packets.hasSeen(packet)) { + if (!_seen_packets.wasSeen(packet)) { + _seen_packets.markSeen(packet); // bridge_delay provides a buffer to prevent immediate processing conflicts in the mesh network. _mgr->queueInbound(packet, millis() + _prefs->bridge_delay); } else { diff --git a/src/helpers/bridges/BridgeBase.h b/src/helpers/bridges/BridgeBase.h index 04c1564bd..8bbe64667 100644 --- a/src/helpers/bridges/BridgeBase.h +++ b/src/helpers/bridges/BridgeBase.h @@ -110,7 +110,7 @@ protected: * @brief Common packet handling for received packets * * Implements the standard pattern used by all bridges: - * - Check if packet was seen before using _seen_packets.hasSeen() + * - Check if packet was seen before using _seen_packets.wasSeen() * - Queue packet for mesh processing if not seen before * - Free packet if already seen to prevent duplicates * diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index b9eb1c105..3c094e7c9 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -32,7 +32,7 @@ void ESPNowBridge::begin() { // Initialize WiFi in station mode WiFi.mode(WIFI_STA); - // Set wifi channel + // Set Wi-Fi channel if (esp_wifi_set_channel(_prefs->bridge_channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) { BRIDGE_DEBUG_PRINTLN("Error setting WIFI channel to %d\n", _prefs->bridge_channel); return; @@ -167,7 +167,8 @@ void ESPNowBridge::sendPacket(mesh::Packet *packet) { return; } - if (!_seen_packets.hasSeen(packet)) { + if (!_seen_packets.wasSeen(packet)) { + _seen_packets.markSeen(packet); // Create a temporary buffer just for size calculation and reuse for actual writing uint8_t sizingBuffer[MAX_PAYLOAD_SIZE]; uint16_t meshPacketLen = packet->writeTo(sizingBuffer); diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 39cd494d8..9f1ecab3d 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1,5 +1,10 @@ #include "MQTTBridge.h" +#include "../MQTTConnectionPolicy.h" #include "../MQTTMessageBuilder.h" +#include "../MQTTPacketQueuePolicy.h" +#include "../MQTTReplyFormat.h" +#include "../MQTTRuntimeBufferLifecycle.h" +#include "../MQTTTopicRouter.h" #include "../TxtDataHelpers.h" #include #include @@ -202,14 +207,17 @@ unsigned long MQTTBridge::getWifiConnectedAtMillis() { } #if defined(WITH_MQTT_NEIGHBORS) -// Compact "3h12m" / "12m" / "45s" for CLI replies that share a 160-byte buffer. -static void formatDuration(char* buf, size_t bufsize, uint32_t secs) { - if (secs >= 3600) { - snprintf(buf, bufsize, "%uh%um", (unsigned)(secs / 3600), (unsigned)((secs % 3600) / 60)); - } else if (secs >= 60) { - snprintf(buf, bufsize, "%um", (unsigned)(secs / 60)); +// Compact "time remaining" for the `get mqtt.status` nbr field: "3h12m" / "12m" / "45s". +static void formatDuration(char* buf, size_t len, uint32_t secs) { + if (!buf || len == 0) return; + uint32_t h = secs / 3600; + uint32_t m = (secs % 3600) / 60; + if (h > 0) { + snprintf(buf, len, "%uh%um", (unsigned)h, (unsigned)m); + } else if (m > 0) { + snprintf(buf, len, "%um", (unsigned)m); } else { - snprintf(buf, bufsize, "%us", (unsigned)secs); + snprintf(buf, len, "%us", (unsigned)secs); } } #endif @@ -234,8 +242,11 @@ void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPref q = b->_queue_count; #endif - int pos = snprintf(buf, bufsize, "> msgs: %s", msgs); - for (int i = 0; i < RUNTIME_MQTT_SLOTS && pos < (int)bufsize - 1; i++) { + // replyAppendf clamps pos into the buffer on every call, so no per-append + // guard or trailing clamp is needed (see MQTTReplyFormat.h / A1). + int pos = 0; + replyAppendf(buf, bufsize, &pos, "> msgs: %s", msgs); + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { const MQTTSlot& slot = b->_slots[i]; const char* name = nullptr; const char* state = nullptr; @@ -258,27 +269,17 @@ void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPref name = slot.preset ? slot.preset->name : "custom"; state = "disc"; } - pos += snprintf(buf + pos, bufsize - pos, ", %d: %s (%s)", i + 1, name, state); - // snprintf returns the untruncated length, so clamp before reusing pos as an - // offset: an overflowing pos would walk past buf and underflow bufsize - pos. - if (pos >= (int)bufsize) { pos = (int)bufsize - 1; break; } - } - if (pos < (int)bufsize - 1) { - pos += snprintf(buf + pos, bufsize - pos, ", q:%d", q); - if (pos >= (int)bufsize) pos = (int)bufsize - 1; + replyAppendf(buf, bufsize, &pos, ", %d: %s (%s)", i + 1, name, state); } + replyAppendf(buf, bufsize, &pos, ", q:%d", q); #if defined(WITH_MQTT_NEIGHBORS) // Periodic neighbors: time to next publish + how the last one went. - if (obs && obs->mqtt_neighbors_enabled && pos < (int)bufsize - 1) { + if (obs && obs->mqtt_neighbors_enabled) { char when[16]; switch (b->_neighbors_phase.load(std::memory_order_relaxed)) { - case NBR_ACTIVE: - strcpy(when, "active"); - break; - case NBR_DUE: - strcpy(when, "due"); - break; + case NBR_ACTIVE: strcpy(when, "active"); break; + case NBR_DUE: strcpy(when, "due"); break; default: formatDuration(when, sizeof(when), b->_neighbors_secs_until_next.load(std::memory_order_relaxed)); @@ -290,7 +291,7 @@ void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPref case NBR_RESULT_FAIL: last = "failed"; break; default: last = "none"; break; } - snprintf(buf + pos, bufsize - pos, ", nbr: %s/%s", when, last); + replyAppendf(buf, bufsize, &pos, ", nbr: %s/%s", when, last); } #endif } @@ -319,29 +320,73 @@ void MQTTBridge::formatMqttStatsReply(char* buf, size_t bufsize) { #endif size_t outbox_total = 0; + unsigned long outbox_drops = 0; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (b->_slots[i].client) outbox_total += b->_slots[i].client->getOutboxSize(); + if (b->_slots[i].client) { + outbox_total += b->_slots[i].client->getOutboxSize(); + outbox_drops += b->_slots[i].client->getOutboxDrops(); + } } - int pos = snprintf(buf, bufsize, "> Free=%d Max=%d q:%d/%d Outbox=%u |", - (int)ESP.getFreeHeap(), (int)ESP.getMaxAllocHeap(), - q, MAX_QUEUE_SIZE, (unsigned)outbox_total); - for (int i = 0; i < RUNTIME_MQTT_SLOTS && pos < (int)bufsize - 1; i++) { + // drops=/: outbox-cap drops vs. memory-pressure skips. + int pos = 0; + replyAppendf(buf, bufsize, &pos, "> Free=%d Max=%d q:%d/%d Outbox=%u drops=%lu/%d", + (int)ESP.getFreeHeap(), (int)ESP.getMaxAllocHeap(), + q, MAX_QUEUE_SIZE, (unsigned)outbox_total, + outbox_drops, b->_skipped_publishes); + // filt=: packets the per-slot type filters rejected before the queue. + // Omitted while zero so an unfiltered node's reply keeps its former length — + // the per-slot list below is what usually gets clamped away first. + if (b->_filtered_packets > 0) { + replyAppendf(buf, bufsize, &pos, " filt=%lu", b->_filtered_packets); + } + replyAppendf(buf, bufsize, &pos, " |"); + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (!b->_slots[i].enabled || !b->_slots[i].client) continue; - pos += snprintf(buf + pos, bufsize - pos, " s%d=%lu/%lu", i + 1, - b->_slots[i].client->getPublishOk(), - b->_slots[i].client->getPublishErr()); + replyAppendf(buf, bufsize, &pos, " s%d=%lu/%lu", i + 1, + b->_slots[i].client->getPublishOk(), + b->_slots[i].client->getPublishErr()); } } -uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; } -unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; } +// Structured per-slot status for the webconfig stats endpoint. Same state +// derivation as formatMqttStatusReply above. Returns false for out-of-range, +// unconfigured, or bridge-not-running slots. +bool MQTTBridge::getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out) { + if (out == nullptr || slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return false; + if (s_mqtt_bridge_instance == nullptr || !s_mqtt_bridge_instance->_initialized) return false; + MQTTBridge* b = s_mqtt_bridge_instance; + const MQTTSlot& slot = b->_slots[slot_index]; + + if (!slot.enabled && slot.preset) { + out->name = slot.preset->name; + out->state = "inactive"; + } else if (!slot.enabled) { + return false; // unconfigured slot + } else { + out->name = slot.preset ? slot.preset->name : "custom"; + if (!b->isSlotReady(slot_index)) { + out->state = "wait"; + } else if (slot.connected) { + out->state = "ok"; + } else if (slot.circuit_breaker_tripped) { + out->state = "fail"; + } else { + out->state = "disc"; + } + } + out->publish_ok = slot.client ? slot.client->getPublishOk() : 0; + out->publish_err = slot.client ? slot.client->getPublishErr() : 0; + // Lets the portal show why a healthy slot is quiet. + out->filter_mask = b->_obs ? b->_obs->mqtt_slot_packet_filter[slot_index] + : MQTTPacketFilter::kAllPacketTypes; + return true; +} -// Each WSS/TLS connection needs ~40KB for mbedTLS buffers. Without PSRAM even 3 -// concurrent connections would exhaust internal heap, so cap at 2; with PSRAM -// cap at 5 (6 configurable but 5 active max). Static so the CLI can report, at -// config time, that a slot beyond this cap won't connect. int MQTTBridge::getMaxActiveSlots() { + // Each WSS/TLS connection needs ~40KB for mbedTLS buffers. Without PSRAM even + // 3 concurrent connections would exhaust internal heap, so cap at 2; with + // PSRAM cap at 5 (6 configurable but 5 active max). #if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM) return psramFound() ? 5 : 2; #else @@ -349,6 +394,9 @@ int MQTTBridge::getMaxActiveSlots() { #endif } +uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; } +unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; } + unsigned long MQTTBridge::getSlotCurrentOutageStartMs(int slot_index) const { if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return 0; return _slots[slot_index].current_outage_started_ms; @@ -436,52 +484,108 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index) state = "disc"; } - int pos = snprintf(buf, bufsize, "> mqtt%d: %s", slot_index + 1, state); + // replyAppendf clamps pos on every call, so the chained appends below can't + // walk past the reply buffer even if the accumulated text exceeds it (A1). + // A non-default filter is the one healthy-looking reason for a slot to stop + // publishing, so it has to appear — but appended last. replyAppendf clamps at + // the 160-byte reply, and an error tail (TLS + mbedTLS + errno + age) can + // already reach ~117 chars, so putting the filter first would push the + // operator's diagnostic detail off the end of a failing slot's line. + const uint16_t filter_mask = b->_obs ? b->_obs->mqtt_slot_packet_filter[slot_index] + : MQTTPacketFilter::kAllPacketTypes; + char filter_text[MQTTPacketFilter::kFilterTextSize]; + const bool show_filter = filter_mask != MQTTPacketFilter::kAllPacketTypes && + MQTTPacketFilter::format(filter_mask, filter_text, sizeof(filter_text)); + + int pos = 0; + replyAppendf(buf, bufsize, &pos, "> mqtt%d: %s", slot_index + 1, state); if (slot.disconnect_count > 0) { - pos += snprintf(buf + pos, bufsize - pos, ", dc:%lu", (unsigned long)slot.disconnect_count); + replyAppendf(buf, bufsize, &pos, ", dc:%lu", (unsigned long)slot.disconnect_count); if (slot.first_disconnect_time > 0) { unsigned long first_disc_age_sec = (millis() - slot.first_disconnect_time) / 1000; - pos += snprintf(buf + pos, bufsize - pos, ", first_disc:%lus", first_disc_age_sec); + replyAppendf(buf, bufsize, &pos, ", first_disc:%lus", first_disc_age_sec); } } - // If connected with no errors, we're done + // Connected with no errors: nothing more to say about the connection. if (slot.connected && slot.last_error_time == 0) { - snprintf(buf + pos, bufsize - pos, ", no errors"); - return; - } - - // Show last error if we have one - if (slot.last_error_time > 0) { + replyAppendf(buf, bufsize, &pos, ", no errors"); + } else if (slot.last_error_time > 0) { // TLS error with human-friendly description if (slot.last_tls_err != 0) { const char* desc = tlsErrorStr(slot.last_tls_err); if (desc) { - pos += snprintf(buf + pos, bufsize - pos, ", %s (0x%04X)", desc, (unsigned)slot.last_tls_err); + replyAppendf(buf, bufsize, &pos, ", %s (0x%04X)", desc, (unsigned)slot.last_tls_err); } else { - pos += snprintf(buf + pos, bufsize - pos, ", tls:0x%04X", (unsigned)slot.last_tls_err); + replyAppendf(buf, bufsize, &pos, ", tls:0x%04X", (unsigned)slot.last_tls_err); } } // mbedTLS stack error (shown as negative hex per convention) if (slot.last_tls_stack_err != 0) { - pos += snprintf(buf + pos, bufsize - pos, ", mbedtls:-0x%04X", (unsigned)(-slot.last_tls_stack_err)); + replyAppendf(buf, bufsize, &pos, ", mbedtls:-0x%04X", (unsigned)(-slot.last_tls_stack_err)); } // Socket errno if (slot.last_sock_errno != 0) { - pos += snprintf(buf + pos, bufsize - pos, ", sock:%d", slot.last_sock_errno); + replyAppendf(buf, bufsize, &pos, ", sock:%d", slot.last_sock_errno); } // Time ago unsigned long ago_sec = (millis() - slot.last_error_time) / 1000; if (ago_sec < 60) { - snprintf(buf + pos, bufsize - pos, ", %lus ago", ago_sec); + replyAppendf(buf, bufsize, &pos, ", %lus ago", ago_sec); } else if (ago_sec < 3600) { - snprintf(buf + pos, bufsize - pos, ", %lum ago", ago_sec / 60); + replyAppendf(buf, bufsize, &pos, ", %lum ago", ago_sec / 60); } else { - snprintf(buf + pos, bufsize - pos, ", %luh ago", ago_sec / 3600); + replyAppendf(buf, bufsize, &pos, ", %luh ago", ago_sec / 3600); } } else if (!slot.connected) { - snprintf(buf + pos, bufsize - pos, ", no error info"); + replyAppendf(buf, bufsize, &pos, ", no error info"); } + + // Appended last so it never displaces connection diagnostics. replyAppendf + // clamps rather than overflows, but a clipped type list is worse than no + // list: "…,13,14," parses as a real, different allowlist, and this is the + // one line an operator reads to find out why a slot is quiet. So the exact + // text is only emitted when it fits whole; otherwise fall back to a count, + // which cannot be misread. `get mqttN.filter` always has the exact value. + if (show_filter) { + static const int kFilterLabelLen = (int)sizeof(", filter:") - 1; + const int remaining = pos < (int)bufsize ? (int)bufsize - 1 - pos : 0; + const uint8_t allowed = MQTTPacketFilter::countTypes(filter_mask); + const int compact_len = kFilterLabelLen + (allowed >= 10 ? 5 : 4); // "N/16" + if (kFilterLabelLen + (int)strlen(filter_text) <= remaining) { + replyAppendf(buf, bufsize, &pos, ", filter:%s", filter_text); + } else if (compact_len <= remaining) { + replyAppendf(buf, bufsize, &pos, ", filter:%u/16", (unsigned)allowed); + } + // Neither fits: the slot is reporting so much error detail that the filter + // is the least useful field on the line. Omit it rather than mislead. + } +} + +// Bounded cooperative-stop timeout for end() (see MQTTLifecycle::Coordinator). +// Phase 0 hardware characterization (2026-07-19, Heltec V3 non-PSRAM + V4 PSRAM, +// see STABILITY_TESTABILITY_HANDOFF.md): a real mbedTLS/wss client teardown +// (disconnect + esp_mqtt_client_destroy) takes ~5-6 s per CONNECTED slot, applied +// SEQUENTIALLY in destroySlotClients(). So the safe timeout scales with the +// number of slots being torn down, not a single constant: a flat 8 s tripped the +// dirty/force-kill fallback on a healthy 2-slot non-PSRAM node (~11-12 s) and a +// normal 3-slot PSRAM node (~16 s), which withholds OTA on healthy devices. +// +// The budget below gives generous headroom (~8 s/slot vs the ~5-6 s measured) +// plus a fixed base for WiFi/queue/buffer teardown. Headroom is nearly free: +// end() returns as soon as the task acks (it checks _stop_acked before ticking +// the timeout), so a larger bound does NOT slow a healthy stop — it only length- +// ens the wait before force-killing a genuinely wedged task. The timeout is set +// per stop in end() via computeStopTimeoutMs() based on the enabled-slot count. +static const uint32_t MQTT_STOP_TIMEOUT_BASE_MS = 5000; // fixed teardown overhead +static const uint32_t MQTT_STOP_TIMEOUT_PER_SLOT_MS = 8000; // ~5-6 s measured + headroom + +// Slot-scaled cooperative-stop timeout. `slots` is the number of MQTT slots that +// will be torn down (enabled/connected); clamped to >=1 so a zero-slot bridge +// still budgets for the base teardown. +static inline uint32_t mqttStopTimeoutForSlots(int slots) { + if (slots < 1) slots = 1; + return MQTT_STOP_TIMEOUT_BASE_MS + MQTT_STOP_TIMEOUT_PER_SLOT_MS * (uint32_t)slots; } // --------------------------------------------------------------------------- @@ -500,7 +604,13 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg // so we must pass rules here. _timezone_storage(TimeChangeRule{"UTC", Last, Sun, Mar, 0, 0}, TimeChangeRule{"UTC", Last, Sun, Mar, 0, 0}), _timezone(&_timezone_storage), +#if defined(BOARD_HAS_PSRAM) + _last_raw_data(nullptr), +#endif _last_raw_len(0), _last_snr(0), _last_rssi(0), _last_raw_timestamp(0), +#if defined(BOARD_HAS_PSRAM) + _publish_json_buffer(nullptr), _status_json_buffer(nullptr), +#endif _identity(identity), _cached_has_connected_slots(false), _last_memory_check(0), _skipped_publishes(0), @@ -519,6 +629,11 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg #else , _queue_head(0), _queue_tail(0) #endif + // Cooperative lifecycle: _lifecycle_ops must be constructed before + // _lifecycle (declaration order guarantees this) so the reference binds. + // Seed with the worst-case (max runtime slots) budget; end() recomputes the + // slot-scaled timeout before each stop via setStopTimeoutMs(). + , _lifecycle_ops(this), _lifecycle(_lifecycle_ops, mqttStopTimeoutForSlots(RUNTIME_MQTT_SLOTS)) { // Initialize default values strncpy(_origin, "MeshCore-Repeater", sizeof(_origin) - 1); @@ -551,6 +666,7 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg _slots[i].last_log_time = 0; _slots[i].port = 1883; _slot_reconfigure_pending[i] = false; + _status_publish_pending[i] = false; } // Reset CLI-requested forced NTP sync handshake (bridge object is reused across restarts) @@ -563,6 +679,17 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg _ntp_diag_done = false; _ntp_diag_count = 0; +#if defined(WITH_MQTT_NEIGHBORS) + // Neighbors publish handoff (buffer allocated in begin() after PSRAM probe). + // std::atomic has no value-initializing default ctor pre-C++20, so set them here. + _neighbors_json_buffer = nullptr; + _neighbors_publish_len = 0; + _neighbors_publish_pending.store(false, std::memory_order_relaxed); + _neighbors_last_result.store(NBR_RESULT_NONE, std::memory_order_relaxed); + _neighbors_phase.store(NBR_SCHEDULED, std::memory_order_relaxed); + _neighbors_secs_until_next.store(0, std::memory_order_relaxed); +#endif + // Initialize JWT username _jwt_username[0] = '\0'; @@ -579,34 +706,86 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg #endif #endif - // On PSRAM boards, allocate raw radio buffer and JSON char buffers in PSRAM to preserve - // internal heap. On non-PSRAM boards these are inline arrays in the class object — - // no separate allocation needed. - #if defined(BOARD_HAS_PSRAM) - _last_raw_data = (uint8_t*)psram_malloc(LAST_RAW_DATA_SIZE); - _publish_json_buffer = (char*)psram_malloc(PUBLISH_JSON_BUFFER_SIZE); - _status_json_buffer = (char*)psram_malloc(STATUS_JSON_BUFFER_SIZE); - #if defined(WITH_MQTT_NEIGHBORS) - _neighbors_json_buffer = (char*)psram_malloc(NEIGHBORS_JSON_BUFFER_SIZE); - _neighbors_publish_pending = false; - _neighbors_publish_len = 0; - _neighbors_last_result = NBR_RESULT_NONE; - _neighbors_phase = NBR_SCHEDULED; - _neighbors_secs_until_next = 0; - #endif - #else + // Non-PSRAM boards keep the raw cache inline for the bridge lifetime. + // PSRAM boards allocate their runtime buffers in begin(), after PSRAM has + // been probed/initialized, and release them in end(). + #if !defined(BOARD_HAS_PSRAM) memset(_last_raw_data, 0, sizeof(_last_raw_data)); #endif // JSON document scratch space is now a StaticJsonDocument inline class member — // no heap allocation needed; reused via doc.clear() on every publish. } +void MQTTBridge::allocateRuntimeBuffers() { + #if defined(BOARD_HAS_PSRAM) + // Keep each allocation independent. A nullptr is deliberately retained on + // failure: status/packet publish paths already use stack fallbacks, and the + // next begin() will retry only the missing buffer. + _last_raw_data = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( + _last_raw_data, LAST_RAW_DATA_SIZE, psram_malloc)); + _publish_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( + _publish_json_buffer, PUBLISH_JSON_BUFFER_SIZE, psram_malloc)); + _status_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( + _status_json_buffer, STATUS_JSON_BUFFER_SIZE, psram_malloc)); + MQTT_DEBUG_PRINTLN("Runtime buffers: raw=%s publish=%s status=%s", + _last_raw_data ? "PSRAM" : "unavailable", + _publish_json_buffer ? "PSRAM" : "stack fallback", + _status_json_buffer ? "PSRAM" : "stack fallback"); + #endif + +#if defined(WITH_MQTT_NEIGHBORS) + // Persistent neighbors JSON buffer, heap-allocated on every board: too large to + // keep inline in the bridge object the way the non-PSRAM status/packet buffers + // are. psram_malloc() falls back to internal DRAM, so this works without PSRAM. + // Unlike status/packet there is no stack fallback — a nullptr simply disables + // publishing (requestPublishNeighbors/publishNeighbors both no-op on nullptr). + _neighbors_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( + _neighbors_json_buffer, NEIGHBORS_JSON_BUFFER_SIZE, psram_malloc)); + MQTT_DEBUG_PRINTLN("Neighbors buffer: %s", + _neighbors_json_buffer ? "ready" : "unavailable"); +#endif +} + +void MQTTBridge::releaseRuntimeBuffers() { + #if defined(BOARD_HAS_PSRAM) + _last_raw_data = static_cast(MQTTRuntimeBufferLifecycle::release( + _last_raw_data, psram_free)); + _publish_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::release( + _publish_json_buffer, psram_free)); + _status_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::release( + _status_json_buffer, psram_free)); + #endif + +#if defined(WITH_MQTT_NEIGHBORS) + // Paired with the unconditional allocation in allocateRuntimeBuffers(). + _neighbors_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::release( + _neighbors_json_buffer, psram_free)); + _neighbors_publish_len = 0; + _neighbors_publish_pending.store(false, std::memory_order_release); +#endif + + // Never pair a newly allocated raw buffer with metadata from a prior bridge + // run. This also makes non-PSRAM restarts discard their stale raw cache. + _last_raw_len = 0; + _last_snr = 0; + _last_rssi = 0; + _last_raw_timestamp = 0; +} + // --------------------------------------------------------------------------- // begin() // --------------------------------------------------------------------------- void MQTTBridge::begin() { MQTT_DEBUG_PRINTLN("Initializing MQTT Bridge..."); + // Idempotent start (Phase 5): a second begin() on an already-running bridge + // would re-run allocation and re-create the task, leaking the previous + // queue/task. Guard here instead of relying on caller discipline. + if (_initialized) { + MQTT_DEBUG_PRINTLN("MQTT Bridge already running - begin() ignored"); + return; + } + // PSRAM diagnostic - helps debug memory fragmentation on boards with external RAM #ifdef BOARD_HAS_PSRAM { @@ -639,8 +818,7 @@ void MQTTBridge::begin() { MQTT_DEBUG_PRINTLN("PSRAM: not configured for this board (no BOARD_HAS_PSRAM)"); #endif - // Limit active slots based on available memory (single source of truth in - // getMaxActiveSlots(), which the CLI also uses to warn about inactive slots). + // Limit active slots based on available memory (see getMaxActiveSlots()). _max_active_slots = getMaxActiveSlots(); MQTT_DEBUG_PRINTLN("Max active slots: %d", _max_active_slots); @@ -650,6 +828,10 @@ void MQTTBridge::begin() { return; } + // These are begin()/end()-scoped on PSRAM targets. Allocation happens after + // the PSRAM probe above so a late psramInit() has taken effect. + allocateRuntimeBuffers(); + refreshOriginFromPrefs(); strncpy(_iata, _obs->mqtt_iata, sizeof(_iata) - 1); @@ -662,7 +844,10 @@ void MQTTBridge::begin() { _iata[i] = toupper(_iata[i]); } - // Update enabled flags from preferences + // Initial snapshot of the publish toggles. NOTE: the publish hot paths read + // these live from _obs->mqtt_* (status/packets/raw/rx/tx) so a CLI/web `set` + // takes effect without a bridge restart; these members are kept only for + // startup logging/back-compat and are not the source of truth. _status_enabled = _obs->mqtt_status_enabled; _packets_enabled = _obs->mqtt_packets_enabled; _raw_enabled = _obs->mqtt_raw_enabled; @@ -761,6 +946,7 @@ void MQTTBridge::begin() { psram_free(_packet_queue_storage); #endif _packet_queue_storage = nullptr; + releaseRuntimeBuffers(); return; } @@ -779,6 +965,11 @@ void MQTTBridge::begin() { // causes resets on some boards (e.g. Heltec V4) when the task runs from PSRAM stack. _mqtt_task_stack = nullptr; _mqtt_task_handle = nullptr; + // Clear the cooperative-stop handshake before the new task starts reading it. + // deliverStop() leaves _stop_requested latched true after a stop cycle, so a + // restart must reset it or the fresh task would self-terminate immediately. + _stop_requested = false; + _stop_acked = false; BaseType_t create_result = xTaskCreatePinnedToCore( mqttTask, "MQTTBridge", @@ -799,6 +990,7 @@ void MQTTBridge::begin() { psram_free(_packet_queue_storage); #endif _packet_queue_storage = nullptr; + releaseRuntimeBuffers(); return; } @@ -818,6 +1010,14 @@ void MQTTBridge::begin() { // instead of churning ~40 KB of internal heap per cycle. initSlotClients(); + // Sync the lifecycle Coordinator to Running now that all resources exist and + // the task is created. Driven only on the success path: the failure rollbacks + // above already free what they acquired and leave the bridge Stopped, so we + // must not also fire the state machine's release effect there (double free). + // A fresh start also clears any dirty-stop latch (re-enabling OTA flashing). + _lifecycle.requestStart(); // Stopped -> Starting (startTask() is a no-op here) + _lifecycle.onTaskStarted(); // Starting -> Running + _initialized = true; s_mqtt_bridge_instance = this; MQTT_DEBUG_PRINTLN("MQTT Bridge initialized"); @@ -828,75 +1028,154 @@ void MQTTBridge::begin() { // --------------------------------------------------------------------------- void MQTTBridge::end() { MQTT_DEBUG_PRINTLN("Stopping MQTT Bridge..."); + + // Idempotent stop: nothing to tear down if we never started (or already stopped). + if (!_initialized) { + MQTT_DEBUG_PRINTLN("MQTT Bridge already stopped - end() ignored"); + return; + } + + // Stop new diagnostic reads through the singleton before teardown begins. s_mqtt_bridge_instance = nullptr; - #ifdef ESP_PLATFORM - // Delete FreeRTOS task first (it will clean up WiFi/MQTT connections) - if (_mqtt_task_handle != nullptr) { - vTaskDelete(_mqtt_task_handle); - _mqtt_task_handle = nullptr; - } - // Free PSRAM task stack - psram_free(_mqtt_task_stack); - _mqtt_task_stack = nullptr; - - // Clean up queued packets from FreeRTOS queue - // Packets are value-copied in the queue, so no external pointers to clean up. - if (_packet_queue_handle != nullptr) { - QueuedPacket queued; - while (xQueueReceive(_packet_queue_handle, &queued, 0) == pdTRUE) { - _queue_count--; - } - vQueueDelete(_packet_queue_handle); - _packet_queue_handle = nullptr; - } - #if defined(BOARD_HAS_PSRAM) - psram_free(_packet_queue_storage); - #endif - _packet_queue_storage = nullptr; - - #else - // Clean up queued packet references - // Packets are value-copied in the queue, so no external pointers to clean up. - for (int i = 0; i < _queue_count; i++) { - int index = (_queue_head + i) % MAX_QUEUE_SIZE; - memset(&_packet_queue[index], 0, sizeof(QueuedPacket)); - } - - _queue_count = 0; - _queue_head = 0; - _queue_tail = 0; - memset(_packet_queue, 0, sizeof(_packet_queue)); - #endif - - // Disconnect and delete persistent MQTT clients. teardownSlot() intentionally - // only disconnects; destruction happens here so the mbedTLS contexts survive - // the reconfigure/reconnect hot path. + // Size the stop timeout to the work about to happen: each enabled slot's + // mbedTLS/wss client takes ~5-6 s to disconnect + destroy, sequentially (Phase + // 0 hardware characterization). A flat bound force-killed healthy multi-slot + // nodes and withheld OTA; the slot-scaled budget lets a normal teardown ack + // cleanly. Count enabled slots (the ones that connect); a disabled slot's + // client destroys quickly. Must run BEFORE requestStop() arms the window. + int stop_slots = 0; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - teardownSlot(i); + if (_slots[i].enabled) stop_slots++; } - destroySlotClients(); + _lifecycle.setStopTimeoutMs(mqttStopTimeoutForSlots(stop_slots)); + MQTT_DEBUG_PRINTLN("MQTT stop: %d enabled slot(s), timeout %lu ms", + stop_slots, (unsigned long)_lifecycle.stopTimeoutMs()); - // Timezone is inline class storage (_timezone_storage) since Phase 3 of - // the MQTT memory-defrag work — nothing to delete. _timezone always - // points at &_timezone_storage and stays valid for the bridge lifetime. + // Cooperative shutdown (Phase 5). Request the stop, then let the lifecycle + // Coordinator drive it. On ESP32 the MQTT task (Core 0) tears down its own + // clients where the mbedTLS contexts live and acknowledges via _stop_acked; + // the queue/buffer release happens inside LifecycleOps::releaseResources() + // once the Coordinator reaches Stopped (clean ack OR the reviewed timeout + // fallback). This replaces the former blind vTaskDelete that could kill the + // task mid-mbedTLS and then free client buffers on a corrupted heap. + _lifecycle.requestStop(); // Running -> StopRequested; deliverStop() sets _stop_requested - // Free PSRAM-backed buffers (non-PSRAM builds use inline class arrays — no free needed) - #if defined(BOARD_HAS_PSRAM) - psram_free(_last_raw_data); _last_raw_data = nullptr; - psram_free(_publish_json_buffer); _publish_json_buffer = nullptr; - psram_free(_status_json_buffer); _status_json_buffer = nullptr; - #if defined(WITH_MQTT_NEIGHBORS) - psram_free(_neighbors_json_buffer); _neighbors_json_buffer = nullptr; - _neighbors_publish_pending = false; - _neighbors_publish_len = 0; - #endif - #endif - // JSON documents are now StaticJsonDocument inline members — no heap allocation to free. +#ifdef ESP_PLATFORM + // Wait (bounded) for the task to acknowledge. tick() synthesizes the timeout + // fallback if the task never acks. Checking the ack first each iteration means + // a stop that completes right as the timeout expires is still treated as clean. + while (_lifecycle.isStopInProgress()) { + if (_stop_acked) { + _lifecycle.onTaskStopped(); // StopRequested -> Stopped (clean): releaseResources() + break; + } + _lifecycle.tick(); // may fire StopTimedOut -> Stopped (dirty): releaseResources() + if (!_lifecycle.isStopInProgress()) break; + vTaskDelay(pdMS_TO_TICKS(20)); + } +#else + // Non-ESP32: the bridge runs cooperatively in loop(); there is no separate + // task to signal. Drive straight to a clean Stopped and let releaseResources() + // perform the (unchanged) synchronous teardown. + _stop_acked = true; + _lifecycle.onTaskStopped(); +#endif + // Timezone is inline class storage (_timezone_storage) — nothing to delete. + // JSON documents are StaticJsonDocument inline members — no heap to free. _initialized = false; _slots_setup_done = false; // Reset so deferred setup runs again on next begin() - MQTT_DEBUG_PRINTLN("MQTT Bridge stopped"); + MQTT_DEBUG_PRINTLN("MQTT Bridge stopped (%s)", + _lifecycle.stopTimedOut() ? "forced/timeout - OTA blocked" : "clean"); +} + +// --------------------------------------------------------------------------- +// LifecycleOps — binds MQTTLifecycle::Ops (the pure, host-tested spec) to the +// FreeRTOS / PsychicMqttClient runtime. Every method runs on the loop task +// (Core 1): the Coordinator that calls them is driven only from begin()/end(). +// --------------------------------------------------------------------------- +uint32_t MQTTBridge::LifecycleOps::nowMs() { + return (uint32_t)millis(); +} + +void MQTTBridge::LifecycleOps::startTask() { + // No-op: begin() owns task/queue/buffer creation and its rollback paths. The + // Coordinator is synced to Running there via requestStart()/onTaskStarted(). +} + +void MQTTBridge::LifecycleOps::deliverStop() { + // Clear any stale ack before raising the request (same ordering as the NTP + // handshake: clear the done-flag, then set the request). The MQTT task polls + // _stop_requested at the top of mqttTaskLoop(). + _b->_stop_acked = false; + _b->_stop_requested = true; +} + +void MQTTBridge::LifecycleOps::releaseResources() { + MQTTBridge* b = _b; +#ifdef ESP_PLATFORM + // stopTimedOut() is set before this effect fires (Coordinator::dispatch), so + // it reliably distinguishes a clean ack from the timeout fallback. + const bool dirty = b->_lifecycle.stopTimedOut(); + if (dirty && !b->_stop_acked) { + // Reviewed fallback: the task never acknowledged (likely wedged in mbedTLS). + // Force-kill it and tear down clients here on Core 1 — the pre-cooperative + // behavior — accepting the heap risk. The dirty latch keeps OTA flashing + // blocked (canFlashAfterStop() == false) so firmware is never written after + // this path. + if (b->_mqtt_task_handle != nullptr) { + vTaskDelete(b->_mqtt_task_handle); + } + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) b->teardownSlot(i); + b->destroySlotClients(); + } + // Clean path (or a task that acked right at the deadline): the MQTT task + // already disconnected/deleted its clients on Core 0 and self-terminated, so + // we must NOT touch slots here (that would be a cross-core double-delete). + // Just drop our handle reference; FreeRTOS reclaims the self-deleted task's + // dynamically-allocated stack/TCB in the idle task. + b->_mqtt_task_handle = nullptr; + + // Free the PSRAM task stack (nullptr for dynamic tasks — no-op). + psram_free(b->_mqtt_task_stack); + b->_mqtt_task_stack = nullptr; + + // Drain and delete the FreeRTOS packet queue (value-copied packets, no + // external pointers to clean up). Safe on Core 1: not a TLS resource. + if (b->_packet_queue_handle != nullptr) { + QueuedPacket queued; + while (xQueueReceive(b->_packet_queue_handle, &queued, 0) == pdTRUE) { + b->_queue_count--; + } + vQueueDelete(b->_packet_queue_handle); + b->_packet_queue_handle = nullptr; + } + #if defined(BOARD_HAS_PSRAM) + psram_free(b->_packet_queue_storage); + #endif + b->_packet_queue_storage = nullptr; +#else + // Non-ESP32 circular buffer + synchronous client teardown (unchanged behavior). + for (int i = 0; i < b->_queue_count; i++) { + int index = (b->_queue_head + i) % MAX_QUEUE_SIZE; + memset(&b->_packet_queue[index], 0, sizeof(QueuedPacket)); + } + b->_queue_count = 0; + b->_queue_head = 0; + b->_queue_tail = 0; + memset(b->_packet_queue, 0, sizeof(b->_packet_queue)); + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) b->teardownSlot(i); + b->destroySlotClients(); +#endif + + b->releaseRuntimeBuffers(); +} + +void MQTTBridge::LifecycleOps::onStopComplete(bool clean) { + MQTT_DEBUG_PRINTLN("MQTT stop %s", clean + ? "acknowledged (clean)" + : "TIMED OUT (dirty; OTA flashing withheld)"); } // --------------------------------------------------------------------------- @@ -983,6 +1262,22 @@ void MQTTBridge::mqttTaskLoop() { static unsigned long last_agent_log = 0; #endif while (true) { + // Cooperative stop (Phase 5). end() on the loop task (Core 1) set this flag. + // Tear down our own clients HERE on Core 0 — where the mbedTLS/transport + // state lives — instead of letting Core 1 free them after a blind + // vTaskDelete. Acknowledge LAST so end() only frees the queue/buffers once + // this teardown has completed, then self-terminate via the mqttTask() + // trampoline (vTaskDelete(nullptr)). + if (_stop_requested) { + MQTT_DEBUG_PRINTLN("MQTT task: cooperative stop - tearing down clients on Core 0"); + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + teardownSlot(i); + } + destroySlotClients(); + _stop_acked = true; // release semantics: set only after teardown is done + return; + } + #ifdef MQTT_MEMORY_DEBUG // #region agent log unsigned long now_loop = millis(); @@ -1082,7 +1377,7 @@ void MQTTBridge::mqttTaskLoop() { } char reason[80]; if (!isSlotReady(i, reason, sizeof(reason))) { - MQTT_DEBUG_PRINTLN("MQTT%d not ready — run '%s' to connect", i + 1, reason); + MQTT_DEBUG_PRINTLN("MQTT%d not ready - run '%s' to connect", i + 1, reason); continue; } setupSlot(i); @@ -1105,6 +1400,19 @@ void MQTTBridge::mqttTaskLoop() { } } + // Publish on-connect status for slots whose onConnect callback fired since + // the last loop. Raised on the esp-mqtt event task, consumed here on the + // bridge task so the shared status doc/buffer/origin are only ever touched + // from Core 0 (see the onConnect handler / A2). Clear before publishing so a + // reconnect during the publish re-arms for the next loop rather than being + // lost; publishStatusToSlot() re-checks slot.connected and no-ops if dropped. + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_status_publish_pending[i]) { + _status_publish_pending[i] = false; + publishStatusToSlot(i); + } + } + // Maintain slot connections (token renewal, reconnect with backoff) maintainSlotConnections(); @@ -1112,10 +1420,15 @@ void MQTTBridge::mqttTaskLoop() { processPacketQueue(); #if defined(WITH_MQTT_NEIGHBORS) + // Consume a pending neighbors snapshot handed over by the mesh (Core 1). + // The pending flag stays raised across the whole publish so a second + // request is rejected until this one completes (see requestPublishNeighbors). if (_neighbors_publish_pending.load(std::memory_order_acquire)) { bool ok = publishNeighbors(); _neighbors_last_result.store(ok ? NBR_RESULT_OK : NBR_RESULT_FAIL, std::memory_order_relaxed); + // MQTT_DEBUG_PRINTLN concatenates its format as a string literal, so the + // argument must be a literal, not a ternary expression. if (ok) { MQTT_DEBUG_PRINTLN("Neighbors published"); } else { @@ -1154,8 +1467,10 @@ void MQTTBridge::mqttTaskLoop() { refreshNTP(); } - // Publish status updates (handle millis() overflow correctly) - if (_status_enabled) { + // Publish status updates (handle millis() overflow correctly). + // Read the toggle live from prefs (like mqtt.packets/rx/tx below) so a + // CLI/web `set mqtt.status` change applies without a bridge restart. + if (_obs->mqtt_status_enabled) { bool has_destinations = _cached_has_connected_slots; // Early exit if no destinations - skip all the expensive logic below @@ -1267,8 +1582,14 @@ void MQTTBridge::initSlotClients() { _slots[index].last_sock_errno = 0; _slots[index].last_error_time = 0; _slots[index].current_outage_started_ms = 0; // clear current-outage timer for AlertReporter - updateCachedConnectionStatus(); - publishStatusToSlot(index); + updateCachedConnectionStatus(); // bool store — safe from this (esp-mqtt) task + // This callback runs on the client's esp-mqtt event task, not the bridge + // task. Do NOT build/publish status here: publishStatusToSlot() writes the + // shared _status_json_doc/_status_json_buffer/_origin that the periodic + // publishStatus() uses on the bridge task, and two slots' callbacks could + // race each other over them. Marshal the publish onto the bridge task via a + // per-slot flag (see mqttTaskLoop consumer / A2). + _status_publish_pending[index] = true; }); slot.client->onDisconnect([this, index](bool sessionPresent) { MQTT_DEBUG_PRINTLN("MQTT%d disconnected", index + 1); @@ -1336,7 +1657,7 @@ void MQTTBridge::setupSlot(int index) { // Persistent client is expected to have been allocated by initSlotClients(). // If it hasn't, we can't proceed — bail loudly rather than silently leaking. if (slot.client == nullptr) { - MQTT_DEBUG_PRINTLN("MQTT%d: setupSlot before initSlotClients() — skipping", index + 1); + MQTT_DEBUG_PRINTLN("MQTT%d: setupSlot before initSlotClients() - skipping", index + 1); return; } @@ -1566,8 +1887,8 @@ void MQTTBridge::maintainSlotConnections() { // JWT tokens require valid timestamps unsigned long clock_sec = current_time; - bool clock_looks_set = (clock_sec >= 1735689600); // 2025-01-01 00:00:00 UTC - bool can_do_jwt = _ntp_synced || clock_looks_set; + bool can_do_jwt = MQTTConnectionPolicy::jwtClockAvailable( + _ntp_synced, static_cast(clock_sec)); // Count connected slots to inform reconnect decisions int connected_count = 0; @@ -1580,8 +1901,8 @@ void MQTTBridge::maintainSlotConnections() { // Time-based guard: block reconnects if any slot reconnected within the last 15 s, // ensuring the previous TLS handshake (and its Core-0-expensive completion events) // finish before the next slot begins its own handshake. - const unsigned long RECONNECT_GUARD_MS = 15000UL; - bool reconnect_attempted_this_cycle = (now_millis - _last_slot_reconnect_ms < RECONNECT_GUARD_MS); + bool reconnect_attempted_this_cycle = MQTTConnectionPolicy::reconnectGuardActive( + static_cast(now_millis), static_cast(_last_slot_reconnect_ms)); // Only allow one full teardown+setup per cycle to limit heap fragmentation // when multiple slots fail simultaneously bool teardown_attempted_this_cycle = false; @@ -1610,11 +1931,10 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns // (worst case the 300 s rung / 30-minute breaker probes) instead of // hammering full TLS handshakes at the 10 s rung — see the onConnect // handler in initSlotClients() for why this doesn't happen on CONNACK. - static const unsigned long BACKOFF_STABLE_RESET_MS = 120000UL; if (slot.connected && (slot.reconnect_backoff != 0 || slot.max_backoff_failures != 0) && - slot.connected_at_ms != 0 && - (now_millis - slot.connected_at_ms) >= BACKOFF_STABLE_RESET_MS) { + MQTTConnectionPolicy::stableConnection(static_cast(now_millis), + static_cast(slot.connected_at_ms))) { MQTT_DEBUG_PRINTLN("MQTT%d stable for %lus - clearing reconnect backoff (was level %d)", index + 1, (now_millis - slot.connected_at_ms) / 1000UL, slot.reconnect_backoff); slot.reconnect_backoff = 0; @@ -1626,23 +1946,19 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns (!slot.preset && slot.audience[0] != '\0'); if (slot_uses_jwt) { // Renew (and below, reconnect) this many seconds before the token's exp - // claim. Scaled to the slot's token lifetime — see tokenRenewalBufferSecs + // claim. Scaled to the slot's token lifetime — see renewalBufferSecs() // for why a flat 60 s lost the renewal race against brokers that enforce // exp on live sessions (waev's 55-minute tokens). - const unsigned long renewal_buffer = tokenRenewalBufferSecs(slotTokenLifetime(index)); - bool token_needs_renewal = false; - if (!time_synced) { - token_needs_renewal = (slot.token_expires_at == 0); - } else { - token_needs_renewal = (slot.token_expires_at == 0) || - !(slot.token_expires_at >= 1000000000) || - (current_time >= slot.token_expires_at) || - (current_time >= (slot.token_expires_at - renewal_buffer)); - } + const unsigned long renewal_buffer = MQTTConnectionPolicy::renewalBufferSecs( + static_cast(slotTokenLifetime(index))); + bool token_needs_renewal = MQTTConnectionPolicy::tokenNeedsRenewal( + time_synced, static_cast(current_time), + static_cast(slot.token_expires_at), + static_cast(renewal_buffer)); // Throttle renewal attempts to once per minute - const unsigned long RENEWAL_THROTTLE_MS = 60000; - bool can_attempt_renewal = (now_millis - slot.last_token_renewal) >= RENEWAL_THROTTLE_MS; + bool can_attempt_renewal = MQTTConnectionPolicy::renewalAttemptAllowed( + static_cast(now_millis), static_cast(slot.last_token_renewal)); if (token_needs_renewal && can_attempt_renewal) { slot.last_token_renewal = now_millis; @@ -1699,11 +2015,10 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns // Periodic probe for circuit-breaker-tripped slots (recovery from transient outages) // Attempts a single reconnect every 30 minutes to see if the server has come back if (slot.circuit_breaker_tripped && !reconnect_attempted) { - static const unsigned long CIRCUIT_BREAKER_PROBE_INTERVAL_MS = 1800000UL; // 30 minutes - unsigned long probe_elapsed = (now_millis >= slot.last_reconnect_attempt) ? - (now_millis - slot.last_reconnect_attempt) : - (ULONG_MAX - slot.last_reconnect_attempt + now_millis + 1); - if (probe_elapsed >= CIRCUIT_BREAKER_PROBE_INTERVAL_MS) { + unsigned long probe_elapsed = MQTTConnectionPolicy::elapsedMs( + static_cast(now_millis), static_cast(slot.last_reconnect_attempt)); + if (MQTTConnectionPolicy::circuitBreakerProbeDue( + static_cast(now_millis), static_cast(slot.last_reconnect_attempt))) { slot.last_reconnect_attempt = now_millis; reconnect_attempted = true; _last_slot_reconnect_ms = now_millis; @@ -1732,24 +2047,18 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns // Reconnect with exponential backoff (for disconnected slots that already have valid config) // Only one reconnect per maintenance cycle to prevent TLS handshakes from blocking other slots if (!slot.connected && slot.initial_connect_done && !slot.circuit_breaker_tripped && !reconnect_attempted) { - static const unsigned long SLOT_BACKOFF_MS[] = { 10000, 30000, 60000, 120000, 300000 }; - static const uint8_t MAX_FAILURES_AT_MAX_BACKOFF = 3; // ~15 min at max backoff before giving up - unsigned long reconnect_elapsed = (now_millis >= slot.last_reconnect_attempt) ? - (now_millis - slot.last_reconnect_attempt) : - (ULONG_MAX - slot.last_reconnect_attempt + now_millis + 1); - unsigned int idx = (slot.reconnect_backoff < 5) ? slot.reconnect_backoff : 4; - unsigned long delay_ms = SLOT_BACKOFF_MS[idx] + (index * 3000UL); // stagger by slot index - if (reconnect_elapsed >= delay_ms) { + if (MQTTConnectionPolicy::reconnectDue( + static_cast(now_millis), static_cast(slot.last_reconnect_attempt), + slot.reconnect_backoff, static_cast(index))) { slot.last_reconnect_attempt = now_millis; - if (slot.reconnect_backoff < 5) { - slot.reconnect_backoff++; - } else { - slot.max_backoff_failures++; - if (slot.max_backoff_failures >= MAX_FAILURES_AT_MAX_BACKOFF) { - slot.circuit_breaker_tripped = true; - MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker tripped after %d failures at max backoff - stopping reconnect attempts. Reconfigure slot to retry.", index + 1, slot.max_backoff_failures); - return; - } + MQTTConnectionPolicy::BackoffAdvance advance = MQTTConnectionPolicy::advanceBackoff( + slot.reconnect_backoff, slot.max_backoff_failures); + slot.reconnect_backoff = advance.reconnect_backoff; + slot.max_backoff_failures = advance.max_backoff_failures; + slot.circuit_breaker_tripped = advance.circuit_breaker_tripped; + if (!advance.should_reconnect) { + MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker tripped after %d failures at max backoff - stopping reconnect attempts. Reconfigure slot to retry.", index + 1, slot.max_backoff_failures); + return; } MQTT_DEBUG_PRINTLN("MQTT%d reconnecting (backoff level %d, failures at max: %d, int_heap=%d)", index + 1, slot.reconnect_backoff, slot.max_backoff_failures, (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); @@ -1786,14 +2095,12 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns // renewal scheduling in maintainSlotConnection() can be derived from it. unsigned long MQTTBridge::slotTokenLifetime(int index) const { const MQTTSlot& slot = _slots[index]; - unsigned long base_lifetime = 86400; // default 24h + unsigned long base_lifetime = MQTTConnectionPolicy::kDefaultJwtLifetimeSecs; if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT && slot.preset->token_lifetime > 0) { base_lifetime = slot.preset->token_lifetime; } - // Stagger token expiry per slot to avoid simultaneous renewal/reconnect. - // Use 5% of lifetime per slot, capped at 300s, so short-lived tokens aren't over-reduced. - unsigned long stagger = index * min((unsigned long)300, base_lifetime / 20); - return base_lifetime - stagger; + return MQTTConnectionPolicy::jwtLifetimeSecs( + static_cast(base_lifetime), static_cast(index)); } // How early (seconds before the token's exp claim) to renew the token AND @@ -1809,13 +2116,6 @@ unsigned long MQTTBridge::slotTokenLifetime(int index) const { // lifetime/10 with a 60 s floor and 300 s cap: 24 h tokens renew 5 min early // (unchanged in practice), waev renews ~5 min early with ~5 throttled retry // windows, and degenerate short lifetimes still renew inside their validity. -unsigned long MQTTBridge::tokenRenewalBufferSecs(unsigned long lifetime_secs) { - unsigned long buffer = lifetime_secs / 10; - if (buffer < 60) buffer = 60; - if (buffer > 300) buffer = 300; - return buffer; -} - bool MQTTBridge::createSlotAuthToken(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; MQTTSlot& slot = _slots[index]; @@ -1930,78 +2230,30 @@ bool MQTTBridge::publishToAllSlots(const char* topic, const char* payload, bool // Topic building - resolves the correct topic for a given slot and message type. // Presets use hardcoded topic logic; custom slots support user-defined templates. // --------------------------------------------------------------------------- -const char* MQTTBridge::messageTypeSuffix(MQTTMessageType type) { - switch (type) { - case MSG_STATUS: return "status"; - case MSG_PACKETS: return "packets"; - case MSG_RAW: return "raw"; - default: return "neighbors"; - } -} - bool MQTTBridge::substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size) { - const char* type_str = messageTypeSuffix(type); - const char* token = _obs->mqtt_slot_token[slot_index]; - - size_t out = 0; - const char* p = tmpl; - while (*p && out < buf_size - 1) { - if (*p == '{') { - if (strncmp(p, "{iata}", 6) == 0) { - size_t len = strlen(_iata); - if (out + len >= buf_size) return false; - memcpy(buf + out, _iata, len); - out += len; - p += 6; - } else if (strncmp(p, "{device}", 8) == 0) { - size_t len = strlen(_device_id); - if (out + len >= buf_size) return false; - memcpy(buf + out, _device_id, len); - out += len; - p += 8; - } else if (strncmp(p, "{token}", 7) == 0) { - size_t len = strlen(token); - if (out + len >= buf_size) return false; - memcpy(buf + out, token, len); - out += len; - p += 7; - } else if (strncmp(p, "{type}", 6) == 0) { - size_t len = strlen(type_str); - if (out + len >= buf_size) return false; - memcpy(buf + out, type_str, len); - out += len; - p += 6; - } else { - buf[out++] = *p++; - } - } else { - buf[out++] = *p++; - } - } - buf[out] = '\0'; - return out > 0; + return mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, (int)type, tmpl, + _iata, _device_id, _obs->mqtt_slot_token[slot_index], + buf, buf_size); } bool MQTTBridge::buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size) { - if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; + static_assert( + static_cast(MSG_STATUS) == MQTT_PUBLICATION_STATUS && + static_cast(MSG_PACKETS) == MQTT_PUBLICATION_PACKETS && + static_cast(MSG_RAW) == MQTT_PUBLICATION_RAW && + static_cast(MSG_NEIGHBORS) == MQTT_PUBLICATION_NEIGHBORS, + "topic router enum drift"); + + if (!mqttTopicSlotIndexValid(index, RUNTIME_MQTT_SLOTS)) return false; const MQTTSlot& slot = _slots[index]; // Preset slots: use hardcoded topic logic if (slot.preset) { - if (slot.preset->topic_style == MQTT_TOPIC_MESHRANK) { - // MeshRank uses a per-slot token in its topic path. Publish every message - // type using the same suffixes as the standard MeshCore topic layout. - const char* token = _obs->mqtt_slot_token[index]; - if (!token || token[0] == '\0') return false; - snprintf(topic_buf, buf_size, "meshrank/uplink/%s/%s/%s", token, _device_id, - messageTypeSuffix(type)); - return true; - } - // MQTT_TOPIC_MESHCORE (default for all other presets) - if (!isIATAValid()) return false; - snprintf(topic_buf, buf_size, "meshcore/%s/%s/%s", _iata, _device_id, - messageTypeSuffix(type)); - return true; + MQTTTopicRouteStyle style = (slot.preset->topic_style == MQTT_TOPIC_MESHRANK) + ? MQTT_ROUTE_MESHRANK : MQTT_ROUTE_MESHCORE; + return mqttBuildPublicationTopic(style, (int)type, nullptr, + _iata, _device_id, _obs->mqtt_slot_token[index], + topic_buf, buf_size); } // Custom slots: use topic template if set, otherwise default meshcore format @@ -2009,10 +2261,9 @@ bool MQTTBridge::buildTopicForSlot(int index, MQTTMessageType type, char* topic_ return substituteTopicTemplate(_obs->mqtt_slot_topic[index], type, index, topic_buf, buf_size); } // Default: meshcore format - if (!isIATAValid()) return false; - snprintf(topic_buf, buf_size, "meshcore/%s/%s/%s", _iata, _device_id, - messageTypeSuffix(type)); - return true; + return mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, (int)type, nullptr, + _iata, _device_id, _obs->mqtt_slot_token[index], + topic_buf, buf_size); } void MQTTBridge::publishStatusToSlot(int index) { @@ -2029,7 +2280,10 @@ void MQTTBridge::publishStatusToSlot(int index) { } // Reuse pre-allocated buffer to avoid heap alloc/free churn under memory pressure. - // _status_json_buffer and _last_raw_data are both Core 0-owned; no mutex needed. + // _status_json_doc/_status_json_buffer/_origin are shared with publishStatus(); + // both callers run only on the bridge task (this function is reached solely via + // the _status_publish_pending consumer in mqttTaskLoop, never from the onConnect + // callback thread — see A2), so the accesses are serialized and need no mutex. #if defined(BOARD_HAS_PSRAM) char fallback_status_buffer[STATUS_JSON_BUFFER_SIZE]; char* json_buffer = (_status_json_buffer != nullptr) ? _status_json_buffer : fallback_status_buffer; @@ -2095,10 +2349,11 @@ void MQTTBridge::publishStatusToSlot(int index) { ); if (len > 0) { - // Honor the preset's retain policy, as the packets/neighbors paths do — - // brokers that set allow_retain=false (meshrank, waev) reject retained - // publishes. Custom slots keep the long-standing retained status behavior. - bool use_retain = slot.preset ? slot.preset->allow_retain : true; + // Honor the preset's retain policy, matching publishStatus() — brokers that + // set allow_retain=false (e.g. waev) reject retained publishes, so this + // on-connect status must not force retain=true. Custom slots default to + // non-retained here too, keeping both status paths consistent. + bool use_retain = slot.preset ? slot.preset->allow_retain : false; int result = slot.client->publish(status_topic, 1, use_retain, json_buffer, strlen(json_buffer)); if (result <= 0) { MQTT_DEBUG_PRINTLN("MQTT%d status publish failed", index + 1); @@ -2156,10 +2411,24 @@ void MQTTBridge::applySlotPreset(int slot_index, const char* preset_name) { } if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) { - slot.enabled = true; slot.preset = nullptr; - // Custom broker settings should already be set via setSlotCustomBroker - if (_initialized && customEndpointComplete(slot.host, slot.port)) { + // Re-sync every custom field from prefs (same copy begin() does at startup) + // so a CLI/web edit to the host, port, credentials, or JWT audience is + // actually picked up on reconfigure. Previously this branch reused the + // stale slot fields, so e.g. changing mqttN.server or mqttN.username had no + // effect on the live connection. Token and topic are read live from _obs in + // setupSlot()/buildTopicForSlot(), so they don't need copying here. + strncpy(slot.host, _obs->mqtt_slot_host[slot_index], sizeof(slot.host) - 1); + slot.host[sizeof(slot.host) - 1] = '\0'; + slot.port = _obs->mqtt_slot_port[slot_index]; + strncpy(slot.username, _obs->mqtt_slot_username[slot_index], sizeof(slot.username) - 1); + slot.username[sizeof(slot.username) - 1] = '\0'; + strncpy(slot.password, _obs->mqtt_slot_password[slot_index], sizeof(slot.password) - 1); + slot.password[sizeof(slot.password) - 1] = '\0'; + strncpy(slot.audience, _obs->mqtt_slot_audience[slot_index], sizeof(slot.audience) - 1); + slot.audience[sizeof(slot.audience) - 1] = '\0'; + slot.enabled = (slot.host[0] != '\0'); + if (_initialized && slot.enabled && customEndpointComplete(slot.host, slot.port)) { setupSlot(slot_index); } return; @@ -2178,7 +2447,7 @@ void MQTTBridge::applySlotPreset(int slot_index, const char* preset_name) { if (_initialized) { char reason[80]; if (!isSlotReady(slot_index, reason, sizeof(reason))) { - MQTT_DEBUG_PRINTLN("MQTT%d (%s) not ready — run '%s' to connect", slot_index + 1, preset_name, reason); + MQTT_DEBUG_PRINTLN("MQTT%d (%s) not ready - run '%s' to connect", slot_index + 1, preset_name, reason); return; } setupSlot(slot_index); @@ -2209,7 +2478,7 @@ void MQTTBridge::checkConfigurationMismatch() { if (_obs->mqtt_packets_enabled && !_obs->mqtt_rx_enabled && _obs->mqtt_tx_enabled == 0) { unsigned long now = millis(); if (_last_config_warning == 0 || (now - _last_config_warning > CONFIG_WARNING_INTERVAL)) { - MQTT_DEBUG_PRINTLN("MQTT: Both mqtt.rx and mqtt.tx are off — no packets will be published. Run 'set mqtt.rx on' or 'set mqtt.tx on' to fix."); + MQTT_DEBUG_PRINTLN("MQTT: Both mqtt.rx and mqtt.tx are off - no packets will be published. Run 'set mqtt.rx on' or 'set mqtt.tx on' to fix."); _last_config_warning = now; } } else { @@ -2275,18 +2544,17 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { } } } else if (_wifi_disconnected_time > 0) { - unsigned long disconnected_duration = now - _wifi_disconnected_time; - static const unsigned long WIFI_BACKOFF_MS[] = { 15000, 30000, 60000, 120000, 300000 }; - unsigned int idx = (_wifi_reconnect_backoff_attempt < 5) ? _wifi_reconnect_backoff_attempt : 4; - unsigned long delay_ms = WIFI_BACKOFF_MS[idx]; - unsigned long elapsed_since_attempt = (now >= _last_wifi_reconnect_attempt) - ? (now - _last_wifi_reconnect_attempt) - : (ULONG_MAX - _last_wifi_reconnect_attempt + now + 1); - if (disconnected_duration >= delay_ms && elapsed_since_attempt >= delay_ms) { + // Backoff ladder + wrap-safe timing live in MQTTConnectionPolicy (Phase 6), + // exercised by host tests. Behavior is unchanged: both the link-down + // duration and the since-last-attempt interval must clear the current rung + // (elapsedMs is the wrap-safe form of the old ULONG_MAX branch). + if (MQTTConnectionPolicy::wifiReconnectDue( + (uint32_t)now, (uint32_t)_wifi_disconnected_time, + (uint32_t)_last_wifi_reconnect_attempt, + _wifi_reconnect_backoff_attempt)) { _last_wifi_reconnect_attempt = now; - if (_wifi_reconnect_backoff_attempt < 5) { - _wifi_reconnect_backoff_attempt++; - } + _wifi_reconnect_backoff_attempt = + MQTTConnectionPolicy::nextWifiBackoffAttempt(_wifi_reconnect_backoff_attempt); WiFi.disconnect(); WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); } @@ -2406,8 +2674,10 @@ void MQTTBridge::loop() { refreshNTP(); } - // Publish status updates (handle millis() overflow correctly) - if (_status_enabled) { + // Publish status updates (handle millis() overflow correctly). + // Read the toggle live from prefs so a CLI/web `set mqtt.status` change + // applies without a bridge restart. + if (_obs->mqtt_status_enabled) { bool has_destinations = _cached_has_connected_slots; if (has_destinations) { @@ -2473,15 +2743,16 @@ void MQTTBridge::loop() { void MQTTBridge::onPacketReceived(mesh::Packet *packet) { if (!_initialized || !_obs->mqtt_packets_enabled || !_obs->mqtt_rx_enabled) return; - // Check if we have any enabled slots to send to - bool has_valid_slots = false; - for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].enabled && _slots[i].client) { - has_valid_slots = true; - break; - } + // Drop before the queue copy when no configured slot allows this payload + // type. A QueuedPacket carries the packet plus up to 256 bytes of raw radio + // data and crosses to Core 0, so filtering here (not at publish time) is + // what actually saves the queue slot and the memcpy. This also subsumes the + // older "is any slot configured at all?" pre-check. + bool filtered = false; + if (!shouldQueuePacketType(packet->getPayloadType(), filtered)) { + if (filtered) _filtered_packets++; + return; } - if (!has_valid_slots) return; // Queue packet for transmission queuePacket(packet, false); @@ -2499,6 +2770,13 @@ void MQTTBridge::sendPacket(mesh::Packet *packet) { if (!_identity || memcmp(_identity->pub_key, packet->payload, PUB_KEY_SIZE) != 0) return; } + // Same pre-queue filter gate as the RX path. + bool filtered = false; + if (!shouldQueuePacketType(packet->getPayloadType(), filtered)) { + if (filtered) _filtered_packets++; + return; + } + // Queue packet for transmission queuePacket(packet, true); } @@ -2531,7 +2809,9 @@ void MQTTBridge::processPacketQueue() { // Flush stale packets after extended disconnect if (_queue_disconnected_since == 0) { _queue_disconnected_since = now; - } else if ((now - _queue_disconnected_since) >= QUEUE_STALE_MS) { + } else if (MQTTPacketQueuePolicy::shouldFlushDisconnected( + static_cast(now), + static_cast(_queue_disconnected_since))) { QueuedPacket discard; while (xQueueReceive(_packet_queue_handle, &discard, 0) == pdTRUE) {} _queue_count = 0; @@ -2547,19 +2827,18 @@ void MQTTBridge::processPacketQueue() { // Adaptive drain: burst-process when queue has backlog, gentle otherwise int processed = 0; - int max_per_loop = (_queue_count > 5) ? 5 : 1; + const MQTTPacketQueuePolicy::DrainBudget drain_budget = + MQTTPacketQueuePolicy::drainBudget(static_cast(_queue_count)); unsigned long loop_start_time = millis(); - const unsigned long MAX_PROCESSING_TIME_MS = (_queue_count > 5) ? 100 : 30; - static const uint8_t MAX_QOS0_RETRY_ATTEMPTS = 3; - static const unsigned long RETRY_DELAY_BASE_MS = 300UL; - static const unsigned long RETRY_DELAY_JITTER_MS = 200UL; #ifdef MQTT_DIAG_VERBOSE static unsigned long last_retry_schedule_log = 0; #endif - while (processed < max_per_loop) { - unsigned long elapsed = millis() - loop_start_time; - if (elapsed > MAX_PROCESSING_TIME_MS) { + while (processed < drain_budget.max_packets) { + if (!MQTTPacketQueuePolicy::drainTimeAvailable( + static_cast(millis()), + static_cast(loop_start_time), + drain_budget.max_time_ms)) { break; } @@ -2570,7 +2849,10 @@ void MQTTBridge::processPacketQueue() { } unsigned long now_ms = millis(); - if (queued.next_retry_ms != 0 && now_ms < queued.next_retry_ms) { + if (!MQTTPacketQueuePolicy::retryReady( + static_cast(now_ms), + static_cast(queued.next_retry_ms), + queued.retry_attempts)) { // Not ready yet; put it back and stop draining this cycle. xQueueSend(_packet_queue_handle, &queued, 0); break; @@ -2586,29 +2868,44 @@ void MQTTBridge::processPacketQueue() { _last_raw_timestamp = millis(); } + bool packet_eligible = false; bool packet_published = publishPacket(&queued.packet_copy, queued.is_tx, + packet_eligible, queued.has_raw_data ? queued.raw_data : nullptr, queued.has_raw_data ? queued.raw_len : 0, queued.snr, queued.rssi); taskYIELD(); // allow higher-priority tasks to run between packet publishes - // Publish raw if enabled + // Publish raw if enabled (live from prefs so `set mqtt.raw` applies without + // a bridge restart) + bool raw_eligible = false; bool raw_published = false; - if (_raw_enabled) { - raw_published = publishRaw(&queued.packet_copy); + if (_obs->mqtt_raw_enabled) { + raw_published = publishRaw(&queued.packet_copy, raw_eligible); } - bool any_published = packet_published || raw_published; - if (!any_published && queued.retry_attempts < MAX_QOS0_RETRY_ATTEMPTS) { - queued.retry_attempts++; - unsigned long retry_delay_ms = RETRY_DELAY_BASE_MS + (now_ms % RETRY_DELAY_JITTER_MS); - queued.next_retry_ms = now_ms + retry_delay_ms; + // Decide intentional completion once across the entire queue item. An + // ineligible raw path (for example, MeshRank, which does not take raw) + // must not hide a failed eligible structured publish. + const bool queue_complete = MQTTPacketFilter::publishComplete( + packet_eligible || raw_eligible, + MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published)); + const MQTTPacketQueuePolicy::RetryDecision retry = + MQTTPacketQueuePolicy::retryDecision( + queue_complete, queued.retry_attempts, + static_cast(now_ms)); + if (retry.action == MQTTPacketQueuePolicy::RetryAction::Schedule) { + queued.retry_attempts = retry.retry_attempts; + queued.next_retry_ms = retry.next_retry_ms; #ifdef MQTT_DIAG_VERBOSE if (now_ms - last_retry_schedule_log > 5000UL) { - unsigned long age_ms = (queued.timestamp > 0 && now_ms >= queued.timestamp) ? (now_ms - queued.timestamp) : 0; + unsigned long age_ms = queued.timestamp > 0 + ? MQTTPacketQueuePolicy::elapsedMs(static_cast(now_ms), + static_cast(queued.timestamp)) + : 0; MQTT_DEBUG_PRINTLN("Retry scheduled: attempt=%u/%u delay=%lu age=%lu q=%u pkt_type=%u packet_ok=%d raw_ok=%d", - (unsigned)queued.retry_attempts, (unsigned)MAX_QOS0_RETRY_ATTEMPTS, - retry_delay_ms, age_ms, (unsigned)uxQueueMessagesWaiting(_packet_queue_handle), + (unsigned)queued.retry_attempts, (unsigned)MQTTPacketQueuePolicy::kMaxQos0RetryAttempts, + (unsigned long)retry.delay_ms, age_ms, (unsigned)uxQueueMessagesWaiting(_packet_queue_handle), (unsigned)queued.packet_copy.getPayloadType(), packet_published ? 1 : 0, raw_published ? 1 : 0); last_retry_schedule_log = now_ms; } @@ -2616,13 +2913,16 @@ void MQTTBridge::processPacketQueue() { if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) { MQTT_DEBUG_PRINTLN("Retry requeue failed, dropping packet (attempt=%u)", queued.retry_attempts); } - } else if (!any_published) { + } else if (retry.action == MQTTPacketQueuePolicy::RetryAction::Drop) { // Intentional: QoS0 best-effort packets are dropped silently in normal // builds; detailed exhaustion logs are only emitted in verbose mode. #ifdef MQTT_DIAG_VERBOSE static unsigned long last_retry_drop_log = 0; if (now_ms - last_retry_drop_log > 60000UL) { - unsigned long age_ms = (queued.timestamp > 0 && now_ms >= queued.timestamp) ? (now_ms - queued.timestamp) : 0; + unsigned long age_ms = queued.timestamp > 0 + ? MQTTPacketQueuePolicy::elapsedMs(static_cast(now_ms), + static_cast(queued.timestamp)) + : 0; MQTT_DEBUG_PRINTLN("Packet dropped after retry exhaustion (attempts=%u age=%lu pkt_type=%u packet_ok=%d raw_ok=%d)", queued.retry_attempts, age_ms, (unsigned)queued.packet_copy.getPayloadType(), packet_published ? 1 : 0, raw_published ? 1 : 0); @@ -2637,6 +2937,7 @@ void MQTTBridge::processPacketQueue() { #else // Non-ESP32: Use circular buffer if (_queue_count == 0) { + _queue_disconnected_since = 0; return; } @@ -2649,33 +2950,48 @@ void MQTTBridge::processPacketQueue() { MQTT_DEBUG_PRINTLN("Queue has %d packets but no slots connected", _queue_count); _last_no_broker_log = now; } + if (_queue_disconnected_since == 0) { + _queue_disconnected_since = now; + } else if (MQTTPacketQueuePolicy::shouldFlushDisconnected( + static_cast(now), + static_cast(_queue_disconnected_since))) { + while (_queue_count > 0) { + dequeuePacket(); + } + MQTT_DEBUG_PRINTLN("Flushed stale packet queue after %lu ms disconnected", + now - _queue_disconnected_since); + _queue_disconnected_since = now; + } } return; } + _queue_disconnected_since = 0; _last_no_broker_log = 0; // Adaptive drain: burst-process when queue has backlog, gentle otherwise int processed = 0; - int max_per_loop = (_queue_count > 5) ? 5 : 1; + const MQTTPacketQueuePolicy::DrainBudget drain_budget = + MQTTPacketQueuePolicy::drainBudget(static_cast(_queue_count)); unsigned long loop_start_time = millis(); - const unsigned long MAX_PROCESSING_TIME_MS = (_queue_count > 5) ? 100 : 30; - static const uint8_t MAX_QOS0_RETRY_ATTEMPTS = 3; - static const unsigned long RETRY_DELAY_BASE_MS = 300UL; - static const unsigned long RETRY_DELAY_JITTER_MS = 200UL; #ifdef MQTT_DIAG_VERBOSE static unsigned long last_retry_schedule_log = 0; #endif - while (_queue_count > 0 && processed < max_per_loop) { - unsigned long elapsed = millis() - loop_start_time; - if (elapsed > MAX_PROCESSING_TIME_MS) { + while (_queue_count > 0 && processed < drain_budget.max_packets) { + if (!MQTTPacketQueuePolicy::drainTimeAvailable( + static_cast(millis()), + static_cast(loop_start_time), + drain_budget.max_time_ms)) { break; } QueuedPacket& queued = _packet_queue[_queue_head]; unsigned long now_ms = millis(); - if (queued.next_retry_ms != 0 && now_ms < queued.next_retry_ms) { + if (!MQTTPacketQueuePolicy::retryReady( + static_cast(now_ms), + static_cast(queued.next_retry_ms), + queued.retry_attempts)) { break; } @@ -2687,40 +3003,58 @@ void MQTTBridge::processPacketQueue() { _last_raw_timestamp = millis(); } + bool packet_eligible = false; bool packet_published = publishPacket(&queued.packet_copy, queued.is_tx, + packet_eligible, queued.has_raw_data ? queued.raw_data : nullptr, queued.has_raw_data ? queued.raw_len : 0, queued.snr, queued.rssi); // No taskYIELD() on non-ESP32 platforms (non-FreeRTOS, cooperative scheduling not needed) + // Live from prefs so `set mqtt.raw` applies without a bridge restart. + bool raw_eligible = false; bool raw_published = false; - if (_raw_enabled) { - raw_published = publishRaw(&queued.packet_copy); + if (_obs->mqtt_raw_enabled) { + raw_published = publishRaw(&queued.packet_copy, raw_eligible); } - bool any_published = packet_published || raw_published; - if (!any_published && queued.retry_attempts < MAX_QOS0_RETRY_ATTEMPTS) { - queued.retry_attempts++; - unsigned long retry_delay_ms = RETRY_DELAY_BASE_MS + (now_ms % RETRY_DELAY_JITTER_MS); - queued.next_retry_ms = now_ms + retry_delay_ms; + // Decide intentional completion once across the entire queue item. An + // ineligible raw path (for example, MeshRank, which does not take raw) + // must not hide a failed eligible structured publish. + const bool queue_complete = MQTTPacketFilter::publishComplete( + packet_eligible || raw_eligible, + MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published)); + const MQTTPacketQueuePolicy::RetryDecision retry = + MQTTPacketQueuePolicy::retryDecision( + queue_complete, queued.retry_attempts, + static_cast(now_ms)); + if (retry.action == MQTTPacketQueuePolicy::RetryAction::Schedule) { + queued.retry_attempts = retry.retry_attempts; + queued.next_retry_ms = retry.next_retry_ms; #ifdef MQTT_DIAG_VERBOSE if (now_ms - last_retry_schedule_log > 5000UL) { - unsigned long age_ms = (queued.timestamp > 0 && now_ms >= queued.timestamp) ? (now_ms - queued.timestamp) : 0; + unsigned long age_ms = queued.timestamp > 0 + ? MQTTPacketQueuePolicy::elapsedMs(static_cast(now_ms), + static_cast(queued.timestamp)) + : 0; MQTT_DEBUG_PRINTLN("Retry scheduled: attempt=%u/%u delay=%lu age=%lu q=%d pkt_type=%u packet_ok=%d raw_ok=%d", - (unsigned)queued.retry_attempts, (unsigned)MAX_QOS0_RETRY_ATTEMPTS, - retry_delay_ms, age_ms, _queue_count, + (unsigned)queued.retry_attempts, (unsigned)MQTTPacketQueuePolicy::kMaxQos0RetryAttempts, + (unsigned long)retry.delay_ms, age_ms, _queue_count, (unsigned)queued.packet_copy.getPayloadType(), packet_published ? 1 : 0, raw_published ? 1 : 0); last_retry_schedule_log = now_ms; } #endif break; // keep packet at head for delayed retry - } else if (!any_published) { + } else if (retry.action == MQTTPacketQueuePolicy::RetryAction::Drop) { // Intentional: QoS0 best-effort packets are dropped silently in normal // builds; detailed exhaustion logs are only emitted in verbose mode. #ifdef MQTT_DIAG_VERBOSE static unsigned long last_retry_drop_log = 0; if (now_ms - last_retry_drop_log > 60000UL) { - unsigned long age_ms = (queued.timestamp > 0 && now_ms >= queued.timestamp) ? (now_ms - queued.timestamp) : 0; + unsigned long age_ms = queued.timestamp > 0 + ? MQTTPacketQueuePolicy::elapsedMs(static_cast(now_ms), + static_cast(queued.timestamp)) + : 0; MQTT_DEBUG_PRINTLN("Packet dropped after retry exhaustion (attempts=%u age=%lu pkt_type=%u packet_ok=%d raw_ok=%d)", queued.retry_attempts, age_ms, (unsigned)queued.packet_copy.getPayloadType(), packet_published ? 1 : 0, raw_published ? 1 : 0); @@ -2739,6 +3073,69 @@ void MQTTBridge::processPacketQueue() { // Publishing // --------------------------------------------------------------------------- +// Which slots will actually take this payload type, resolved in two stages so +// the cheap test runs first: the filter/enabled check costs a bit test, while +// the topic build costs an snprintf. Both are far cheaper than the ~2 KB JSON +// serialisation they gate, so this must complete before a caller builds a +// message — a slot that cannot form a topic (MeshRank raw, or a meshcore +// preset with no IATA set) would otherwise pay for a document nobody receives. +// +// A slot's connection state is deliberately not consulted: a configured but +// disconnected broker is still a target, so the packet stays on the queue for +// the existing bounded retry rather than being dropped as complete. +uint8_t MQTTBridge::eligiblePacketSlots(uint8_t packet_type, MQTTMessageType type) { + static_assert(RUNTIME_MQTT_SLOTS <= 8, "eligible slot mask must fit in uint8_t"); + // Per-slot filters cover packet traffic only. Status and neighbors are + // documented as never filtered, so refuse those publication types here + // rather than silently applying a packet mask if a caller is added later. + if (!_obs || (type != MSG_PACKETS && type != MSG_RAW)) return 0; + + uint8_t eligible_slots = 0; + char topic[128]; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) { + const bool slot_enabled = _slots[i].enabled && _slots[i].client != nullptr; + // Load once so a live CLI/WebConfig update cannot split this packet's + // decision across two different masks. + const uint16_t filter_mask = _obs->mqtt_slot_packet_filter[i]; + if (!MQTTPacketFilter::slotCandidate(slot_enabled, filter_mask, packet_type)) continue; + + const bool topic_supported = buildTopicForSlot(i, type, topic, sizeof(topic)); + if (MQTTPacketFilter::slotEligible(slot_enabled, topic_supported, + filter_mask, packet_type)) { + eligible_slots |= static_cast(1u << i); + } + } + return eligible_slots; +} + +// Conservative pre-queue gate: the OR of every configured slot's mask. Runs on +// Core 1 in the radio callback, so it must stay cheap — a packet no broker +// wants is rejected before it is copied into the queue at all. `filtered` +// separates "a broker is configured but none wants this type" (worth counting) +// from "no broker configured at all" (the pre-existing silent drop). +bool MQTTBridge::shouldQueuePacketType(uint8_t packet_type, bool& filtered) { + filtered = false; + if (!_obs) return false; + + uint16_t masks[RUNTIME_MQTT_SLOTS]; + bool enabled[RUNTIME_MQTT_SLOTS]; + bool any_enabled = false; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) { + masks[i] = _obs->mqtt_slot_packet_filter[i]; + enabled[i] = _slots[i].enabled && _slots[i].client != nullptr; + any_enabled = any_enabled || enabled[i]; + } + if (!any_enabled) return false; + + if (MQTTPacketFilter::allows( + MQTTPacketFilter::enabledUnion(masks, enabled, RUNTIME_MQTT_SLOTS), + packet_type)) { + return true; + } + filtered = true; + return false; +} + bool MQTTBridge::publishStatus() { if (!_cached_has_connected_slots) { return false; @@ -2827,7 +3224,7 @@ bool MQTTBridge::publishStatus() { } } // If no connected slot accepts status topics, treat as success to avoid - // infinite retry loops. + // infinite retry loops if (published || !any_slot_wants_status) { if (published) MQTT_DEBUG_PRINTLN("Status published"); return true; @@ -2837,55 +3234,20 @@ bool MQTTBridge::publishStatus() { return false; } -#if defined(WITH_MQTT_NEIGHBORS) -void MQTTBridge::setNeighborsSchedule(NeighborsPhase phase, uint32_t secs_until_next) { - _neighbors_phase.store((uint8_t)phase, std::memory_order_relaxed); - _neighbors_secs_until_next.store(secs_until_next, std::memory_order_relaxed); -} - -void MQTTBridge::requestPublishNeighbors(const char* json, size_t len) { - if (!_neighbors_json_buffer || !json || len == 0) return; - if (_neighbors_publish_pending.load(std::memory_order_acquire)) return; - if (len >= NEIGHBORS_JSON_BUFFER_SIZE) { - len = NEIGHBORS_JSON_BUFFER_SIZE - 1; - } - memcpy(_neighbors_json_buffer, json, len); - _neighbors_json_buffer[len] = '\0'; - _neighbors_publish_len = len; - _neighbors_publish_pending.store(true, std::memory_order_release); -} - -bool MQTTBridge::publishNeighbors() { - if (!_neighbors_json_buffer || _neighbors_publish_len == 0) { - return false; - } - if (!_cached_has_connected_slots) { - return false; - } - - refreshOriginFromPrefs(); - - bool published = false; - char topic[128]; - for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].enabled && _slots[i].client && _slots[i].connected) { - if (buildTopicForSlot(i, MSG_NEIGHBORS, topic, sizeof(topic))) { - bool use_retain = _slots[i].preset ? _slots[i].preset->allow_retain : false; - if (publishToSlot(i, topic, _neighbors_json_buffer, use_retain, 1)) { - published = true; - } - } - } - } - return published; -} -#endif - bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, + bool& has_eligible_target, const uint8_t* raw_data, int raw_len, float snr, float rssi) { + has_eligible_target = false; if (!packet) return false; + const uint8_t packet_type = packet->getPayloadType(); + const uint8_t eligible_slots = eligiblePacketSlots(packet_type, MSG_PACKETS); + has_eligible_target = eligible_slots != 0; + // Filtered out everywhere, or no slot can form a packets topic: intentionally + // complete, and nothing below (JSON build included) is worth paying for. + if (!has_eligible_target) return false; + refreshOriginFromPrefs(); // Memory pressure check: Skip publishes when there's not enough contiguous @@ -2902,19 +3264,25 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, static const size_t PUBLISH_SKIP_MAX_ALLOC_THRESHOLD = 8000; #endif unsigned long now = millis(); + // Re-sample max-alloc at most once per interval and cache the verdict. + // getMaxAllocHeap() walks the heap free-list, so it must not run per packet. + // The previous code only advanced _last_memory_check on the healthy path, so + // under sustained pressure the guard stayed open and it walked the heap on + // EVERY packet — the opposite of throttling (A15). Caching the verdict keeps + // the "skip publishes while memory is low" protection but pays for the walk + // only once per interval; _last_memory_check is now advanced on both paths. if (now - _last_memory_check > 5000) { - size_t max_alloc = ESP.getMaxAllocHeap(); - if (max_alloc < PUBLISH_SKIP_MAX_ALLOC_THRESHOLD) { - _skipped_publishes++; - static unsigned long last_skip_log = 0; - if (now - last_skip_log > 60000) { - MQTT_DEBUG_PRINTLN("MQTT: Skipping publish due to memory pressure (Max alloc: %d, threshold: %d, skipped: %d)", - max_alloc, (int)PUBLISH_SKIP_MAX_ALLOC_THRESHOLD, _skipped_publishes); - last_skip_log = now; - } - return false; - } _last_memory_check = now; + size_t max_alloc = ESP.getMaxAllocHeap(); + _memory_pressure = (max_alloc < PUBLISH_SKIP_MAX_ALLOC_THRESHOLD); + if (_memory_pressure) { + MQTT_DEBUG_PRINTLN("MQTT: memory pressure, skipping publishes (Max alloc: %d, threshold: %d, skipped: %d)", + (int)max_alloc, (int)PUBLISH_SKIP_MAX_ALLOC_THRESHOLD, _skipped_publishes); + } + } + if (_memory_pressure) { + _skipped_publishes++; + return false; } #endif @@ -2985,7 +3353,11 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, bool published = false; char topic[128]; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].enabled && _slots[i].client && _slots[i].connected) { + // Eligibility already proved this slot's topic builds; rebuilding it is + // one snprintf, cheaper than carrying a RUNTIME_MQTT_SLOTS x 128 topic + // cache on the MQTT task's 8 KB stack alongside the JSON buffer. + if ((eligible_slots & static_cast(1u << i)) != 0 && + _slots[i].enabled && _slots[i].client && _slots[i].connected) { if (buildTopicForSlot(i, MSG_PACKETS, topic, sizeof(topic))) { if (publishToSlot(i, topic, active_buffer, false)) { published = true; @@ -2995,7 +3367,6 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, } return published; } else { - uint8_t packet_type = packet->getPayloadType(); if (packet_type == 4 || packet_type == 9) { MQTT_DEBUG_PRINTLN("Failed to build packet JSON for type=%d (len=%d), packet not published", packet_type, len); } @@ -3003,9 +3374,17 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, return false; } -bool MQTTBridge::publishRaw(mesh::Packet* packet) { +bool MQTTBridge::publishRaw(mesh::Packet* packet, bool& has_eligible_target) { + has_eligible_target = false; if (!packet) return false; + const uint8_t packet_type = packet->getPayloadType(); + const uint8_t eligible_slots = eligiblePacketSlots(packet_type, MSG_RAW); + has_eligible_target = eligible_slots != 0; + // Filtered out everywhere, or no slot has a raw topic (MeshRank does not take + // raw): intentionally complete, and no JSON is built. + if (!has_eligible_target) return false; + refreshOriginFromPrefs(); #if defined(BOARD_HAS_PSRAM) @@ -3036,7 +3415,8 @@ bool MQTTBridge::publishRaw(mesh::Packet* packet) { bool published = false; char topic[128]; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (_slots[i].enabled && _slots[i].client && _slots[i].connected) { + if ((eligible_slots & static_cast(1u << i)) != 0 && + _slots[i].enabled && _slots[i].client && _slots[i].connected) { if (buildTopicForSlot(i, MSG_RAW, topic, sizeof(topic))) { if (publishToSlot(i, topic, active_buffer, false)) { published = true; @@ -3049,6 +3429,55 @@ bool MQTTBridge::publishRaw(mesh::Packet* packet) { return false; } +#if defined(WITH_MQTT_NEIGHBORS) +// --------------------------------------------------------------------------- +// Periodic neighbors publication +// --------------------------------------------------------------------------- + +void MQTTBridge::setNeighborsSchedule(NeighborsPhase phase, uint32_t secs_until_next) { + _neighbors_phase.store((uint8_t)phase, std::memory_order_relaxed); + _neighbors_secs_until_next.store(secs_until_next, std::memory_order_relaxed); +} + +void MQTTBridge::requestPublishNeighbors(const char* json, size_t len) { + if (!_neighbors_json_buffer || !json || len == 0) return; + // Drop a new snapshot while one is still being published (Core 0 clears the + // flag when done). Acquire pairs with the task loop's release store. + if (_neighbors_publish_pending.load(std::memory_order_acquire)) return; + if (len >= NEIGHBORS_JSON_BUFFER_SIZE) { + len = NEIGHBORS_JSON_BUFFER_SIZE - 1; + } + memcpy(_neighbors_json_buffer, json, len); + _neighbors_json_buffer[len] = '\0'; + _neighbors_publish_len = len; + _neighbors_publish_pending.store(true, std::memory_order_release); +} + +bool MQTTBridge::publishNeighbors() { + if (!_neighbors_json_buffer || _neighbors_publish_len == 0) return false; + if (!_cached_has_connected_slots) return false; + + refreshOriginFromPrefs(); + + bool published = false; + char topic[128]; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].client && _slots[i].connected) { + // Slots that cannot form a neighbors topic are skipped here. + if (buildTopicForSlot(i, MSG_NEIGHBORS, topic, sizeof(topic))) { + // Neighbor snapshots are periodically refreshed. Publish synchronously + // at QoS 0 to avoid the QoS 1 outbox, retaining where the broker allows. + bool use_retain = _slots[i].preset ? _slots[i].preset->allow_retain : false; + if (publishToSlot(i, topic, _neighbors_json_buffer, use_retain, 0)) { + published = true; + } + } + } + } + return published; +} +#endif // WITH_MQTT_NEIGHBORS + // --------------------------------------------------------------------------- // Queue management // --------------------------------------------------------------------------- @@ -3092,27 +3521,40 @@ void MQTTBridge::queuePacket(mesh::Packet* packet, bool is_tx) { // Try to send to queue (non-blocking) if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) { + const MQTTPacketQueuePolicy::EnqueueAction action = + MQTTPacketQueuePolicy::enqueueAction( + static_cast(uxQueueMessagesWaiting(_packet_queue_handle)), + static_cast(MAX_QUEUE_SIZE)); QueuedPacket oldest; - if (xQueueReceive(_packet_queue_handle, &oldest, 0) == pdTRUE) { + if (action == MQTTPacketQueuePolicy::EnqueueAction::EvictOldestThenEnqueue && + xQueueReceive(_packet_queue_handle, &oldest, 0) == pdTRUE) { MQTT_DEBUG_PRINTLN("Queue full, dropping oldest packet reference"); - if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) { - MQTT_DEBUG_PRINTLN("Failed to queue packet after dropping oldest"); - return; - } - } else { + } else if (action == MQTTPacketQueuePolicy::EnqueueAction::Reject) { + MQTT_DEBUG_PRINTLN("Queue has no capacity"); + return; + } else if (action == MQTTPacketQueuePolicy::EnqueueAction::EvictOldestThenEnqueue) { MQTT_DEBUG_PRINTLN("Queue full and cannot remove oldest packet"); return; } + // If the consumer made room after the failed send, retry without evicting. + if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) { + MQTT_DEBUG_PRINTLN("Failed to queue packet after overflow handling"); + return; + } } UBaseType_t queue_messages = uxQueueMessagesWaiting(_packet_queue_handle); _queue_count = queue_messages; #else // Non-ESP32: Use circular buffer - if (_queue_count >= MAX_QUEUE_SIZE) { - QueuedPacket& oldest = _packet_queue[_queue_head]; + const MQTTPacketQueuePolicy::EnqueueAction action = + MQTTPacketQueuePolicy::enqueueAction( + static_cast(_queue_count), static_cast(MAX_QUEUE_SIZE)); + if (action == MQTTPacketQueuePolicy::EnqueueAction::EvictOldestThenEnqueue) { MQTT_DEBUG_PRINTLN("Queue full, dropping oldest packet (queue size: %d)", _queue_count); dequeuePacket(); + } else if (action == MQTTPacketQueuePolicy::EnqueueAction::Reject) { + return; } QueuedPacket& queued = _packet_queue[_queue_tail]; @@ -3331,8 +3773,6 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { last_timezone[sizeof(last_timezone) - 1] = '\0'; } - (void)gmtime((time_t*)&epochTime); - (void)localtime((time_t*)&epochTime); return true; } @@ -3350,6 +3790,13 @@ bool MQTTBridge::requestForcedNtpSync(uint32_t timeout_ms) { _ntp_force_result = false; _ntp_force_requested = true; + // Fire-and-forget: callers on the Arduino loop task (web config batch, and + // the CLI which shares that task) must not block up to 30 s polling the MQTT + // task — that stalls mesh/radio forwarding, portal DNS, and reboot timers. + // The task still performs the sync; the result is observable via + // `get mqtt.ntp.diag`. Blocking callers pass a non-zero timeout. + if (timeout_ms == 0) return true; + unsigned long start = millis(); while (!_ntp_force_done) { if (millis() - start >= timeout_ms) { diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 17346c43f..143aae36b 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -7,9 +7,11 @@ #include #include #include -#include #include "helpers/JWTHelper.h" +#include "helpers/MQTTPacketFilter.h" #include "helpers/MQTTPresets.h" +#include "helpers/MQTTLifecycle.h" +#include #ifdef WITH_SNMP class MeshSNMPAgent; // Forward declaration @@ -36,7 +38,15 @@ class MeshSNMPAgent; // Forward declaration #ifdef WITH_MQTT_BRIDGE -#if defined(BOARD_HAS_PSRAM) && defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0 +// Periodic neighbors publication keys off the mesh neighbor cache (sized by +// MAX_NEIGHBOURS) and needs a persistent JSON buffer plus a second transient one +// while the mesh builds the table. PSRAM boards get it automatically; non-PSRAM +// boards must opt in per variant with MQTT_NEIGHBORS_WITHOUT_PSRAM, which spends +// internal DRAM the TLS stack also needs (see NEIGHBORS_JSON_BUFFER_SIZE). Every +// neighbors-specific member, method, and code block here is gated on +// WITH_MQTT_NEIGHBORS. +#if defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0 && \ + (defined(BOARD_HAS_PSRAM) || defined(MQTT_NEIGHBORS_WITHOUT_PSRAM)) #define WITH_MQTT_NEIGHBORS 1 #endif @@ -201,6 +211,14 @@ private: // Pending slot reconfigure: set from CLI (Core 1), processed by MQTT task (Core 0) volatile bool _slot_reconfigure_pending[RUNTIME_MQTT_SLOTS]; + // Pending on-connect status publish: set from the onConnect callback (which + // runs on the esp-mqtt event task, NOT this bridge task), consumed by the MQTT + // task (Core 0). publishStatusToSlot() touches the shared status doc/buffer/ + // origin that publishStatus() also uses, so it must run only on the bridge + // task — the callback just raises this flag. Same idiom as + // _slot_reconfigure_pending; a single-byte volatile store/load is atomic. + volatile bool _status_publish_pending[RUNTIME_MQTT_SLOTS]; + // CLI-requested forced NTP sync, marshalled onto the MQTT task (Core 0). // All NTP I/O (_ntp_client, configTime) must run on Core 0; the CLI thread // (Core 1) sets _ntp_force_requested and blocks in requestForcedNtpSync() @@ -226,6 +244,16 @@ private: NtpDiagResult _ntp_diag_results[kMaxNtpServers]; int _ntp_diag_count; + // Cooperative-shutdown handshake (Phase 5). The loop task (Core 1) raises + // _stop_requested through the lifecycle Coordinator; the MQTT task (Core 0) + // sees it, tears down its own clients on Core 0 (where the mbedTLS contexts + // live), sets _stop_acked LAST, and self-terminates. end() waits for the ack + // before freeing the queue/buffers. Plain volatile matches the existing + // NTP/reconfigure handshake idiom above; replacing all of these with a command + // channel / task notifications is explicitly deferred (see MQTT_OWNERSHIP.md). + volatile bool _stop_requested = false; + volatile bool _stop_acked = false; + // Timezone handling. // _timezone_storage is inline class storage (zero heap) that is reconfigured // via setRules() whenever the preferred timezone string changes. _timezone @@ -262,19 +290,6 @@ private: // class object so these allocations don't interleave with large TLS buffers at startup. static const size_t PUBLISH_JSON_BUFFER_SIZE = 2048; static const size_t STATUS_JSON_BUFFER_SIZE = 768; - #if defined(WITH_MQTT_NEIGHBORS) - char* _neighbors_json_buffer; - size_t _neighbors_publish_len; - // Release/acquire handoff from the Arduino loop (Core 1) to the MQTT task - // (Core 0). A second snapshot is dropped while the current one is publishing. - std::atomic _neighbors_publish_pending; - // Written by the MQTT task (Core 0), read by the CLI (Core 1). - enum NeighborsResult : uint8_t { NBR_RESULT_NONE, NBR_RESULT_OK, NBR_RESULT_FAIL }; - std::atomic _neighbors_last_result; - // Written by the mesh loop (Core 1), read by the CLI (Core 1). - std::atomic _neighbors_phase; - std::atomic _neighbors_secs_until_next; - #endif #if defined(BOARD_HAS_PSRAM) char* _publish_json_buffer; char* _status_json_buffer; @@ -283,6 +298,26 @@ private: char _status_json_buffer[STATUS_JSON_BUFFER_SIZE]; #endif +#if defined(WITH_MQTT_NEIGHBORS) + // Persistent PSRAM copy of the neighbors-table JSON. The mesh (Core 1) builds + // the payload into its own transient buffer, hands it here via + // requestPublishNeighbors(), and the MQTT task (Core 0) publishes this copy. + // Allocated in allocateRuntimeBuffers()/freed in releaseRuntimeBuffers() like + // the other PSRAM buffers (nullptr if the allocation failed). + char* _neighbors_json_buffer; + size_t _neighbors_publish_len; + // Release/acquire handoff from the mesh loop (Core 1) to the MQTT task (Core 0). + // A second snapshot is dropped while the current one is still publishing. + std::atomic _neighbors_publish_pending; + // Written by the MQTT task (Core 0), read by the CLI (Core 1) for `get mqtt.status`. + enum NeighborsResult : uint8_t { NBR_RESULT_NONE, NBR_RESULT_OK, NBR_RESULT_FAIL }; + std::atomic _neighbors_last_result; + // Written by the mesh loop (Core 1), read by the CLI (Core 1). Cached schedule + // summary so the wrap-safe millis math stays on the mesh side that owns the timer. + std::atomic _neighbors_phase; + std::atomic _neighbors_secs_until_next; +#endif + // JSON document scratch space — inline StaticJsonDocument keeps the pool off the MQTT // task stack and eliminates two separate heap allocations (fragmentation reduction). StaticJsonDocument _packet_json_doc; @@ -293,7 +328,12 @@ private: // the MQTT memory-defrag work — persistent MQTT clients no longer churn // the heap, so gray-zone / critical-restart trackers are unnecessary. unsigned long _last_memory_check; + bool _memory_pressure = false; // Cached max-alloc verdict; re-sampled at most once per interval in publishPacket() so the heap walk isn't paid per-packet under pressure int _skipped_publishes; // Exposed via SNMP; count of publishes skipped when max_alloc is too low + // Packets rejected by the per-slot filters before reaching the queue. Written + // on Core 1 (radio callbacks), read on Core 0 for `mqtt.stats`; a torn read of + // a diagnostic counter is harmless, so no atomic is warranted. + unsigned long _filtered_packets = 0; // Status publish retry tracking unsigned long _last_status_retry; // Track last retry attempt (separate from successful publish) @@ -307,7 +347,6 @@ private: // Queue staleness tracking unsigned long _queue_disconnected_since; // 0 = has connected slots - static const unsigned long QUEUE_STALE_MS = 300000UL; // Flush queue after 5 min disconnected #ifdef WITH_SNMP MeshSNMPAgent* _snmp_agent; @@ -337,9 +376,10 @@ private: // Topic building enum MQTTMessageType { MSG_STATUS, MSG_PACKETS, MSG_RAW, MSG_NEIGHBORS }; - static const char* messageTypeSuffix(MQTTMessageType type); bool buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size); bool substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size); + uint8_t eligiblePacketSlots(uint8_t packet_type, MQTTMessageType type); + bool shouldQueuePacketType(uint8_t packet_type, bool& filtered); // Internal methods - slot management // Lifetime model (Phase 1 of MQTT memory-defrag): @@ -360,7 +400,6 @@ private: void maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted); bool createSlotAuthToken(int index); // Create/renew JWT token for a slot unsigned long slotTokenLifetime(int index) const; // effective JWT lifetime (preset/default minus slot stagger), seconds - static unsigned long tokenRenewalBufferSecs(unsigned long lifetime_secs); // how early to renew+reconnect before exp bool publishToSlot(int index, const char* topic, const char* payload, bool retained = false, uint8_t qos = 0); bool publishToAllSlots(const char* topic, const char* payload, bool retained = false, uint8_t qos = 0); void publishStatusToSlot(int index); @@ -368,9 +407,6 @@ private: void processPacketQueue(); bool publishStatus(); // Returns true if status was successfully published - #if defined(WITH_MQTT_NEIGHBORS) - bool publishNeighbors(); - #endif bool handleWiFiConnection(unsigned long now); // FreeRTOS task function (runs on Core 0) @@ -379,10 +415,15 @@ private: void mqttTaskLoop(); // Main loop for MQTT task void initializeWiFiInTask(); // WiFi initialization moved to task #endif - bool publishPacket(mesh::Packet* packet, bool is_tx, + bool publishPacket(mesh::Packet* packet, bool is_tx, bool& has_eligible_target, const uint8_t* raw_data = nullptr, int raw_len = 0, float snr = 0.0f, float rssi = 0.0f); - bool publishRaw(mesh::Packet* packet); + bool publishRaw(mesh::Packet* packet, bool& has_eligible_target); +#if defined(WITH_MQTT_NEIGHBORS) + // Publishes the pending _neighbors_json_buffer to every connected slot's + // neighbors topic. Runs on the MQTT task (Core 0) only. + bool publishNeighbors(); +#endif void queuePacket(mesh::Packet* packet, bool is_tx); void dequeuePacket(); bool isAnySlotConnected(); @@ -400,6 +441,32 @@ private: void getClientVersion(char* buffer, size_t buffer_size) const; void logMemoryStatus(); void refreshOriginFromPrefs(); + // begin()/end()-scoped PSRAM buffers. Each allocation is independent so a + // transient heap shortage degrades to the existing stack fallback instead + // of making the bridge unusable. + void allocateRuntimeBuffers(); + void releaseRuntimeBuffers(); + + // --- Cooperative lifecycle (Phase 5) --------------------------------------- + // The pure state machine, bounded stop timeout, and OTA barrier live in + // src/helpers/MQTTLifecycle.h and are host-tested by test/test_mqtt_lifecycle/. + // This nested Ops binds that spec to FreeRTOS/PsychicMqttClient. The + // Coordinator is owned and driven ONLY by the loop task (Core 1) from + // begin()/end(); the MQTT task (Core 0) communicates solely through the + // _stop_requested/_stop_acked flags above. Methods are defined in the .cpp. + class LifecycleOps : public MQTTLifecycle::Ops { + public: + explicit LifecycleOps(MQTTBridge* bridge) : _b(bridge) {} + uint32_t nowMs() override; + void startTask() override; + void deliverStop() override; + void releaseResources() override; + void onStopComplete(bool clean) override; + private: + MQTTBridge* _b; + }; + LifecycleOps _lifecycle_ops; + MQTTLifecycle::Coordinator _lifecycle; // Observer config (MQTT/WiFi/timezone/SNMP/alert), persisted to /mqtt_prefs. // _prefs (held by BridgeBase) still provides upstream fields (freq/sf/node_name…). @@ -419,7 +486,7 @@ public: * "set mqttN.preset ". Handles teardown of old connection and * setup of new one. * - * @param slot_index Slot index (0-2) + * @param slot_index Slot index (0 to RUNTIME_MQTT_SLOTS-1) * @param preset_name Preset name: "analyzer-us", "analyzer-eu", "nz-analyzer", "meshmapper", "custom", "none" */ void setSlotPreset(int slot_index, const char* preset_name); @@ -429,7 +496,7 @@ public: * Configure custom broker settings for a slot. Only applies when the * slot's preset is "custom". * - * @param slot_index Slot index (0-2) + * @param slot_index Slot index (0 to RUNTIME_MQTT_SLOTS-1) * @param host Broker hostname * @param port Broker port * @param username MQTT username (empty for anonymous) @@ -446,9 +513,68 @@ public: void setBuildDate(const char* build_date); void storeRawRadioData(const uint8_t* raw_data, int len, float snr, float rssi); void setMessageTypes(bool status, bool packets, bool raw); + +#if defined(WITH_MQTT_NEIGHBORS) + // Single source of truth for the neighbors JSON size, used by both the bridge's + // persistent buffer and the mesh's transient build buffer. + #if defined(BOARD_HAS_PSRAM) + static const size_t NEIGHBORS_JSON_BUFFER_SIZE = 10240; + #else + // Without PSRAM all three neighbors allocations (persistent buffer, transient + // build buffer, ArduinoJson pool) come out of internal DRAM, which each TLS + // slot also needs ~40 KB of. A quarter-size buffer keeps the peak near 13 KB + // instead of ~35 KB; the builder drops the tail and sets "truncated" once the + // next entry will not fit, so the table degrades instead of failing. + static const size_t NEIGHBORS_JSON_BUFFER_SIZE = 4096; + #endif + + // The ArduinoJson pool is NOT bounded by the text buffer: v7 hands out pool + // blocks in fixed 4096-byte chunks, so a table that just fits the text buffer + // can still need well over it in pool. Budgeting the pool at the text size + // starves it, and a starved pool sets doc.overflowed() — which drops the whole + // publish instead of truncating. Measured need is 12541 B for 50 entries and + // 4286 B for 21, so allow a spare block over each. + #if defined(BOARD_HAS_PSRAM) + static const size_t NEIGHBORS_DOC_POOL_BUDGET = 16384; + #else + static const size_t NEIGHBORS_DOC_POOL_BUDGET = 12288; + #endif + + // Entries per publish. One 4096-byte pool block holds 21, so staying under + // that keeps the non-PSRAM pool to a single block (~4.3 KB rather than + // ~8.4 KB) and the whole publish peak near 13 KB of internal DRAM. PSRAM + // boards are limited only by the text buffer and the neighbour cache. + #if defined(BOARD_HAS_PSRAM) + static const int NEIGHBORS_MAX_PUBLISH_ENTRIES = MAX_NEIGHBOURS; + #else + static const int NEIGHBORS_MAX_PUBLISH_ENTRIES = 20; + #endif + + // Called by the mesh (Core 1) once a neighbor-discovery pass has built the + // table JSON. Copies it into the persistent PSRAM buffer and raises the + // publish-pending flag for the MQTT task; a request is dropped if one is + // already in flight or the buffer is unavailable. + void requestPublishNeighbors(const char* json, size_t len); + + // Periodic-neighbors schedule, reported by the mesh loop for `get mqtt.status`. + // The mesh owns the timer; the bridge only caches the summary so the wrap-safe + // millis math stays on the side that already has those helpers. + enum NeighborsPhase : uint8_t { + NBR_SCHEDULED, // waiting for the next publish; secs_until_next is valid + NBR_ACTIVE, // zero-hop refresh or scope queries in flight + NBR_DUE, // publish is due, waiting on the bridge/WiFi to come up + }; + void setNeighborsSchedule(NeighborsPhase phase, uint32_t secs_until_next); +#endif + int getConnectedBrokers() const; int getQueueSize() const; bool isReady() const; + /** True only after a CLEAN cooperative stop — end() received the MQTT task's + * acknowledgment within the timeout. A timed-out/forced stop returns false so + * OTA flashing is withheld until a clean start/stop cycle. Mirrors + * MQTTLifecycle::mayBeginFlash(); read on the loop task (Core 1). */ + bool canFlashAfterStop() const { return _lifecycle.mayBeginFlash(); } static unsigned long getWifiConnectedAtMillis(); @@ -468,6 +594,11 @@ public: bool isSlotEnabledAndAttempted(int slot_index) const; const char* getSlotPresetName(int slot_index) const; static int getRuntimeSlotCount() { return RUNTIME_MQTT_SLOTS; } + /** Max slots that can be connected at once: 5 with PSRAM, 2 without (each + * WSS/TLS connection needs ~40KB for mbedTLS buffers). This is the number of + * usefully-configurable servers; RUNTIME_MQTT_SLOTS carries a spare for + * reconfiguration. Safe to call before begin(). */ + static int getMaxActiveSlots(); /** Resolved origin for MQTT JSON: node_name when mqtt_origin is empty, else mqtt_origin (with quote stripping). */ static void getEffectiveMqttOrigin(const NodePrefs* np, const MQTTPrefs* obs, char* buf, size_t buf_size); static const char* effectiveNtpPrimary(const MQTTPrefs* obs); @@ -493,30 +624,25 @@ public: /** On-demand publish-health + heap snapshot for `get mqtt.stats` (per-slot ok/err, * outbox size, free/max heap, queue depth). */ static void formatMqttStatsReply(char* buf, size_t bufsize); + /** Structured per-slot snapshot for the webconfig stats endpoint. Same state + * logic as formatMqttStatusReply, same cross-task read semantics. Returns + * false when the bridge is not running, the index is out of range, or the + * slot is unconfigured (skip it). */ + struct SlotStatusSnapshot { + const char* name; // preset name or "custom" + const char* state; // "inactive" | "wait" | "ok" | "fail" | "disc" + unsigned long publish_ok; + unsigned long publish_err; + // Raw packet-type allowlist. Kept as the mask rather than canonical text so + // the 1 KB stats JSON stays compact; the portal renders it. + uint16_t filter_mask; + }; + static bool getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out); /** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */ static bool isConfigValid(const MQTTPrefs* obs); static void formatSlotDiagReply(char* buf, size_t bufsize, int slot_index); static uint8_t getLastWifiDisconnectReason(); static unsigned long getLastWifiDisconnectTime(); - /** Max slots that can be connected at once on this hardware (each WSS/TLS link - * needs ~40 KB internal heap): 5 with PSRAM, 2 without. Note this is below - * RUNTIME_MQTT_SLOTS, so more slots can be configured than will connect. */ - static int getMaxActiveSlots(); - - #if defined(WITH_MQTT_NEIGHBORS) - void requestPublishNeighbors(const char* json, size_t len); - static const size_t NEIGHBORS_JSON_BUFFER_SIZE = 10240; - - // Periodic-neighbors schedule, reported by the mesh loop for `get mqtt.status`. - // The mesh owns the timer; the bridge only caches the summary, which keeps the - // wrap-safe millis math on the one side that already has those helpers. - enum NeighborsPhase : uint8_t { - NBR_SCHEDULED, // waiting for the next publish; secs_until_next is valid - NBR_ACTIVE, // zero-hop refresh or scope queries in flight - NBR_DUE, // publish is due, waiting on the bridge/WiFi to come up - }; - void setNeighborsSchedule(NeighborsPhase phase, uint32_t secs_until_next); - #endif static const char* wifiReasonStr(uint8_t reason); static const char* tlsErrorStr(int32_t err); diff --git a/src/helpers/bridges/RS232Bridge.cpp b/src/helpers/bridges/RS232Bridge.cpp index 0024f6f2f..f719d342e 100644 --- a/src/helpers/bridges/RS232Bridge.cpp +++ b/src/helpers/bridges/RS232Bridge.cpp @@ -115,7 +115,8 @@ void RS232Bridge::sendPacket(mesh::Packet *packet) { return; } - if (!_seen_packets.hasSeen(packet)) { + if (!_seen_packets.wasSeen(packet)) { + _seen_packets.markSeen(packet); uint8_t buffer[MAX_SERIAL_PACKET_SIZE]; uint16_t len = packet->writeTo(buffer + 4); diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index 67b1448e8..f474215a1 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -38,7 +38,7 @@ public: * These two functions do nothing for ESP-NOW, but are needed for the * Radio interface. */ - virtual void setRxBoostedGainMode(bool) { } + virtual bool setRxBoostedGainMode(bool) { } virtual bool getRxBoostedGainMode() const { return false; } uint32_t intID(); diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index dcfa0e1e3..6371cf335 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -103,10 +103,9 @@ void SerialBLEInterface::onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param void SerialBLEInterface::onDisconnect(BLEServer* pServer) { BLE_DEBUG_PRINTLN("onDisconnect()"); + deviceConnected = false; if (_isEnabled) { adv_restart_time = millis() + ADVERT_RESTART_DELAY; - - // loop() will detect this on next loop, and set deviceConnected to false } } @@ -118,17 +117,24 @@ void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, esp_ble_gat if (len > MAX_FRAME_SIZE) { BLE_DEBUG_PRINTLN("ERROR: onWrite(), frame too big, len=%d", len); - } else if (recv_queue_len >= FRAME_QUEUE_SIZE) { - BLE_DEBUG_PRINTLN("ERROR: onWrite(), recv_queue is full!"); } else { - recv_queue[recv_queue_len].len = len; - memcpy(recv_queue[recv_queue_len].buf, rxValue, len); - recv_queue_len++; + Frame frame = {}; + frame.len = len; + memcpy(frame.buf, rxValue, len); + + if (xQueueSend(recv_queue, &frame, 0) != pdTRUE) { + BLE_DEBUG_PRINTLN("ERROR: onWrite(), recv_queue is full!"); + } } } // ---------- public methods +void SerialBLEInterface::clearBuffers() { + xQueueReset(recv_queue); + send_queue_len = 0; +} + void SerialBLEInterface::enable() { if (_isEnabled) return; @@ -202,21 +208,13 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { } } - if (recv_queue_len > 0) { // check recv queue - size_t len = recv_queue[0].len; // take from top of queue - memcpy(dest, recv_queue[0].buf, len); - - BLE_DEBUG_PRINTLN("readBytes: sz=%d, hdr=%d", len, (uint32_t) dest[0]); - - recv_queue_len--; - for (int i = 0; i < recv_queue_len; i++) { // delete top item from queue - recv_queue[i] = recv_queue[i + 1]; - } - return len; + Frame frame; + if (xQueueReceive(recv_queue, &frame, 0) == pdTRUE) { + memcpy(dest, frame.buf, frame.len); + BLE_DEBUG_PRINTLN("readBytes: sz=%d, hdr=%d", (uint32_t) frame.len, (uint32_t) dest[0]); + return frame.len; } - if (pServer->getConnectedCount() == 0) deviceConnected = false; - if (deviceConnected != oldDeviceConnected) { if (!deviceConnected) { // disconnecting clearBuffers(); diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 965e90fd1..9fb6adba9 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLEServerCallbacks, BLECharacteristicCallbacks { BLEServer *pServer; @@ -24,12 +26,13 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE }; #define FRAME_QUEUE_SIZE 4 - int recv_queue_len; - Frame recv_queue[FRAME_QUEUE_SIZE]; + StaticQueue_t recv_queue_state; + uint8_t recv_queue_storage[FRAME_QUEUE_SIZE * sizeof(Frame)]; + QueueHandle_t recv_queue; int send_queue_len; Frame send_queue[FRAME_QUEUE_SIZE]; - void clearBuffers() { recv_queue_len = 0; send_queue_len = 0; } + void clearBuffers(); protected: // BLESecurityCallbacks methods @@ -58,7 +61,10 @@ public: _isEnabled = false; _last_write = 0; last_conn_id = 0; - send_queue_len = recv_queue_len = 0; + recv_queue = xQueueCreateStatic( + FRAME_QUEUE_SIZE, sizeof(Frame), recv_queue_storage, &recv_queue_state + ); + send_queue_len = 0; } /** diff --git a/src/helpers/esp32/TBeamBoard.cpp b/src/helpers/esp32/TBeamBoard.cpp index 5f708d71d..d9f6e0227 100644 --- a/src/helpers/esp32/TBeamBoard.cpp +++ b/src/helpers/esp32/TBeamBoard.cpp @@ -16,6 +16,16 @@ void TBeamBoard::begin() { ESP32Board::begin(); +#ifdef TBEAM_SUPREME_SX1262 + // On the T-Beam S3 Supreme the PMU + RTC sit on Wire1 (GPIO 42/41, brought + // up by XPowersLib), while the SH1106 OLED and the BME280/QMC6310 sensors + // sit on the primary bus, Wire (GPIO PIN_BOARD_SDA/PIN_BOARD_SCL). Nothing + // else initialises Wire on this board, so the display driver would + // otherwise talk on the wrong (default) pins and display.begin() fails, + // leaving the screen blank. Bring the OLED bus up here. + Wire.begin(PIN_BOARD_SDA, PIN_BOARD_SCL); +#endif + power_init(); //Configure user button diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp new file mode 100644 index 000000000..34bdca4b7 --- /dev/null +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -0,0 +1,1382 @@ +#if defined(ESP_PLATFORM) && defined(WITH_MQTT_BRIDGE) + +#include "WebConfigServer.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "WebConfigHtml.h" + +// Placeholder sent instead of stored secrets; POSTs carrying it are dropped +// so an untouched password field never overwrites the stored value. +static const char SECRET_SENTINEL[] = "********"; + +// Key classification (allowlist, secret detection, slot-prefix parsing) lives in +// helpers/WebConfigKeys.h so it can be unit-tested on the host. Thin aliases keep +// the call sites below readable. +static inline bool isAllowedSetKey(const char* key) { return wcIsAllowedSetKey(key); } +static inline bool isSecretKey(const char* key) { return wcIsSecretKey(key); } + +// Commands whose CLI handler never returns would take the node down mid-drain, +// before the client could read a single result. `reboot` is deferred instead: +// it is not passed to the CLI at all, and the batch arms the ordinary reboot +// path once the operator has read the results. The rest (clkreboot, poweroff, +// ota update) do real work on the way down and cannot be faked, so they run +// normally and the connection drops — the UI warns before sending them. +// CommonCLI dispatches on a 6-byte PREFIX (memcmp(command, "reboot", 6)), so +// `reboot now` and `rebooted` reach Board::reboot() too. Matching exactly here +// let those through to the CLI, which took the node down mid-drain with no +// results and no deferral — the precise failure the deferral exists to avoid. +// Whatever CommonCLI would treat as a reboot, this must intercept. +static inline bool wcIsDeferredReboot(const char* cmd) { + return strncmp(cmd, "reboot", 6) == 0; +} + +// Commands the CLI reaches but the portal cannot honestly serve. Rejected at +// POST so nothing in the sequence runs, rather than failing halfway with a +// reply that does not explain itself. Returns the reason, or NULL if fine. +static const char* wcCliUnavailable(const char* cmd) { + // ESP32Board::startOTAUpdate() does `new AsyncWebServer(80)` with no bind + // check and answers "Started" regardless. The portal already holds port 80, + // so from here it can only leak the allocation, inhibit sleep, and lie. + if (strncmp(cmd, "start ota", 9) == 0) { + return "start ota needs port 80, which this portal is using. " + "Run it from the serial console, or use `ota update`."; + } + // `clock sync` sets the clock from the CALLER's timestamp. Web requests carry + // none (execCommand passes 0), so CommonCLI always rejects it as moving the + // clock backwards. `time ` is the one that works over this transport. + if (strncmp(cmd, "clock sync", 10) == 0) { + return "clock sync takes its time from the caller, which a web request has " + "no way to supply. Use `time ` instead."; + } + // Both write their real output to Serial and hand back a stub the terminal + // would render as success. Bare `log` also streams a whole file from the loop + // task, stalling the mesh and the radio while it does. + if (strcmp(cmd, "log") == 0) { + return "log writes the packet log to the serial console, not here, and " + "blocks the radio while it does. Use `log start` / `log stop`."; + } + if (strcmp(cmd, "get acl") == 0) { + return "get acl writes to the serial console, not here."; + } + return NULL; +} +// The `password` command echoes the new password back in its reply, and replies +// are served to the client over the open setup AP. The config path overwrites it +// by key; a CLI entry has no key, so match on the command itself. +static inline bool wcCliEchoesSecret(const char* cmd) { + return strncmp(cmd, "password ", 9) == 0; +} + +// CommonCLI splits its surface by CALLER, not by command: a serial caller +// (sender_timestamp 0, physical access) reads secrets in plaintext, while a +// remote one gets "******** (serial only)". Its own comments say so — "Serial +// only (WiFi creds grant LAN access); remote sees set/unset". +// +// execCommand passes 0, which is what makes `erase`, `stats-*` and `set freq` +// reachable from the terminal at all. Left alone, that also claims +// physical-access trust for an HTTP request: `get prv.key` would hand this +// node's identity to anyone associated with the open setup AP, and `get +// wifi.pwd` would hand over the operator's network. Reading a secret and +// writing one are not the same capability — the wizard has always been able to +// REPLACE these; nothing in the portal could ever READ them, because +// /api/config masks them (wcIsSecretKey). +// +// So the command surface stays whole and only the READ is masked, restoring the +// distinction CommonCLI intended for a caller who is not at the serial port. +// Which commands those are lives in WebConfigKeys.h (wcIsSecretReadCommand), +// beside the rest of the secret classification and host-tested with it. + +// Keep the set/unset signal, which is the useful part and what CommonCLI itself +// reports remotely; only the value goes. A getter answers "> value". +static void wcMaskSecretReply(char* reply) { + const char* val = reply; + if (val[0] == '>' && val[1] == ' ') val += 2; + const bool unset = (val[0] == 0 || strcmp(val, "(not set)") == 0); + strcpy(reply, unset ? "> (not set)" : "> ******** (serial only)"); +} +// Reply classification lives in WebConfigBatch.h with the rest of the decisions +// (WebConfigBatch::cliReplyIsFailure / cliReplyGatesReboot / cliWriteSucceeded), +// so the shapes CommonCLI actually emits are enumerated in one host-tested place. + +// Constant-time-ish comparison so login timing doesn't leak a prefix match. +static bool fixedTimeEquals(const char* a, const char* b, size_t max_len) { + size_t la = strnlen(a, max_len), lb = strnlen(b, max_len); + uint8_t diff = (la == lb) ? 0 : 1; + for (size_t i = 0; i < max_len; i++) { + char ca = (i < la) ? a[i] : 0; + char cb = (i < lb) ? b[i] : 0; + diff |= (uint8_t)(ca ^ cb); + } + return diff == 0; +} + +// RAII lock; tolerates a null mutex (allocation failure) by not locking. +struct WCLock { + SemaphoreHandle_t h; + explicit WCLock(SemaphoreHandle_t s) : h(s) { if (h) xSemaphoreTake(h, portMAX_DELAY); } + ~WCLock() { if (h) xSemaphoreGive(h); } +}; + +WebConfigServer* WebConfigServer::_active = NULL; +AsyncWebServer* WebConfigServer::_host = NULL; + +// Out-of-line definitions for the in-class-initialised constants. An in-class +// initialiser is only a declaration under C++11 (what the xtensa-esp32 +// toolchain builds with), so any use that binds a reference rather than reading +// the value — ArduinoJson takes its argument as `const T&` — needs the symbol to +// exist. Comparisons like `count >= MAX_BATCH` never did, which is why this only +// surfaced when MAX_BATCH started being reported in JSON, and then only on the +// targets where the compiler happened not to fold it. +const int WebConfigServer::MAX_BATCH; +const size_t WebConfigServer::MAX_BODY; +const uint32_t WebConfigServer::STOP_WARN_MS; + +// Protects the permanent route host's active-session pointer and handler +// references across the loop and async_tcp cores. The critical sections only +// copy a pointer/update a counter; handlers themselves never run under it. +static portMUX_TYPE s_wc_route_mux = portMUX_INITIALIZER_UNLOCKED; + +WebConfigServer::WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks, + const uint8_t* pub_key, const char* fw_ver, + const char* build_date, + const char* role, const char* board_name) + : _prefs(prefs), _obs(obs), _cb(callbacks), _pub_key(pub_key), + _fw_ver(fw_ver), _build_date(build_date), _role(role), _board_name(board_name) { + _mux = xSemaphoreCreateMutex(); +} + +WebConfigServer::~WebConfigServer() { + detachRoutes(); + if (_mux) vSemaphoreDelete(_mux); +} + +bool WebConfigServer::isRebootPending() { + WebConfigServer* w = _active; + return w != NULL && WebConfigBatch::isConfigRebootPending( + w->_reboot_at, w->_batch_reboot, + toSpecState(w->_batch_state)); +} + +bool WebConfigServer::getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len) { + WebConfigServer* w = _active; + if (w == NULL || w->_mode != MODE_SETUP || w->_stopping) return false; + if (ssid && ssid_len > 0) { + strncpy(ssid, w->_ap_ssid, ssid_len - 1); + ssid[ssid_len - 1] = 0; + } + if (ip && ip_len > 0) { + snprintf(ip, ip_len, "%s", WiFi.softAPIP().toString().c_str()); + } + return true; +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +bool WebConfigServer::startSetupMode(char reply[]) { + if (_mode != MODE_OFF || _stopping) { + strcpy(reply, "Err: webconfig busy"); + return false; + } + // AP_STA (not pure AP) so the WiFi scan for the SSID picker works while + // the AP is up. STA stays unconnected - the bridge won't touch WiFi + // while wifi_ssid is empty, and `start webconfig ap` requires it stopped. + WiFi.mode(WIFI_AP_STA); + // Setup mode serves an UNAUTHENTICATED API on the trust of physical AP + // proximity. `start webconfig ap` can be run with the STA still associated + // to the operator's LAN (MQTTBridge::end() leaves the STA link up and + // auto-reconnect on), which would expose that open API to every host on the + // LAN. Drop the association and disable auto-reconnect so only the SoftAP + // interface is reachable; the STA interface itself stays up (unassociated) + // purely so WiFi.scanNetworks() below can populate the SSID picker. + WiFi.setAutoReconnect(false); + WiFi.disconnect(false /*keep radio on*/, true /*erase stored AP so it can't reconnect*/); + snprintf(_ap_ssid, sizeof(_ap_ssid), "MeshCore-Setup-%02X%02X", _pub_key[0], _pub_key[1]); +#ifdef WEBCONFIG_AP_PASSWORD + bool ap_ok = WiFi.softAP(_ap_ssid, WEBCONFIG_AP_PASSWORD); +#else + bool ap_ok = WiFi.softAP(_ap_ssid); +#endif + if (!ap_ok) { + WiFi.mode(WIFI_OFF); + strcpy(reply, "Err: failed to start AP"); + return false; + } + delay(100); // let the AP netif settle before reading its IP + IPAddress ip = WiFi.softAPIP(); + + _dns = new DNSServer(); + _dns->start(53, "*", ip); // captive portal: every name resolves to us + + _mode = MODE_SETUP; + _initial_setup = (_obs->wifi_ssid[0] == 0); + createServer(); + _was_setup_ap = true; + _last_activity = millis(); + WiFi.scanNetworks(true); // pre-populate the SSID picker + + sprintf(reply, "WebConfig AP started: join '%s' then open http://%s/", _ap_ssid, ip.toString().c_str()); + return true; +} + +bool WebConfigServer::startLanMode(char reply[]) { + if (_mode != MODE_OFF || _stopping) { + strcpy(reply, "Err: webconfig busy"); + return false; + } + if (WiFi.status() != WL_CONNECTED) { + strcpy(reply, "Err: WiFi not connected"); + return false; + } + _mode = MODE_LAN; + createServer(); + _last_activity = millis(); + + int pos = sprintf(reply, "WebConfig started: http://%s/ (admin password login)", + WiFi.localIP().toString().c_str()); + if (heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL) < 60 * 1024) { + sprintf(reply + pos, " WARN: low heap"); + } + return true; +} + +void WebConfigServer::createServer() { + if (_host == NULL) { + _host = new AsyncWebServer(80); + _server = _host; + registerRoutes(); + } else { + _server = _host; + } + // iOS caches plain-HTTP GETs aggressively (keyed by URL, surviving even a + // device reflash behind the same IP), which poisons /api/config/result and + // friends with stale responses from earlier sessions. Forbid caching on + // every response; the HTML is small enough to refetch per visit. + // + // DefaultHeaders::Instance() is a process-lifetime singleton and addHeader() + // appends unconditionally, so registering here on every start/stop cycle + // would leak heap and stack duplicate "Cache-Control" headers on every + // response. Register exactly once for the firmware lifetime. + static bool s_default_headers_registered = false; + if (!s_default_headers_registered) { + DefaultHeaders::Instance().addHeader("Cache-Control", "no-store"); + s_default_headers_registered = true; + } + attachRoutes(); + _server->begin(); +} + +void WebConfigServer::requestStop() { + if (_mode == MODE_OFF && !_stopping) return; + // Detach first. Any request whose headers/body finish parsing after this point + // is dispatched by the permanent host as 503 and never sees this session. + detachRoutes(); + if (_server) _server->end(); + if (_dns) _dns->stop(); + _mode = MODE_OFF; + _stopping = true; + _stop_warn_at = WebConfigBatch::scheduleAt(millis(), STOP_WARN_MS); + _stop_warned = false; +} + +void WebConfigServer::finalizeTeardown() { + // `_host` and its routes are process-lifetime. AsyncWebServerRequest retains + // its server pointer until disconnect, so deleting the host here cannot be + // made safe using route-level request counts. Only the per-run session is + // reclaimed by MyMesh after this method reports completion. + // + // Design note (reverses the earlier free-on-stop approach): stop used to + // `delete _server` here once an in-flight request count hit zero, gated by a + // settle window and a hard cap. That could never be race-free - the hard cap + // freed the server even with a request still in flight, and because the + // request holds the server pointer until it disconnects, no request count can + // close that window. The disconnect UAF that motivated this file lived + // exactly there. We now keep the AsyncWebServer resident for the firmware + // lifetime (a small, bounded permanent cost: the server object + route table; + // the ~10 KB session with its _batch[] array is still reclaimed, and the + // listener socket is released by _server->end() on stop) and route requests + // through the currently attached session, so nothing a live request points at + // is ever freed. + + _server = NULL; + delete _dns; + _dns = NULL; + if (_was_setup_ap) { + WiFi.softAPdisconnect(true); + // Nothing else owns WiFi when we raised the AP: either the node is + // unconfigured, or `start webconfig ap` required the bridge stopped. + if (_obs->wifi_ssid[0] == 0) { + WiFi.mode(WIFI_OFF); + } else { + WiFi.mode(WIFI_STA); + } + _was_setup_ap = false; + } + _initial_setup = false; + _stopping = false; + _stop_warn_at = 0; + _stop_warned = false; + _reboot_at = 0; + _batch_state = BATCH_IDLE; + _batch_next = 0; + _batch_reboot_armed = false; + _session_token[0] = 0; + _stats_json[0] = 0; + if (_cb) _cb->onWebConfigStopped(); +} + +void WebConfigServer::tick(uint32_t now) { + if (_stopping) { + uint32_t refs = handlerRefCount(); + switch (WebConfigBatch::stopStep(refs, _stop_warned, _stop_warn_at, now)) { + case WebConfigBatch::StopAction::Finalize: + finalizeTeardown(); + break; + case WebConfigBatch::StopAction::Warn: + _stop_warned = true; + Serial.printf("WC: stop waiting for %lu handler(s); retaining session safely\n", + (unsigned long)refs); + break; + case WebConfigBatch::StopAction::Wait: + break; + } + return; + } + if (_mode == MODE_OFF) return; + + if (_dns) _dns->processNextRequest(); + + if (_batch_state == BATCH_PENDING) drainBatch(now); + + if (WebConfigBatch::rebootDue(_reboot_at, now)) { + Serial.printf("WC: rebooting now (%s)\n", _batch_reboot_armed ? "confirmed" : "fallback"); + _cb->rebootNow(); // does not return + } + + if ((int32_t)(_diag_until - now) > 0 && (now - _diag_last) >= 1000) { + _diag_last = now; + Serial.printf("WC: diag sta=%d heap=%u batch=%d/%d state=%d\n", + (int)WiFi.softAPgetStationNum(), (unsigned)ESP.getFreeHeap(), + (int)_batch_next, (int)_batch_count, (int)_batch_state); + } + + // Refresh the stats snapshot only while a client is actually polling. + if ((int32_t)(_stats_wanted_until - now) > 0 && (now - _stats_built_at) >= 2000) { + WCLock lock(_mux); + _cb->buildStatsJson(_stats_json, sizeof(_stats_json)); + _stats_built_at = now; + } + + // Idle timeout: only the setup AP auto-stops (a deployed node must not be + // left broadcasting an open AP). LAN mode runs until `stop webconfig`. + if (_mode == MODE_SETUP && WiFi.softAPgetStationNum() == 0 && + (now - _last_activity) > WEBCONFIG_AP_IDLE_TIMEOUT_MS) { + requestStop(); + } +} + +void WebConfigServer::drainBatch(uint32_t now) { + // One command per call, spaced out. Each `set` persists prefs with a flash + // write, and flash writes stall the WiFi task (flash cache off); running a + // whole batch back-to-back starves the softAP of beacons long enough for + // clients (iPhones especially) to drop off mid-save. + if (_batch_next == 0) { + _cb->onConfigBatchStart(); + } else if (WebConfigBatch::drainMustWait(_batch_next, _batch_count, now, _batch_last_cmd)) { + return; // let the WiFi task breathe between flash writes + } + if (_batch_next < _batch_count) { + BatchEntry& e = _batch[_batch_next++]; + e.reply[0] = 0; + uint32_t t0 = millis(); + // Hold _mux across the setter: it mutates the same _prefs/_obs strings that + // handleConfigGet() serializes on the async_tcp task, so without the lock + // the "read" side is unprotected and a GET can observe a half-written value. + // One command per tick keeps the hold brief; the flash write inside stalls + // WiFi regardless, so a concurrent GET waiting on it costs nothing extra. + { + WCLock lock(_mux); + // A CLI `reboot` is answered here rather than executed: Board::reboot() + // does not return, so running it would take the node down before the + // operator could read whether the preceding commands succeeded. The + // batch arms the ordinary deferred reboot on the first result read. + if (_batch_kind == BATCH_CLI && wcIsDeferredReboot(e.cmd)) { + // Deliberately non-committal: whether the reboot actually happens is + // not known until the whole sequence has run (it is withheld if any + // command failed), and a later command could still fail after this one. + strcpy(e.reply, "OK - reboot queued"); + } else { + _cb->execCommand(e.cmd, e.reply); + } + if (e.reply[0] == 0) strcpy(e.reply, "OK"); + // The upstream `password` command echoes the new password back in its + // reply, and replies are served to the client over the open setup AP. + // Overwrite it: the command cannot fail, so there is nothing to report. + // Config entries carry the key; a CLI entry is matched on the command. + const bool set_admin_pwd = wcIsAdminPasswordKey(e.key) || wcCliEchoesSecret(e.cmd); + if (set_admin_pwd) strcpy(e.reply, "OK"); + // Satisfies the initial-setup invariant for the rest of this session, so + // the operator can set the password and configure WiFi in separate steps + // (the form batch always sends them together and needs no such memory). + if (set_admin_pwd) _admin_pwd_set = true; + if (_batch_kind == BATCH_CLI && wcIsSecretReadCommand(e.cmd)) wcMaskSecretReply(e.reply); + // What gates the reboot is narrower than "did anything fail": only a + // write can leave the node in a config not worth rebooting into, and only + // a write has a reply convention ("OK") solid enough to test. Diagnostics + // in the sequence neither gate it nor get guessed at. + if (_batch_kind != BATCH_CLI || WebConfigBatch::cliReplyGatesReboot(e.cmd)) { + _batch_all_ok = WebConfigBatch::nextAllOk( + _batch_all_ok, WebConfigBatch::cliWriteSucceeded(e.reply)); + } + } + _batch_last_cmd = millis(); + // Config entries are named by their (non-secret) key. A CLI command is + // deliberately not logged: the operator can see what they typed, and a + // `set wifi.pwd` or `password` from the terminal must not reach the serial + // log, which is a different audience from the browser session. + if (_batch_kind == BATCH_CLI) { + Serial.printf("WC: cli %d/%d took %lums\n", (int)_batch_next, (int)_batch_count, + (unsigned long)(_batch_last_cmd - t0)); + } else { + Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count, + e.key, (unsigned long)(_batch_last_cmd - t0)); + } + if (!WebConfigBatch::drainFinished(_batch_next, _batch_count)) { + return; // more commands next tick + } + } + _cb->onConfigBatchEnd(); + WCLock lock(_mux); + _batch_state = BATCH_DONE; + // Assign only when a reboot is actually scheduled. finishRebootAt() returns 0 + // for "not scheduled", but _reboot_at is NOT solely batch-owned: the manual + // /api/reboot route can arm it from the async_tcp task while a batch is still + // draining, and an unconditional assign here would silently cancel it. + if (WebConfigBatch::finishRebootAt(_batch_reboot, _batch_all_ok, now) != 0) { + // Fallback only, and only when every command succeeded: rebooting into a + // partially-applied config would strand the node. The real 3 s reboot timer + // is armed when the client reads /api/config/result (handleConfigResult), so + // the browser gets its confirmation before the AP/WiFi drops. This covers a + // client that disconnected and never polls — generous enough for a phone + // that got bounced off the AP mid-save to rejoin and fetch its confirmation. + _reboot_at = WebConfigBatch::finishRebootAt(_batch_reboot, _batch_all_ok, now); + } +} + +// --------------------------------------------------------------------------- +// Routes / auth +// --------------------------------------------------------------------------- + +// Diagnostic trace of every request that reaches the server (async_tcp task). +// Distinguishes "client stopped sending" from "server stopped accepting" when +// a save's confirmation polls go missing on hardware. +static void wcLogReq(AsyncWebServerRequest* r) { + Serial.printf("WC: http %s %s\n", r->methodToString(), r->url().c_str()); +} + +void WebConfigServer::attachRoutes() { + portENTER_CRITICAL(&s_wc_route_mux); + _active = this; + portEXIT_CRITICAL(&s_wc_route_mux); +} + +void WebConfigServer::detachRoutes() { + portENTER_CRITICAL(&s_wc_route_mux); + if (_active == this) _active = NULL; + portEXIT_CRITICAL(&s_wc_route_mux); +} + +uint32_t WebConfigServer::handlerRefCount() const { + portENTER_CRITICAL(&s_wc_route_mux); + uint32_t refs = _handler_refs; + portEXIT_CRITICAL(&s_wc_route_mux); + return refs; +} + +void WebConfigServer::dispatchRequest(AsyncWebServerRequest* req, RequestHandler handler) { + wcLogReq(req); + WebConfigServer* target = NULL; + portENTER_CRITICAL(&s_wc_route_mux); + if (_active != NULL) { + target = _active; + target->_handler_refs++; + } + portEXIT_CRITICAL(&s_wc_route_mux); + + if (target == NULL) { + req->send(503, "application/json", "{\"error\":\"webconfig stopped\"}"); + return; + } + + (target->*handler)(req); + + portENTER_CRITICAL(&s_wc_route_mux); + if (target->_handler_refs > 0) target->_handler_refs--; + portEXIT_CRITICAL(&s_wc_route_mux); +} + +void WebConfigServer::registerRoutes() { + _server->on("/", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleRoot); }); + _server->on("/api/status", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleStatus); }); + _server->on("/api/presets", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handlePresets); }); + _server->on("/api/login", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleLogin); }, + NULL, collectBody); + _server->on("/api/logout", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleLogout); }); + // NB: plain-string routes match sub-paths too ("/api/config" matches + // "/api/config/result") and handlers run in registration order, so the more + // specific route MUST be registered first or it never fires. This was why + // save confirmations were lost: result polls were answered with config JSON. + _server->on("/api/config/result", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleConfigResult); }); + _server->on("/api/config", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleConfigGet); }); + _server->on("/api/config", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleConfigPost); }, + NULL, collectBody); + // Same specific-route-first rule as /api/config above. + _server->on("/api/cli/result", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleCliResult); }); + _server->on("/api/cli", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleCliPost); }, + NULL, collectBody); + _server->on("/api/stats", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleStats); }); + _server->on("/api/scan", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleScan); }); + _server->on("/api/reboot", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleReboot); }); + _server->on("/api/portal/exit", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handlePortalExit); }); + _server->onNotFound([](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleNotFound); }); +} + +// Accumulate a small JSON body into request->_tempObject (freed automatically +// by the request destructor). Oversized bodies are left unbuffered and +// rejected in the completion handler. +void WebConfigServer::collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len, + size_t index, size_t total) { + if (total == 0 || total > MAX_BODY) return; + if (index == 0) { + req->_tempObject = malloc(total + 1); + if (req->_tempObject) ((char*)req->_tempObject)[total] = 0; + } + if (req->_tempObject) memcpy((uint8_t*)req->_tempObject + index, data, len); +} + +bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) { + _last_activity = millis(); + if (_mode == MODE_SETUP) return true; // physical proximity implied, nothing configured + if (_mode != MODE_LAN) return false; + if (_session_token[0] == 0) return false; + if (!req->hasHeader("Cookie")) return false; + const String& cookies = req->getHeader("Cookie")->value(); + int idx = cookies.indexOf("wcs="); + if (idx < 0 || (int)cookies.length() < idx + 4 + 32) return false; + String token = cookies.substring(idx + 4, idx + 4 + 32); + uint32_t now = millis(); + if ((uint32_t)(now - _session_last_seen) > WEBCONFIG_SESSION_TTL_MS) return false; + if (!fixedTimeEquals(token.c_str(), _session_token, 32)) return false; + _session_last_seen = now; // sliding expiry + return true; +} + +// --------------------------------------------------------------------------- +// Handlers (async_tcp task - no CLI/prefs writes, no radio access) +// --------------------------------------------------------------------------- + +void WebConfigServer::handleRoot(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + _last_activity = millis(); + if (req->hasHeader("If-None-Match") && + req->getHeader("If-None-Match")->value() == WEBCONFIG_HTML_ETAG) { + req->send(304); + return; + } + AsyncWebServerResponse* res = + req->beginResponse(200, "text/html", WEBCONFIG_HTML_GZ, WEBCONFIG_HTML_GZ_LEN); + res->addHeader("Content-Encoding", "gzip"); + res->addHeader("ETag", WEBCONFIG_HTML_ETAG); + req->send(res); +} + +void WebConfigServer::handleStatus(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + bool authed = checkAuth(req); + + DynamicJsonDocument doc(512); + doc["mode"] = (_mode == MODE_SETUP) ? "setup" : "lan"; + doc["auth"] = authed; + doc["needs_setup"] = (_obs->wifi_ssid[0] == 0); + doc["name"] = (const char*)_prefs->node_name; + char node_id[17]; + for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]); + doc["node_id"] = node_id; + doc["fw"] = _fw_ver; + // The page shows a trimmed version — base + build number + channel — and + // pairs it with this, the way `ver` does. Both come from the same defines. + doc["build_date"] = _build_date; + doc["role"] = _role; + doc["board"] = _board_name; + doc["uptime_s"] = millis() / 1000; + doc["runtime_slots"] = RUNTIME_MQTT_SLOTS; + doc["max_slots"] = MAX_MQTT_SLOTS; + // Servers the UI should expose: only as many as can actually be active at + // once (2 without PSRAM, 5 with). Configuring more never connects. + doc["active_slots"] = MQTTBridge::getMaxActiveSlots(); + // Commands the terminal may submit at once. The CLI shares the config + // batch's fixed slot, so the cap is MAX_BATCH — reported rather than + // duplicated in the page, which cannot know how this build was sized. + doc["max_cmds"] = MAX_BATCH; + + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +void WebConfigServer::handleLogin(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + _last_activity = millis(); + if (_mode == MODE_SETUP) { // no auth in setup mode + req->send(200, "application/json", "{\"ok\":true}"); + return; + } + uint32_t now = millis(); + if (_login_lock_until && (int32_t)(now - _login_lock_until) < 0) { + req->send(429, "application/json", "{\"error\":\"locked, retry in 30s\"}"); + return; + } + const char* body = (const char*)req->_tempObject; + DynamicJsonDocument doc(256); + if (!body || deserializeJson(doc, body) != DeserializationError::Ok) { + req->send(400, "application/json", "{\"error\":\"bad request\"}"); + return; + } + const char* pwd = doc["password"] | ""; + if (!fixedTimeEquals(pwd, _prefs->password, sizeof(_prefs->password))) { + if (++_login_fails >= 5) { + _login_lock_until = now + 30000; + if (_login_lock_until == 0) _login_lock_until = 1; + _login_fails = 0; + } + req->send(401, "application/json", "{\"error\":\"wrong password\"}"); + return; + } + _login_fails = 0; + _login_lock_until = 0; + for (int i = 0; i < 4; i++) sprintf(&_session_token[i * 8], "%08lx", (unsigned long)esp_random()); + _session_last_seen = now; + + AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}"); + char cookie[80]; + sprintf(cookie, "wcs=%s; HttpOnly; SameSite=Lax; Path=/", _session_token); + res->addHeader("Set-Cookie", cookie); + req->send(res); +} + +void WebConfigServer::handleLogout(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + _session_token[0] = 0; + AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}"); + res->addHeader("Set-Cookie", "wcs=; Max-Age=0; Path=/"); + req->send(res); +} + +void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + + DynamicJsonDocument doc(6144); + { + WCLock lock(_mux); + + JsonObject radio = doc.createNestedObject("radio"); + // round via double so float error doesn't leak into the JSON + // (910.525f would otherwise serialize as 910.5250244) + radio["freq"] = (double)roundf(_prefs->freq * 1000.0f) / 1000.0; + radio["bw"] = (double)roundf(_prefs->bw * 100.0f) / 100.0; + radio["sf"] = _prefs->sf; + radio["cr"] = _prefs->cr; + radio["tx"] = _prefs->tx_power_dbm; + radio["af"] = _prefs->airtime_factor; + radio["rxdelay"] = _prefs->rx_delay_base; + radio["txdelay"] = _prefs->tx_delay_factor; + radio["cad"] = (bool)_prefs->cad_enabled; + radio["rxgain"] = (bool)_prefs->rx_boosted_gain; + radio["repeat"] = !(bool)_prefs->disable_fwd; // CLI `repeat on` == disable_fwd 0 + radio["flood_max"] = _prefs->flood_max; + radio["flood_max_advert"] = _prefs->flood_max_advert; + radio["flood_max_unscoped"] = _prefs->flood_max_unscoped; + static const char* const LOOP_MODES[] = { "off", "minimal", "moderate", "strict" }; + radio["loop_detect"] = LOOP_MODES[_prefs->loop_detect <= LOOP_DETECT_STRICT ? _prefs->loop_detect : 0]; + radio["name"] = (const char*)_prefs->node_name; + radio["lat"] = _prefs->node_lat; + radio["lon"] = _prefs->node_lon; + radio["advert_interval"] = _prefs->advert_interval * 2; // stored as mins/2 + radio["flood_advert_interval"] = _prefs->flood_advert_interval; // hours + + JsonObject wifi = doc.createNestedObject("wifi"); + wifi["ssid"] = (const char*)_obs->wifi_ssid; + wifi["pwd"] = _obs->wifi_password[0] ? SECRET_SENTINEL : ""; + wifi["powersave"] = _obs->wifi_power_save == 0 ? "min" + : _obs->wifi_power_save == 2 ? "max" : "none"; + + JsonObject mqtt = doc.createNestedObject("mqtt"); + mqtt["origin"] = (const char*)_obs->mqtt_origin; + mqtt["iata"] = (const char*)_obs->mqtt_iata; + mqtt["status"] = (bool)_obs->mqtt_status_enabled; + mqtt["packets"] = (bool)_obs->mqtt_packets_enabled; + mqtt["raw"] = (bool)_obs->mqtt_raw_enabled; + mqtt["tx"] = _obs->mqtt_tx_enabled == 2 ? "advert" + : _obs->mqtt_tx_enabled == 1 ? "on" : "off"; + mqtt["rx"] = (bool)_obs->mqtt_rx_enabled; + mqtt["interval"] = _obs->mqtt_status_interval / 60000; // CLI takes minutes + mqtt["neighbors"] = (bool)_obs->mqtt_neighbors_enabled; + mqtt["neighbors_interval"] = _obs->mqtt_neighbors_interval / 3600000UL; // CLI takes hours + mqtt["timezone"] = (const char*)_obs->timezone_string; + mqtt["timezone_offset"] = _obs->timezone_offset; + mqtt["ntp"] = (const char*)_obs->mqtt_ntp_server; + mqtt["owner"] = (const char*)_obs->mqtt_owner_public_key; + mqtt["email"] = (const char*)_obs->mqtt_email; + mqtt["snmp"] = (bool)_obs->snmp_enabled; + mqtt["snmp_community"] = (const char*)_obs->snmp_community; + + JsonArray slots = mqtt.createNestedArray("slots"); + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + JsonObject s = slots.createNestedObject(); + s["preset"] = (const char*)_obs->mqtt_slot_preset[i]; + s["server"] = (const char*)_obs->mqtt_slot_host[i]; + s["port"] = _obs->mqtt_slot_port[i]; + s["username"] = (const char*)_obs->mqtt_slot_username[i]; + s["password"] = _obs->mqtt_slot_password[i][0] ? SECRET_SENTINEL : ""; + s["token"] = _obs->mqtt_slot_token[i][0] ? SECRET_SENTINEL : ""; + s["topic"] = (const char*)_obs->mqtt_slot_topic[i]; + s["audience"] = (const char*)_obs->mqtt_slot_audience[i]; + char filter_text[MQTTPacketFilter::kFilterTextSize]; + if (MQTTPacketFilter::format(_obs->mqtt_slot_packet_filter[i], + filter_text, sizeof(filter_text))) { + // Mutable char input is copied into the ArduinoJson document; the + // stack buffer is reused on the next slot. + s["filter"] = filter_text; + } else { + s["filter"] = "all"; + } + } + } + + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + if (req->contentLength() > MAX_BODY) { + req->send(413, "application/json", "{\"error\":\"body too large\"}"); + return; + } + const char* body = (const char*)req->_tempObject; + DynamicJsonDocument doc(6144); + if (!body || deserializeJson(doc, body) != DeserializationError::Ok) { + req->send(400, "application/json", "{\"error\":\"bad json\"}"); + return; + } + bool reboot_after = doc["reboot"] | false; + const char* reqid = doc["reqid"] | ""; + if (!wcIsValidReqId(reqid)) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } + JsonObject set = doc["set"]; + + WCLock lock(_mux); + // A DONE batch stays readable until the next POST claims the slot, so a + // client that lost the result response can re-poll instead of failing. + // Repeating a POST with the same request ID is also idempotent: acknowledge + // the batch already occupying the slot instead of applying its commands a + // second time after an ambiguous network failure. + // Classification lives in WebConfigBatch::classifyPost. It is consulted in two + // phases because the change count is only known after the `set` map is parsed + // below, and parsing must not run before Replay/Busy are answered (a replayed + // POST carrying a bad key must still get its 202, not a 400). kCountUnknown is + // a non-zero placeholder that keeps the count-dependent arms unreachable here. + const WebConfigBatch::State bstate = toSpecState(_batch_state); + const bool reqid_matches = (strcmp(reqid, _batch_reqid) == 0); + const int kCountUnknown = 1; + const WebConfigBatch::PostOutcome pre = + WebConfigBatch::classifyPost(bstate, reqid_matches, kCountUnknown, reboot_after); + + if (pre == WebConfigBatch::PostOutcome::Replay) { + StaticJsonDocument<96> ack; + ack["state"] = WebConfigBatch::replayStateName(bstate); + ack["count"] = _batch_count; + ack["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(ack, out); + req->send(202, "application/json", out); + return; + } + if (pre == WebConfigBatch::PostOutcome::Busy) { + // Echo the in-flight batch's reqid so the caller can tell its own retry + // (same reqid — landed, keep polling) from another client's save. + StaticJsonDocument<96> bd; + bd["error"] = "busy"; + bd["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(bd, out); + req->send(409, "application/json", out); + return; + } + + // First onboarding is not complete until the known factory password has + // been replaced. Enforce this server-side so the Advanced editor or a crafted + // request cannot save WiFi and strand the node with the default password. + if (_mode == MODE_SETUP && _initial_setup && !set.containsKey("password") && + (reboot_after || set.containsKey("wifi.ssid"))) { + req->send(400, "application/json", "{\"error\":\"admin password required for initial setup\"}"); + return; + } + + int count = 0; + for (JsonPair kv : set) { + const char* key = kv.key().c_str(); + const char* val = kv.value().as(); + // The admin password is the one key outside the `set` allowlist. It is safe + // in both modes: MODE_OFF was refused above, MODE_LAN required a login to + // get here, and MODE_SETUP has physical proximity. Rotating it is why the + // portal exists — restricting it to the AP would force a bridge outage. + const bool admin_pwd = wcIsAdminPasswordKey(key); + if (!val || (!isAllowedSetKey(key) && !admin_pwd)) { + // Build with ArduinoJson so an attacker-supplied key containing quotes or + // backslashes is escaped rather than breaking out of the JSON string. + char safe_key[33]; + strncpy(safe_key, key, sizeof(safe_key) - 1); + safe_key[sizeof(safe_key) - 1] = 0; + StaticJsonDocument<128> ed; + ed["error"] = "bad key"; + ed["key"] = safe_key; + String out; + serializeJson(ed, out); + req->send(400, "application/json", out); + return; + } + if (admin_pwd && !wcIsValidAdminPassword(val)) { + req->send(400, "application/json", + "{\"error\":\"admin password must be 1-15 characters with no line breaks\"}"); + return; + } + if (isSecretKey(key) && strcmp(val, SECRET_SENTINEL) == 0) continue; // unchanged + if (count >= MAX_BATCH) { + req->send(400, "application/json", "{\"error\":\"too many changes\"}"); + return; + } + BatchEntry& e = _batch[count]; + strncpy(e.key, key, sizeof(e.key) - 1); + e.key[sizeof(e.key) - 1] = 0; + // Build the allowlisted CLI command, stripping CR/LF from the value so it + // can't smuggle in a second command. The admin password reuses the existing + // top-level `password` command, so it persists exactly as the CLI does. + int pos = admin_pwd ? snprintf(e.cmd, sizeof(e.cmd), "password ") + : snprintf(e.cmd, sizeof(e.cmd), "set %s ", key); + bool overflow = false; + for (const char* p = val; *p; p++) { + if (*p == '\r' || *p == '\n') continue; + if (pos >= (int)sizeof(e.cmd) - 1) { overflow = true; break; } + e.cmd[pos++] = *p; + } + e.cmd[pos] = 0; + // A value that doesn't fit used to be truncated here and applied anyway. + // For length-checked keys the CLI would reject the remainder, but a key + // whose grammar stays valid when clipped (mqttN.filter: "advert,2" cut to + // "advert") would silently persist a *different* setting and still answer + // OK. Refuse the batch instead — the caller can shorten and retry. + if (overflow) { + // Name the key, like the "bad key" rejection above: a 20-field batch is + // rejected whole, so without it the operator has nothing to correct. + char safe_key[33]; + strncpy(safe_key, key, sizeof(safe_key) - 1); + safe_key[sizeof(safe_key) - 1] = 0; + StaticJsonDocument<128> ed; + ed["error"] = "value too long"; + ed["key"] = safe_key; + String out; + serializeJson(ed, out); + req->send(400, "application/json", out); + return; + } + count++; + } + // Phase 2: the count is now known, so the remaining NoChanges/Accept arms + // resolve. Replay/Busy were already answered above. + if (WebConfigBatch::classifyPost(bstate, reqid_matches, count, reboot_after) == + WebConfigBatch::PostOutcome::NoChanges) { + req->send(400, "application/json", "{\"error\":\"no changes\"}"); + return; + } + _batch_kind = BATCH_CONFIG; + _batch_count = count; + _batch_next = 0; + _batch_reboot = reboot_after; + _batch_reboot_armed = false; + _batch_all_ok = true; + strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1); + _batch_reqid[sizeof(_batch_reqid) - 1] = 0; + _batch_state = BATCH_PENDING; // tick() picks it up on the loop task + uint32_t du = millis() + 60000; + if (du == 0) du = 1; + _diag_until = du; + Serial.printf("WC: config POST accepted, %d cmds, reboot=%d\n", count, (int)reboot_after); + + StaticJsonDocument<96> ack; + ack["state"] = "pending"; + ack["count"] = count; + ack["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(ack, out); + req->send(202, "application/json", out); +} + +void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { Serial.println("WC: result read -> 503 (mode off)"); req->send(503); return; } + if (!checkAuth(req)) { Serial.println("WC: result read -> 401"); req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + if (!req->hasParam("reqid")) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } + String requested_reqid = req->getParam("reqid")->value(); + if (!wcIsValidReqId(requested_reqid.c_str())) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } + + // Entry print BEFORE the lock (racy state read is fine for diag): if this + // fires but no branch print follows, the handler is blocked on _mux. + Serial.printf("WC: result entry mode=%d state=%d\n", (int)_mode, (int)_batch_state); + WCLock lock(_mux); + // A CLI sequence occupying the shared slot is not a config save, whatever the + // reqid says: its entries have no `key` and its results belong to the + // terminal's reader. Treat it as unknown here (and vice versa there). + const bool mine = (_batch_kind == BATCH_CONFIG) && + (strcmp(requested_reqid.c_str(), _batch_reqid) == 0); + const WebConfigBatch::ResultOutcome outcome = + WebConfigBatch::classifyResult(toSpecState(_batch_state), mine); + if (outcome == WebConfigBatch::ResultOutcome::Idle) { + Serial.println("WC: result read -> idle"); + StaticJsonDocument<64> idle; + idle["state"] = "idle"; + idle["reqid"] = requested_reqid; + String out; + serializeJson(idle, out); + req->send(200, "application/json", out); + return; + } + if (outcome == WebConfigBatch::ResultOutcome::Unknown) { + req->send(404, "application/json", "{\"error\":\"unknown request\"}"); + return; + } + if (outcome == WebConfigBatch::ResultOutcome::Pending) { + StaticJsonDocument<96> pd; + pd["state"] = "pending"; + pd["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(pd, out); + req->send(200, "application/json", out); + return; + } + Serial.printf("WC: result read -> done (reboot=%d armed=%d all_ok=%d)\n", + (int)_batch_reboot, (int)_batch_reboot_armed, (int)_batch_all_ok); + DynamicJsonDocument doc(6144); + doc["state"] = "done"; + // Only advertise a reboot when it will actually happen: a partially-failed + // batch is not rebooted (see below), so the UI must not show a reboot screen. + doc["reboot"] = WebConfigBatch::doneReportsReboot(_batch_reboot, _batch_all_ok); + doc["all_ok"] = _batch_all_ok; + doc["reqid"] = (const char*)_batch_reqid; + JsonArray results = doc.createNestedArray("results"); + for (int i = 0; i < _batch_count; i++) { + JsonObject r = results.createNestedObject(); + r["key"] = (const char*)_batch[i].key; + r["reply"] = (const char*)_batch[i].reply; + } + // State stays DONE (re-readable) until the next POST claims the slot. + if (WebConfigBatch::shouldArmConfirmReboot(toSpecState(_batch_state), _batch_reboot, + _batch_all_ok, _batch_reboot_armed)) { + // Confirmation delivered and every command succeeded — reboot 3 s from now + // (replaces the 30 s drain-time fallback) so the UI can show its countdown + // first. Armed once; re-reads must not keep pushing the deadline out. A + // partially-failed batch is deliberately left running so the operator can + // correct and retry instead of rebooting into a broken config. + _batch_reboot_armed = true; + _reboot_at = WebConfigBatch::confirmRebootAt(millis()); + } + + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +// --------------------------------------------------------------------------- +// CLI terminal (/api/cli). Same 202 + reqid + poll contract as a config save, +// and for the same reason: CommonCLI touches prefs, the radio and the +// filesystem, none of which may be reached from the async_tcp task. The +// commands go into the shared deferred slot and tick() drains them. +// +// Unlike a save this is NOT allowlisted. That is the point: the terminal exists +// to reach what the serial console reaches, and execCommand() passes +// sender_timestamp 0, so it gets the same local privilege the serial console +// has. Authentication is the boundary — as it is for serial (physical access) +// and for remote admin over the mesh (the admin password). +// --------------------------------------------------------------------------- + +// Commands whose CLI handler never returns would take the node down mid-drain, +// before the client could read a single result. `reboot` is deferred instead: +// it is not passed to the CLI at all, and the batch arms the ordinary reboot +// path once the operator has read the results. The rest (clkreboot, poweroff, +// ota update) do real work on the way down and cannot be faked, so they run +// normally and the connection drops — the UI warns before sending them. +void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + if (req->_tempObject == NULL) { + req->send(413, "application/json", "{\"error\":\"body too large\"}"); + return; + } + const char* body = (const char*)req->_tempObject; + DynamicJsonDocument doc(6144); + if (!body || deserializeJson(doc, body) != DeserializationError::Ok) { + req->send(400, "application/json", "{\"error\":\"bad json\"}"); + return; + } + const char* reqid = doc["reqid"] | ""; + if (!wcIsValidReqId(reqid)) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } + JsonArray cmds = doc["cmds"]; + if (cmds.isNull()) { + req->send(400, "application/json", "{\"error\":\"no commands\"}"); + return; + } + + WCLock lock(_mux); + // Replay/Busy exactly as a config save classifies them: a repeated POST is + // acknowledged rather than executed twice, and a different sequence while one + // is still draining is refused. + const WebConfigBatch::State bstate = toSpecState(_batch_state); + const bool reqid_matches = (strcmp(reqid, _batch_reqid) == 0); + const WebConfigBatch::PostOutcome pre = + WebConfigBatch::classifyPost(bstate, reqid_matches, 1 /* count unknown yet */, false); + if (pre == WebConfigBatch::PostOutcome::Replay) { + StaticJsonDocument<96> ack; + ack["state"] = (bstate == WebConfigBatch::State::Done) ? "done" : "running"; + ack["total"] = _batch_count; + ack["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(ack, out); + req->send(202, "application/json", out); + return; + } + if (pre == WebConfigBatch::PostOutcome::Busy) { + StaticJsonDocument<96> bd; + bd["error"] = "busy"; + bd["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(bd, out); + req->send(409, "application/json", out); + return; + } + + int count = 0; + bool defer_reboot = false, seq_sets_pwd = false, seq_sets_ssid = false; + for (JsonVariant v : cmds) { + const char* raw = v.as(); + if (!raw) continue; + if (count >= MAX_BATCH) { + StaticJsonDocument<96> ed; + ed["error"] = "too many commands"; + ed["max"] = MAX_BATCH; + String out; + serializeJson(ed, out); + req->send(413, "application/json", out); + return; + } + // Strip CR/LF so one entry cannot smuggle a second command past the + // operator's confirmation, and skip whatever is left blank. + BatchEntry& e = _batch[count]; + int pos = 0; + for (const char* p = raw; *p; p++) { + if (*p == '\r' || *p == '\n') continue; + if (pos == 0 && (*p == ' ' || *p == '\t')) continue; // leading space + if (pos >= (int)sizeof(e.cmd) - 1) { + req->send(400, "application/json", "{\"error\":\"command too long\"}"); + return; + } + e.cmd[pos++] = *p; + } + while (pos > 0 && (e.cmd[pos - 1] == ' ' || e.cmd[pos - 1] == '\t')) pos--; + e.cmd[pos] = 0; + if (pos == 0) continue; + // Reject before anything runs, so a sequence never half-applies and then + // stops on a command that was never going to work here. + const char* why = wcCliUnavailable(e.cmd); + if (why) { + StaticJsonDocument<256> ed; + ed["error"] = why; + String out; + serializeJson(ed, out); + req->send(400, "application/json", out); + return; + } + e.key[0] = 0; // CLI entries have no config key + if (wcIsDeferredReboot(e.cmd)) defer_reboot = true; + if (strncmp(e.cmd, "password ", 9) == 0) seq_sets_pwd = true; + if (strncmp(e.cmd, "set wifi.ssid ", 14) == 0) seq_sets_ssid = true; + count++; + } + if (count == 0) { + req->send(400, "application/json", "{\"error\":\"no commands\"}"); + return; + } + + // The same invariant handleConfigPost enforces, and for the same reason: the + // reboot is what commits first onboarding, and a node that reboots onto the + // LAN still holding the factory password is a known credential on someone + // else's network. The terminal warned about this client-side, which is a + // reminder, not a rule — a pasted script or a direct POST ignored it. + if (_mode == MODE_SETUP && _initial_setup && !seq_sets_pwd && !_admin_pwd_set && + (defer_reboot || seq_sets_ssid)) { + req->send(400, "application/json", + "{\"error\":\"admin password required for initial setup — " + "run `password ` first\"}"); + return; + } + + _batch_kind = BATCH_CLI; + _batch_count = count; + _batch_next = 0; + _batch_reboot = defer_reboot; + _batch_reboot_armed = false; + _batch_all_ok = true; + strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1); + _batch_reqid[sizeof(_batch_reqid) - 1] = 0; + _batch_state = BATCH_PENDING; // tick() picks it up on the loop task + Serial.printf("WC: cli POST accepted, %d cmds, reboot=%d\n", count, (int)defer_reboot); + + StaticJsonDocument<96> ack; + ack["state"] = "running"; + ack["total"] = count; + ack["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(ack, out); + req->send(202, "application/json", out); +} + +void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + if (!req->hasParam("reqid")) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } + String requested_reqid = req->getParam("reqid")->value(); + if (!wcIsValidReqId(requested_reqid.c_str())) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } + int from = 0; + if (req->hasParam("from")) { + from = req->getParam("from")->value().toInt(); + if (from < 0) from = 0; + } + + WCLock lock(_mux); + // A config save occupying the slot is not this client's sequence, whatever + // the reqid says; treat it as unknown rather than serving `set` results + // through the terminal's reader. + const bool mine = (_batch_kind == BATCH_CLI) && + (strcmp(requested_reqid.c_str(), _batch_reqid) == 0); + const WebConfigBatch::ResultOutcome outcome = + WebConfigBatch::classifyResult(toSpecState(_batch_state), mine); + if (outcome == WebConfigBatch::ResultOutcome::Idle) { + StaticJsonDocument<64> idle; + idle["state"] = "idle"; + idle["reqid"] = requested_reqid; + String out; + serializeJson(idle, out); + req->send(200, "application/json", out); + return; + } + if (outcome == WebConfigBatch::ResultOutcome::Unknown) { + req->send(404, "application/json", "{\"error\":\"unknown request\"}"); + return; + } + + // Results stream: hand back whatever has drained since the client's cursor, + // capped so the document stays small on the async_tcp task. + const int produced = _batch_next; + const int page = WebConfigBatch::cliPageCount(from, produced, WebConfigBatch::kCliResultPage); + const bool final_read = WebConfigBatch::cliReadIsFinal(toSpecState(_batch_state), + from, page, _batch_count); + DynamicJsonDocument doc(4096); + doc["state"] = final_read ? "done" : "running"; + doc["reqid"] = (const char*)_batch_reqid; + doc["total"] = _batch_count; + doc["from"] = from; + JsonArray results = doc.createNestedArray("results"); + for (int i = from; i < from + page; i++) { + JsonObject r = results.createNestedObject(); + // The command is deliberately NOT echoed: it may hold a password or token, + // and the client already has the sequence it sent. It matches by index. + r["ok"] = !WebConfigBatch::cliReplyIsFailure(_batch[i].reply); + r["reply"] = (const char*)_batch[i].reply; + } + if (final_read) { + doc["all_ok"] = _batch_all_ok; + const bool rebooting = WebConfigBatch::cliRebootAllowed(_batch_reboot, _batch_all_ok); + doc["reboot"] = rebooting; + // Tell the operator why a `reboot` they asked for is not happening. + if (_batch_reboot && !_batch_all_ok) doc["reboot_withheld"] = true; + if (WebConfigBatch::shouldArmConfirmReboot(toSpecState(_batch_state), _batch_reboot, + _batch_all_ok, _batch_reboot_armed)) { + _batch_reboot_armed = true; + _reboot_at = WebConfigBatch::confirmRebootAt(millis()); + } + } + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +void WebConfigServer::handleStats(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + uint32_t until = millis() + 15000; + if (until == 0) until = 1; + _stats_wanted_until = until; // tick() refreshes the snapshot while polled + + WCLock lock(_mux); + if (_stats_json[0] == 0) { + req->send(200, "application/json", "{\"state\":\"pending\"}"); + return; + } + req->send(200, "application/json", _stats_json); +} + +void WebConfigServer::handleScan(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + + int n = WiFi.scanComplete(); + if (req->hasParam("rescan") && n >= 0) { + WiFi.scanDelete(); + n = WIFI_SCAN_FAILED; + } + if (n == WIFI_SCAN_FAILED) { + WiFi.scanNetworks(true); + req->send(200, "application/json", "{\"state\":\"scanning\"}"); + return; + } + if (n < 0) { // WIFI_SCAN_RUNNING + req->send(200, "application/json", "{\"state\":\"scanning\"}"); + return; + } + DynamicJsonDocument doc(3072); + doc["state"] = "done"; + JsonArray nets = doc.createNestedArray("networks"); + for (int i = 0; i < n && i < 20; i++) { + JsonObject net = nets.createNestedObject(); + net["ssid"] = WiFi.SSID(i); + net["rssi"] = WiFi.RSSI(i); + net["enc"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN; + } + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +void WebConfigServer::handlePresets(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + _last_activity = millis(); + DynamicJsonDocument doc(3072); + JsonArray arr = doc.createNestedArray("presets"); + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + const MQTTPresetDef& p = MQTT_PRESETS[i]; + JsonObject o = arr.createNestedObject(); + o["name"] = p.name; + // What the UI must collect for this preset to connect + if (p.topic_style == MQTT_TOPIC_MESHRANK) { + o["needs"] = "token"; + } else if (mqttPresetNeedsSlotUsername(&p) && mqttPresetNeedsSlotPassword(&p)) { + o["needs"] = "userpass"; + } else if (mqttPresetNeedsSlotPassword(&p)) { + o["needs"] = "password"; + } else if (mqttPresetNeedsSlotUsername(&p)) { + o["needs"] = "userpass"; + } else { + o["needs"] = "none"; + } + } + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + +void WebConfigServer::handleReboot(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + _reboot_at = millis() + 1500; + if (_reboot_at == 0) _reboot_at = 1; + req->send(200, "application/json", "{\"ok\":true}"); +} + +void WebConfigServer::handlePortalExit(AsyncWebServerRequest* req) { + // Setup mode only: switch captive probes to native "success" replies so the + // OS sign-in sheet can be dismissed (iOS: "Done") without dropping the WiFi; + // the user then continues at http:/// in their real browser, + // which survives the phone sleeping (the captive sheet does not). + if (_mode != MODE_SETUP) { req->send(404); return; } + _captive_release = true; + _last_activity = millis(); + char body[64]; + snprintf(body, sizeof(body), "{\"ok\":true,\"url\":\"http://%s/\"}", + WiFi.softAPIP().toString().c_str()); + req->send(200, "application/json", body); +} + +void WebConfigServer::handleNotFound(AsyncWebServerRequest* req) { + // Captive-portal probes (/generate_204, /hotspot-detect.html, /ncsi.txt, + // /connecttest.txt, ...) all land here; a redirect to the portal makes the + // phone pop its sign-in sheet. + if (_mode == MODE_SETUP && req->method() == HTTP_GET) { + if (_captive_release) { + // Answer each OS's connectivity check natively so the sheet reports + // success and can be closed. Deliberately does NOT bump _last_activity: + // background probes must not hold the portal open past the idle timeout. + const String& url = req->url(); + if (url.indexOf("generate_204") >= 0 || url.indexOf("gen_204") >= 0) { + req->send(204); // Android + } else if (url.indexOf("hotspot-detect") >= 0 || url.indexOf("success") >= 0) { + req->send(200, "text/html", // Apple CNA + "SuccessSuccess"); + } else if (url.indexOf("ncsi.txt") >= 0) { + req->send(200, "text/plain", "Microsoft NCSI"); // Windows + } else if (url.indexOf("connecttest.txt") >= 0) { + req->send(200, "text/plain", "Microsoft Connect Test"); + } else { + req->send(404); + } + return; + } + _last_activity = millis(); + req->redirect(String("http://") + WiFi.softAPIP().toString() + "/"); + return; + } + req->send(404); +} + +#endif // ESP_PLATFORM && WITH_MQTT_BRIDGE diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h new file mode 100644 index 000000000..0dcebebd4 --- /dev/null +++ b/src/helpers/esp32/WebConfigServer.h @@ -0,0 +1,223 @@ +#pragma once + +// Browser-based configuration portal for ESP32 MQTT observer builds. +// +// Two modes: +// - SETUP: open SoftAP + captive portal, raised automatically on first boot +// when no WiFi is configured (wifi_ssid empty), or manually via +// `start webconfig ap`. Save -> reboot; auto-stops after an idle timeout. +// - LAN: bound to the existing STA connection (owned by the MQTT bridge +// task), started via `start webconfig`, admin-password login required. +// +// Concurrency model: AsyncWebServer handlers run on the async_tcp task and +// must never touch the CLI, prefs persistence, or the radio. Config writes +// are marshaled into a single-slot command batch that tick() - called from +// MyMesh::loop() on the Arduino loop task - drains through the existing CLI +// `set` handlers. Prefs-struct reads and batch state are guarded by _mux. +// The HTTP server/routes live for the firmware lifetime; routes acquire the +// currently attached WebConfigServer session only for the synchronous handler +// call. This lets a stopped session be reclaimed without deleting an +// AsyncWebServer that may still be referenced by a partially received request. +// +// No persisted state: everything here is RAM-only, so prefs-file layouts +// (fleet-critical) are untouched. + +#if defined(ESP_PLATFORM) && defined(WITH_MQTT_BRIDGE) + +#define WITH_WEBCONFIG 1 + +#include +#include +#include +#include + +class AsyncWebServer; +class AsyncWebServerRequest; +class DNSServer; +struct NodePrefs; +struct MQTTPrefs; + +#ifndef WEBCONFIG_AP_IDLE_TIMEOUT_MS + #define WEBCONFIG_AP_IDLE_TIMEOUT_MS (10UL * 60UL * 1000UL) +#endif +#ifndef WEBCONFIG_SESSION_TTL_MS + #define WEBCONFIG_SESSION_TTL_MS (20UL * 60UL * 1000UL) +#endif + +class WebConfigServer { +public: + enum Mode : uint8_t { MODE_OFF = 0, MODE_SETUP, MODE_LAN }; + + class Callbacks { + public: + // Run one CLI command. Called from tick() only (loop task); reply is 160 bytes. + virtual void execCommand(char* cmd, char* reply) = 0; + virtual void rebootNow() = 0; + // Bracket a config batch so bridge restarts triggered by individual + // `set` handlers can be coalesced into one. + virtual void onConfigBatchStart() {} + virtual void onConfigBatchEnd() {} + // Fill buf with the stats JSON snapshot. Called from tick() (loop task). + virtual void buildStatsJson(char* buf, size_t buf_size) = 0; + // Teardown finished (session + DNS freed, WiFi mode restored). + virtual void onWebConfigStopped() {} + }; + + WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks, + const uint8_t* pub_key, const char* fw_ver, const char* build_date, + const char* role, const char* board_name); + ~WebConfigServer(); + + // For the device display: true while a setup-mode portal is active. + // Fills the AP SSID and portal IP; either buffer may be NULL to just poll. + // Call from the loop task only (same task that changes the mode). + static bool getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len); + + // For the device display: true once a config save completed and the node is + // about to reboot — ground truth for the user even if the browser lost its + // connection before the confirmation arrived. Loop task only. + static bool isRebootPending(); + + bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal + bool startLanMode(char reply[]); // bind to existing STA connection + void requestStop(); // stop listening and detach this session + void tick(uint32_t now); // call every loop iteration + + Mode mode() const { return _mode; } + bool isRunning() const { return _mode != MODE_OFF; } + bool isStopping() const { return _stopping; } + +private: + // Batch/reboot/stop decisions and timings live in the fork-owned pure spec + // WebConfigBatch.h (host-tested by test/test_webconfig_batch). These aliases + // keep a single source of truth so the spec and this server cannot drift. + static const int MAX_BATCH = WebConfigBatch::kMaxBatch; + static const size_t MAX_BODY = 4096; + // A detached session normally drains immediately because handlers are short. + // If one does not, keep the session alive (safe) and emit a diagnostic rather + // than freeing memory still referenced by the async task. + static const uint32_t STOP_WARN_MS = WebConfigBatch::kStopWarnMs; + enum BatchState : uint8_t { BATCH_IDLE = 0, BATCH_PENDING, BATCH_DONE }; + // What filled the shared slot. A config save comes from allowlisted form + // fields; a CLI sequence is arbitrary commands typed into the terminal. They + // share the slot (see WebConfigBatch.h) but differ in how results are read + // and in whether `key` means anything, so every reader checks the kind. + enum BatchKind : uint8_t { BATCH_CONFIG = 0, BATCH_CLI }; + + // BatchState and WebConfigBatch::State are deliberately kept as separate + // types (the enum is stored in a volatile member and used in prints); this + // is the one conversion point. + static WebConfigBatch::State toSpecState(BatchState s) { + switch (s) { + case BATCH_PENDING: return WebConfigBatch::State::Pending; + case BATCH_DONE: return WebConfigBatch::State::Done; + default: return WebConfigBatch::State::Idle; + } + } + struct BatchEntry { + char key[24]; // allowlisted config key (echoed back to the UI); empty for CLI entries + char cmd[160]; // full CLI command (may contain secrets - never echoed) + char reply[160]; // CLI reply budget, same 160 bytes the serial console gets + }; + + NodePrefs* _prefs; + MQTTPrefs* _obs; + Callbacks* _cb; + const uint8_t* _pub_key; + const char* _fw_ver; + const char* _build_date; + const char* _role; + const char* _board_name; + + AsyncWebServer* _server = NULL; + DNSServer* _dns = NULL; + SemaphoreHandle_t _mux; + Mode _mode = MODE_OFF; + bool _stopping = false; + bool _was_setup_ap = false; + bool _initial_setup = false; + // A `password` command has succeeded this session. Lets the CLI satisfy the + // initial-setup invariant across separate submissions; the form batch always + // sends the password with the rest, so it never needed the memory. + bool _admin_pwd_set = false; + char _ap_ssid[33] = {0}; + + // Currently attached session, also used by the display's setup-info poll. + static WebConfigServer* _active; + // Process-lifetime listener and route table. Requests retain a pointer to the + // AsyncWebServer internally until disconnect, so this object is deliberately + // never deleted during normal firmware operation. + static AsyncWebServer* _host; + + // Command batch: filled by async_tcp under _mux, drained by tick(). + volatile BatchState _batch_state = BATCH_IDLE; + volatile BatchKind _batch_kind = BATCH_CONFIG; + uint8_t _batch_count = 0; + uint8_t _batch_next = 0; // drain progress (one command per tick) + uint32_t _batch_last_cmd = 0; + bool _batch_reboot = false; + bool _batch_reboot_armed = false; + bool _batch_all_ok = true; // every drained command replied "OK" (gates reboot) + // Client-supplied batch identity, echoed in the 202/result/409 responses so a + // reused/lost/concurrent result can't be mistaken for this client's own. + char _batch_reqid[24] = {0}; + BatchEntry _batch[MAX_BATCH]; + + // LAN-mode session (single slot; new login evicts the old session) + char _session_token[33] = {0}; + uint32_t _session_last_seen = 0; + uint8_t _login_fails = 0; + uint32_t _login_lock_until = 0; + + // Once set (via /api/portal/exit), OS captive probes get native "success" + // replies so the phone's sign-in sheet can be dismissed without dropping the + // WiFi, letting the user continue in their real browser. async_tcp-task only. + volatile bool _captive_release = false; + + // Save-path diagnostics: 1 Hz serial trace for 60 s after each config POST + // (AP station count, heap, batch state) to pinpoint client drops on hardware. + volatile uint32_t _diag_until = 0; + uint32_t _diag_last = 0; + + volatile uint32_t _last_activity = 0; + uint32_t _reboot_at = 0; // 0 = none scheduled + uint32_t _stop_warn_at = 0; + bool _stop_warned = false; + // Number of synchronous route handlers currently using this session. Access + // is serialized by the file-local route spinlock in WebConfigServer.cpp. + uint32_t _handler_refs = 0; + volatile uint32_t _stats_wanted_until = 0; + uint32_t _stats_built_at = 0; + char _stats_json[1024] = {0}; + + void createServer(); + void registerRoutes(); + typedef void (WebConfigServer::*RequestHandler)(AsyncWebServerRequest*); + static void dispatchRequest(AsyncWebServerRequest* req, RequestHandler handler); + void attachRoutes(); + void detachRoutes(); + uint32_t handlerRefCount() const; + void drainBatch(uint32_t now); + void finalizeTeardown(); + bool checkAuth(AsyncWebServerRequest* req); + static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len, + size_t index, size_t total); + + void handleRoot(AsyncWebServerRequest* req); + void handleStatus(AsyncWebServerRequest* req); + void handleLogin(AsyncWebServerRequest* req); + void handleLogout(AsyncWebServerRequest* req); + void handleConfigGet(AsyncWebServerRequest* req); + void handleConfigPost(AsyncWebServerRequest* req); + void handleConfigResult(AsyncWebServerRequest* req); + void handleCliPost(AsyncWebServerRequest* req); + void handleCliResult(AsyncWebServerRequest* req); + void handleStats(AsyncWebServerRequest* req); + void handleScan(AsyncWebServerRequest* req); + void handlePresets(AsyncWebServerRequest* req); + void handleReboot(AsyncWebServerRequest* req); + void handlePortalExit(AsyncWebServerRequest* req); + void handleNotFound(AsyncWebServerRequest* req); +}; + +#endif // ESP_PLATFORM && WITH_MQTT_BRIDGE diff --git a/src/helpers/ethernet/EthernetInterface.h b/src/helpers/ethernet/EthernetInterface.h new file mode 100644 index 000000000..4e1f9176a --- /dev/null +++ b/src/helpers/ethernet/EthernetInterface.h @@ -0,0 +1,11 @@ +#pragma once + +#if defined(ETHERNET_ENABLED) + #if defined(ETHERNET_USE_CH390) + #include "helpers/ethernet/ch390/CH390EthernetInterface.h" + #elif defined(ETHERNET_USE_RAK13800) + #include "helpers/ethernet/RAK13800/RAK13800EthernetInterface.h" + #else + #error "ETHERNET_ENABLED is defined, but no specific driver flag (e.g. ETHERNET_USE_CH390) was provided!" + #endif +#endif diff --git a/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.cpp b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.cpp new file mode 100644 index 000000000..027581e30 --- /dev/null +++ b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.cpp @@ -0,0 +1,109 @@ +#include "RAK13800EthernetInterface.h" +#include "../../nrf52/EthernetMac.h" +#include +#include + +#define PIN_SPI1_MISO (29) // (0 + 29) +#define PIN_SPI1_MOSI (30) // (0 + 30) +#define PIN_SPI1_SCK (3) // (0 + 3) + +SPIClass ETHERNET_SPI_PORT(NRF_SPIM1, PIN_SPI1_MISO, PIN_SPI1_SCK, PIN_SPI1_MOSI); + +#define PIN_ETHERNET_POWER_EN WB_IO2 // output, high to enable +#define PIN_ETHERNET_RESET 21 +#define PIN_ETHERNET_SS 26 + +bool RAK13800EthernetInterface::begin() { + + // WB_IO2 (power enable) is already driven HIGH by early constructor + // in RAK4631Board.cpp to support POE boot. + // Skip hardware reset — the W5100S comes out of power-on reset cleanly, + // and toggling reset kills the PHY link which breaks POE power. +#ifdef PIN_ETHERNET_RESET + pinMode(PIN_ETHERNET_RESET, OUTPUT); + digitalWrite(PIN_ETHERNET_RESET, HIGH); +#endif + + // generate mac address + uint8_t mac[6]; + generateEthernetMac(mac); + ETHERNET_DEBUG_PRINTLN( + "Ethernet MAC: %02X:%02X:%02X:%02X:%02X:%02X", + mac[0], + mac[1], + mac[2], + mac[3], + mac[4], + mac[5]); + ETHERNET_SPI_PORT.begin(); + Ethernet.init(ETHERNET_SPI_PORT, PIN_ETHERNET_SS); + + // Use static IP if build flags are defined, otherwise DHCP + #if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) && defined(ETHERNET_STATIC_DNS) + IPAddress ip(ETHERNET_STATIC_IP); + IPAddress gateway(ETHERNET_STATIC_GATEWAY); + IPAddress subnet(ETHERNET_STATIC_SUBNET); + IPAddress dns(ETHERNET_STATIC_DNS); + Ethernet.begin(mac, ip, dns, gateway, subnet); + #else + if (Ethernet.begin(mac) == 0) { + ETHERNET_DEBUG_PRINTLN("Failed to initialize RAK13800 hardware."); + if (Ethernet.hardwareStatus() == EthernetNoHardware) { + ETHERNET_DEBUG_PRINTLN("Ethernet hardware not found."); + } else if (Ethernet.linkStatus() == LinkOFF) { + ETHERNET_DEBUG_PRINTLN("Ethernet cable not connected."); + } else { + ETHERNET_DEBUG_PRINTLN("DHCP failed for unknown reason."); + } + return false; + } + #endif + + ETHERNET_DEBUG_PRINTLN("Ethernet begin complete"); + ETHERNET_DEBUG_PRINT_IP("IP Address", Ethernet.localIP()); + ETHERNET_DEBUG_PRINT_IP("Subnet Mask", Ethernet.subnetMask()); + ETHERNET_DEBUG_PRINT_IP("Gateway", Ethernet.gatewayIP()); + ETHERNET_DEBUG_PRINT_IP("DNS", Ethernet.dnsServerIP()); + + server.begin(); + ETHERNET_DEBUG_PRINTLN("listening on TCP port: %d", ETHERNET_TCP_PORT); + + return true; +} + +int RAK13800EthernetInterface::available() { + return client.available(); +} + +int RAK13800EthernetInterface::read() { + return client.read(); +} + +size_t RAK13800EthernetInterface::write(const uint8_t *buf, size_t size) { + return client.write(buf, size); +} + +bool RAK13800EthernetInterface::isConnected() const { + return _isConnected; +} + +void RAK13800EthernetInterface::loop() { + + Ethernet.maintain(); + + auto newClient = server.accept(); + if (newClient) { + IPAddress remoteIp = newClient.remoteIP(); + uint16_t remotePort = newClient.remotePort(); + ETHERNET_DEBUG_PRINTLN("New client accepted %u.%u.%u.%u:%u", remoteIp[0], remoteIp[1], remoteIp[2], remoteIp[3], remotePort); + if (client) { + ETHERNET_DEBUG_PRINTLN("Closing previous client"); + client.stop(); + } + client = newClient; + onClientConnected(); + } + + _isConnected = client.connected(); + +} diff --git a/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.h b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.h new file mode 100644 index 000000000..34bb75877 --- /dev/null +++ b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.h @@ -0,0 +1,27 @@ +#pragma once + +#include "../SerialEthernetInterface.h" +#include +#include + +class RAK13800EthernetInterface : public SerialEthernetInterface { + + bool _isConnected; + EthernetServer server; + EthernetClient client; + + public: + RAK13800EthernetInterface() : server(EthernetServer(ETHERNET_TCP_PORT)) { + _isConnected = false; + } + + bool begin(); + void loop() override; + + // BaseSerialInterface methods + bool isConnected() const override; + + int available() override; + int read() override; + size_t write(const uint8_t *buf, size_t size) override; +}; diff --git a/src/helpers/ethernet/SerialEthernetInterface.cpp b/src/helpers/ethernet/SerialEthernetInterface.cpp new file mode 100644 index 000000000..22bcabc10 --- /dev/null +++ b/src/helpers/ethernet/SerialEthernetInterface.cpp @@ -0,0 +1,140 @@ +#include "SerialEthernetInterface.h" + +#define RECV_STATE_IDLE 0 +#define RECV_STATE_HDR_FOUND 1 +#define RECV_STATE_LEN1_FOUND 2 +#define RECV_STATE_LEN2_FOUND 3 + +bool SerialEthernetInterface::begin() { + return true; +} + +void SerialEthernetInterface::enable() { + if (_isEnabled) return; + _isEnabled = true; + clearBuffers(); +} + +void SerialEthernetInterface::disable() { + _isEnabled = false; +} + +size_t SerialEthernetInterface::writeFrame(const uint8_t src[], size_t len) { + if (len > MAX_FRAME_SIZE) { + ETHERNET_DEBUG_PRINTLN("writeFrame(), frame too big, len=%d\n", len); + return 0; + } + + if (isConnected() && len > 0) { + if (send_queue_len >= FRAME_QUEUE_SIZE) { + ETHERNET_DEBUG_PRINTLN("writeFrame(), send_queue is full!"); + return 0; + } + + send_queue[send_queue_len].len = len; // add to send queue + memcpy(send_queue[send_queue_len].buf, src, len); + send_queue_len++; + + return len; + } + return 0; +} + +bool SerialEthernetInterface::isWriteBusy() const { + return false; +} + +void SerialEthernetInterface::onClientConnected() { + _state = RECV_STATE_IDLE; + _frame_len = 0; + _rx_len = 0; +} + +size_t SerialEthernetInterface::checkRecvFrame(uint8_t dest[]) { + + if (isConnected()) { + if (send_queue_len > 0) { // first, check send queue + + _last_write = millis(); + int len = send_queue[0].len; + +#if ETHERNET_RAW_LINE + ETHERNET_DEBUG_PRINTLN("TX line len=%d", len); + client.write(send_queue[0].buf, len); + client.write("\r\n", 2); +#else + uint8_t pkt[3+len]; // use same header as serial interface so client can delimit frames + pkt[0] = '>'; + pkt[1] = (len & 0xFF); // LSB + pkt[2] = (len >> 8); // MSB + memcpy(&pkt[3], send_queue[0].buf, send_queue[0].len); + ETHERNET_DEBUG_PRINTLN("Sending frame len=%d", len); + #if ETHERNET_DEBUG_LOGGING && ARDUINO + ETHERNET_DEBUG_PRINTLN("TX frame len=%d", len); + #endif + write(pkt, 3 + len); +#endif + send_queue_len--; + for (int i = 0; i < send_queue_len; i++) { // delete top item from queue + send_queue[i] = send_queue[i + 1]; + } + } else { + while (available()) { + int c = read(); + if (c < 0) break; + +#if ETHERNET_RAW_LINE + if (c == '\r' || c == '\n') { + if (_rx_len == 0) { + continue; + } + uint16_t out_len = _rx_len; + if (out_len > MAX_FRAME_SIZE) out_len = MAX_FRAME_SIZE; + memcpy(dest, _rx_buf, out_len); + _rx_len = 0; + return out_len; + } + if (_rx_len < MAX_FRAME_SIZE) { + _rx_buf[_rx_len] = (uint8_t)c; + _rx_len++; + } +#else + switch (_state) { + case RECV_STATE_IDLE: + if (c == '<') { + _state = RECV_STATE_HDR_FOUND; + } + break; + case RECV_STATE_HDR_FOUND: + _frame_len = (uint8_t)c; + _state = RECV_STATE_LEN1_FOUND; + break; + case RECV_STATE_LEN1_FOUND: + _frame_len |= ((uint16_t)c) << 8; + _rx_len = 0; + _state = _frame_len > 0 ? RECV_STATE_LEN2_FOUND : RECV_STATE_IDLE; + break; + default: + if (_rx_len < MAX_FRAME_SIZE) { + _rx_buf[_rx_len] = (uint8_t)c; + } + _rx_len++; + if (_rx_len >= _frame_len) { + if (_frame_len > MAX_FRAME_SIZE) { + _frame_len = MAX_FRAME_SIZE; + } + #if ETHERNET_DEBUG_LOGGING && ARDUINO + ETHERNET_DEBUG_PRINTLN("RX frame len=%d", _frame_len); + #endif + memcpy(dest, _rx_buf, _frame_len); + _state = RECV_STATE_IDLE; + return _frame_len; + } + } +#endif + } + } + } + + return 0; +} diff --git a/src/helpers/ethernet/SerialEthernetInterface.h b/src/helpers/ethernet/SerialEthernetInterface.h new file mode 100644 index 000000000..46789f710 --- /dev/null +++ b/src/helpers/ethernet/SerialEthernetInterface.h @@ -0,0 +1,75 @@ +#pragma once + +#include "../BaseSerialInterface.h" + +#ifndef ETHERNET_TCP_PORT + #define ETHERNET_TCP_PORT 5000 +#endif +// define ETHERNET_RAW_LINE=1 to use raw line-based CLI instead of framed packets + +class SerialEthernetInterface : public BaseSerialInterface { + bool _isEnabled; + unsigned long _last_write; + uint8_t _state; + uint16_t _frame_len; + uint16_t _rx_len; + uint8_t _rx_buf[MAX_FRAME_SIZE]; + + struct Frame { + uint8_t len; + uint8_t buf[MAX_FRAME_SIZE]; + }; + + #define FRAME_QUEUE_SIZE 4 + int send_queue_len; + Frame send_queue[FRAME_QUEUE_SIZE]; + + void clearBuffers() { + send_queue_len = 0; + _state = 0; + _frame_len = 0; + _rx_len = 0; + } + + protected: + + public: + SerialEthernetInterface() { + _isEnabled = false; + _last_write = 0; + send_queue_len = 0; + _state = 0; + _frame_len = 0; + _rx_len = 0; + } + bool begin(); + + void onClientConnected(); + + // BaseSerialInterface methods + void enable() override; + void disable() override; + bool isEnabled() const override { return _isEnabled; } + + bool isConnected() const override; + bool isWriteBusy() const override; + + size_t writeFrame(const uint8_t src[], size_t len) override; + size_t checkRecvFrame(uint8_t dest[]) override; + + virtual int available() = 0; + virtual int read() = 0; + virtual size_t write(const uint8_t *buf, size_t size) = 0; +}; + + +#if ETHERNET_DEBUG_LOGGING && ARDUINO + #include + #define ETHERNET_DEBUG_PRINT(F, ...) Serial.printf("ETH: " F, ##__VA_ARGS__) + #define ETHERNET_DEBUG_PRINTLN(F, ...) Serial.printf("ETH: " F "\n", ##__VA_ARGS__) + #define ETHERNET_DEBUG_PRINT_IP(name, ip) Serial.printf("ETH: " name ": %u.%u.%u.%u" "\n", ip[0], ip[1], ip[2], ip[3]) +#else + #define ETHERNET_DEBUG_PRINT(...) {} + #define ETHERNET_DEBUG_PRINTLN(...) {} + #define ETHERNET_DEBUG_PRINT_IP(...) {} +#endif diff --git a/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp b/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp new file mode 100644 index 000000000..7f6962453 --- /dev/null +++ b/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp @@ -0,0 +1,94 @@ +#include "CH390EthernetInterface.h" + +void onWiFiEvent(WiFiEvent_t event) { + switch(event){ + case ARDUINO_EVENT_ETH_START: + ETHERNET_DEBUG_PRINTLN("Ethernet Started"); + break; + case ARDUINO_EVENT_ETH_CONNECTED: + ETHERNET_DEBUG_PRINTLN("Ethernet Connected"); + break; + case ARDUINO_EVENT_ETH_DISCONNECTED: + ETHERNET_DEBUG_PRINTLN("Ethernet Disconnected"); + break; + case ARDUINO_EVENT_ETH_GOT_IP: + ETHERNET_DEBUG_PRINTLN("Ethernet Got IP"); + ETHERNET_DEBUG_PRINT_IP("IP Address", CH390.localIP()); + ETHERNET_DEBUG_PRINT_IP("Subnet Mask", CH390.subnetMask()); + ETHERNET_DEBUG_PRINT_IP("Gateway", CH390.gatewayIP()); + ETHERNET_DEBUG_PRINT_IP("DNS", CH390.dnsIP()); + ETHERNET_DEBUG_PRINTLN("MAC Address: %s", CH390.macAddress().c_str()); + break; + default: + break; + } +} + +bool CH390EthernetInterface::begin() { + + // listen to ethernet events + WiFi.onEvent(onWiFiEvent); + + // Init CH390 + ch390_config_t config = CH390_DEFAULT_CONFIG(); + config.spi_miso_gpio = ETH_MISO_PIN; + config.spi_mosi_gpio = ETH_MOSI_PIN; + config.spi_sck_gpio = ETH_SCLK_PIN; + config.spi_cs_gpio = ETH_CS_PIN; + config.int_gpio = ETH_INT_PIN; + if (!CH390.begin(config)) { + ETHERNET_DEBUG_PRINTLN("Failed to initialize CH390 hardware."); + return false; + } + + // Setup Static IP if build flags are present + #if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) + IPAddress ip(ETHERNET_STATIC_IP); + IPAddress gw(ETHERNET_STATIC_GATEWAY); + IPAddress sn(ETHERNET_STATIC_SUBNET); + CH390.config(ip, gw, sn); + #endif + + // Start Server + server.begin(ETHERNET_TCP_PORT); + ETHERNET_DEBUG_PRINTLN("listening on TCP port: %d", ETHERNET_TCP_PORT); + + return true; +} + +int CH390EthernetInterface::available() { + return client.available(); +} + +int CH390EthernetInterface::read() { + return client.read(); +} + +size_t CH390EthernetInterface::write(const uint8_t *buf, size_t size) { + return client.write(buf, size); +} + +bool CH390EthernetInterface::isConnected() const { + return _isConnected; +} + +void CH390EthernetInterface::loop() { + + if (server.hasClient()) { + auto newClient = server.available(); + if (newClient) { + IPAddress remoteIp = newClient.remoteIP(); + uint16_t remotePort = newClient.remotePort(); + ETHERNET_DEBUG_PRINTLN("New client accepted %u.%u.%u.%u:%u", remoteIp[0], remoteIp[1], remoteIp[2], remoteIp[3], remotePort); + if (client) { + ETHERNET_DEBUG_PRINTLN("Closing previous client"); + client.stop(); + } + client = newClient; + onClientConnected(); + } + } + + _isConnected = client.connected(); + +} diff --git a/src/helpers/ethernet/ch390/CH390EthernetInterface.h b/src/helpers/ethernet/ch390/CH390EthernetInterface.h new file mode 100644 index 000000000..09c3fc23d --- /dev/null +++ b/src/helpers/ethernet/ch390/CH390EthernetInterface.h @@ -0,0 +1,30 @@ +#pragma once + +#include "../SerialEthernetInterface.h" +#include +#include +#include +#include +#include + +class CH390EthernetInterface : public SerialEthernetInterface { + + bool _isConnected; + WiFiServer server; + WiFiClient client; + + public: + CH390EthernetInterface(){ + _isConnected = false; + } + + bool begin(); + void loop() override; + + // BaseSerialInterface methods + bool isConnected() const override; + + int available() override; + int read() override; + size_t write(const uint8_t *buf, size_t size) override; +}; diff --git a/src/helpers/nrf52/EthernetCLI.h b/src/helpers/nrf52/EthernetCLI.h new file mode 100644 index 000000000..34802e567 --- /dev/null +++ b/src/helpers/nrf52/EthernetCLI.h @@ -0,0 +1,157 @@ +#pragma once + +#ifdef ETHERNET_ENABLED + +#include +#include +#include +#include + +#define PIN_SPI1_MISO (29) +#define PIN_SPI1_MOSI (30) +#define PIN_SPI1_SCK (3) + +static SPIClass ETHERNET_SPI_PORT(NRF_SPIM1, PIN_SPI1_MISO, PIN_SPI1_SCK, PIN_SPI1_MOSI); + +#define PIN_ETHERNET_POWER_EN WB_IO2 +#define PIN_ETHERNET_RESET 21 +#define PIN_ETHERNET_SS 26 + +#ifndef ETHERNET_TCP_PORT + #define ETHERNET_TCP_PORT 23 // telnet port for CLI access +#endif + +#ifndef ETHERNET_CLI_BANNER + #define ETHERNET_CLI_BANNER "MeshCore CLI" +#endif + +#define ETHERNET_RETRY_INTERVAL_MS 30000 + +static EthernetServer ethernet_server(ETHERNET_TCP_PORT); +static EthernetClient ethernet_client; +static volatile bool ethernet_running = false; + +// FreeRTOS task: handles hw init, DHCP, and retries in the background +static void ethernet_task(void* param) { + (void)param; + + Serial.println("ETH: Initializing hardware"); + // WB_IO2 (power enable) is already driven HIGH by early constructor + // in RAK4631Board.cpp to support POE boot. + // Skip hardware reset — the W5100S comes out of power-on reset cleanly, + // and toggling reset kills the PHY link which breaks POE power. + pinMode(PIN_ETHERNET_RESET, OUTPUT); + digitalWrite(PIN_ETHERNET_RESET, HIGH); + + ETHERNET_SPI_PORT.begin(); + Ethernet.init(ETHERNET_SPI_PORT, PIN_ETHERNET_SS); + + uint8_t mac[6]; + generateEthernetMac(mac); + Serial.printf("ETH: MAC: %02X:%02X:%02X:%02X:%02X:%02X\n", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + // Retry loop: keep trying until we get an IP + while (!ethernet_running) { + Serial.println("ETH: Attempting DHCP..."); + if (Ethernet.begin(mac, 10000, 2000) == 0) { + if (Ethernet.hardwareStatus() == EthernetNoHardware) { + Serial.println("ETH: Hardware not found, giving up"); + vTaskDelete(NULL); + return; + } + if (Ethernet.linkStatus() == LinkOFF) { + Serial.println("ETH: Cable not connected, will retry"); + } else { + Serial.println("ETH: DHCP failed, will retry"); + } + vTaskDelay(pdMS_TO_TICKS(ETHERNET_RETRY_INTERVAL_MS)); + continue; + } + + IPAddress ip = Ethernet.localIP(); + Serial.printf("ETH: IP: %u.%u.%u.%u\n", ip[0], ip[1], ip[2], ip[3]); + Serial.printf("ETH: Listening on TCP port %d\n", ETHERNET_TCP_PORT); + ethernet_server.begin(); + ethernet_running = true; + } + + // DHCP succeeded, task is done + vTaskDelete(NULL); +} + +static void ethernet_start_task() { + xTaskCreate(ethernet_task, "eth_init", 1024, NULL, 1, NULL); +} + +// Format ethernet status into reply buffer. Returns true if command was handled. +static bool ethernet_handle_command(const char* command, char* reply) { + if (strcmp(command, "eth.status") == 0) { + if (!ethernet_running) { + strcpy(reply, "ETH: not connected"); + } else { + IPAddress ip = Ethernet.localIP(); + sprintf(reply, "ETH: %u.%u.%u.%u:%d", ip[0], ip[1], ip[2], ip[3], ETHERNET_TCP_PORT); + } + return true; + } + return false; +} + +// Check for new TCP client connections, replacing any existing connection. +// Use accept() (not available()) so we only see newly-accepted sockets; +// available() also returns existing connected sockets that have data, which +// would force us to disambiguate every inbound packet from a real new client. +static void ethernet_check_client() { + auto newClient = ethernet_server.accept(); + if (newClient) { + if (ethernet_client) ethernet_client.stop(); + ethernet_client = newClient; + IPAddress ip = ethernet_client.remoteIP(); + Serial.printf("ETH: Client connected from %u.%u.%u.%u\n", ip[0], ip[1], ip[2], ip[3]); + ethernet_client.println(ETHERNET_CLI_BANNER); + } +} + +// Call from loop() to maintain DHCP and check for new clients +static void ethernet_loop_maintain() { + if (ethernet_running) { + ethernet_check_client(); + Ethernet.maintain(); + } +} + +// Read a line from the Ethernet client into the command buffer. +// Returns true when a complete line is ready to process (command is null-terminated). +// The caller should process the command and then reset ethernet_command[0] = 0. +static bool ethernet_read_line(char* ethernet_command, size_t buf_size) { + if (!ethernet_running || !ethernet_client || !ethernet_client.connected()) return false; + + int elen = strlen(ethernet_command); + while (ethernet_client.available() && elen < (int)buf_size - 1) { + char c = ethernet_client.read(); + if (c == '\n' && elen == 0) continue; // ignore leading LF (from CR+LF) + if (c == '\r' || c == '\n') { ethernet_command[elen++] = '\r'; break; } + ethernet_command[elen++] = c; + ethernet_command[elen] = 0; + } + if (elen == (int)buf_size - 1) { + ethernet_command[buf_size - 1] = '\r'; + } + + if (elen > 0 && ethernet_command[elen - 1] == '\r') { + ethernet_command[elen - 1] = 0; + ethernet_client.println(); + return true; + } + return false; +} + +// Send a reply to the Ethernet client +static void ethernet_send_reply(const char* reply) { + if (reply[0]) { + ethernet_client.print(" -> "); ethernet_client.println(reply); + } +} + +#endif // ETHERNET_ENABLED diff --git a/src/helpers/nrf52/EthernetMac.h b/src/helpers/nrf52/EthernetMac.h new file mode 100644 index 000000000..0ee2ac06f --- /dev/null +++ b/src/helpers/nrf52/EthernetMac.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +static inline void generateEthernetMac(uint8_t mac[6]) { + uint32_t device_id = NRF_FICR->DEVICEID[0]; + mac[0] = 0x02; + mac[1] = 0x92; + mac[2] = 0x1F; + mac[3] = (device_id >> 16) & 0xFF; + mac[4] = (device_id >> 8) & 0xFF; + mac[5] = device_id & 0xFF; +} diff --git a/src/helpers/radiolib/CustomLLCC68.h b/src/helpers/radiolib/CustomLLCC68.h index 82f886c32..1dcd916fa 100644 --- a/src/helpers/radiolib/CustomLLCC68.h +++ b/src/helpers/radiolib/CustomLLCC68.h @@ -2,10 +2,12 @@ #include -#define SX126X_IRQ_HEADER_VALID 0b0000010000 // 4 4 valid LoRa header received -#define SX126X_IRQ_PREAMBLE_DETECTED 0x04 - class CustomLLCC68 : public LLCC68 { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; + public: CustomLLCC68(Module *mod) : LLCC68(mod) { } @@ -66,11 +68,11 @@ class CustomLLCC68 : public LLCC68 { setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); #endif #if defined(SX126X_RXEN) || defined(SX126X_TXEN) - #ifndef SX1262X_RXEN - #define SX1262X_RXEN RADIOLIB_NC + #ifndef SX126X_RXEN + #define SX126X_RXEN RADIOLIB_NC #endif - #ifndef SX1262X_TXEN - #define SX1262X_TXEN RADIOLIB_NC + #ifndef SX126X_TXEN + #define SX126X_TXEN RADIOLIB_NC #endif setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); #endif @@ -78,10 +80,61 @@ class CustomLLCC68 : public LLCC68 { return true; // success } + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return LLCC68::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + bool isReceiving() { - uint16_t irq = getIrqFlags(); - bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); - return detected; + uint32_t irq = getIrqFlags(); + bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 + bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 + bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 + uint32_t now = millis(); + if (hdrErr) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); } bool getRxBoostedGainMode() { diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index 8861f76d2..ae0fe0a25 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -14,6 +14,10 @@ public: ((CustomLLCC68 *)_radio)->setBandwidth(bw); ((CustomLLCC68 *)_radio)->setCodingRate(cr); updatePreamble(sf); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomLLCC68 *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomLLCC68 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); + } bool isReceivingPacket() override { @@ -33,8 +37,8 @@ public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - void setRxBoostedGainMode(bool en) override { - ((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en); + bool setRxBoostedGainMode(bool en) override { + return ((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomLLCC68 *)_radio)->getRxBoostedGainMode(); diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index 4061c6b1a..75674a1df 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -4,6 +4,10 @@ #include "MeshCore.h" class CustomLR1110 : public LR1110 { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; bool _rx_boosted = false; public: @@ -31,11 +35,61 @@ class CustomLR1110 : public LR1110 { bool getRxBoostedGainMode() const { return _rx_boosted; } + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags. + return LR1110::startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + bool isReceiving() { - uint16_t irq = getIrqStatus(); - bool detected = ((irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID) || (irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED)); - return detected; + uint32_t irq = getIrqStatus(); + bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 + bool header = irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID; // bit 5 + bool hdrErr = irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR; // bit 6 + uint32_t now = millis(); + if (hdrErr) { + clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); } uint8_t getSpreadingFactor() const { return spreadingFactor; } -}; \ No newline at end of file +}; diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 13efd25b5..44230c61c 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -14,6 +14,9 @@ public: ((CustomLR1110 *)_radio)->setBandwidth(bw); ((CustomLR1110 *)_radio)->setCodingRate(cr); updatePreamble(sf); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomLR1110 *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomLR1110 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } void doResetAGC() override { lr11x0ResetAGC((LR11x0 *)_radio, ((CustomLR1110 *)_radio)->getFreqMHz()); } @@ -26,6 +29,11 @@ public: return rssi; } + uint32_t getEstAirtimeFor(int len_bytes) override { + auto airtime = RadioLibWrapper::getEstAirtimeFor(len_bytes); + return airtime < 200 ? 200 : airtime; // at least 200 millis + } + void onSendFinished() override { RadioLibWrapper::onSendFinished(); _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts @@ -36,8 +44,8 @@ public: uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } - void setRxBoostedGainMode(bool en) override { - ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en); + bool setRxBoostedGainMode(bool en) override { + return ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomLR1110 *)_radio)->getRxBoostedGainMode(); diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h new file mode 100644 index 000000000..17944ef00 --- /dev/null +++ b/src/helpers/radiolib/CustomLR2021.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include "MeshCore.h" + +class CustomLR2021 : public LR2021 { + bool _rx_boosted = false; + + public: + CustomLR2021(Module *mod) : LR2021(mod) { irqDioNum = LR2021_IRQ_DIO; } + + bool std_init(SPIClass* spi = NULL) + { + + #ifdef LR2021_TCXO_VOLTAGE + float tcxo = LR2021_TCXO_VOLTAGE; + #else + float tcxo = 1.6f; + #endif + + #ifdef LORA_CR + uint8_t cr = LORA_CR; + #else + uint8_t cr = 5; + #endif + + #if defined(P_LORA_SCLK) + #ifdef NRF52_PLATFORM + if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } + #elif defined(RP2040_PLATFORM) + if (spi) { + spi->setMISO(P_LORA_MISO); + //spi->setCS(P_LORA_NSS); // Setting CS results in freeze + spi->setSCK(P_LORA_SCLK); + spi->setMOSI(P_LORA_MOSI); + spi->begin(); + } + #else + if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); + #endif + #endif + int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f + if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { + tcxo = 0.0f; + status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + } + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(status); + return false; // fail + } + + setCRC(2); + explicitHeader(); + + + #ifdef LR2021_RX_BOOSTED_GAIN + setRxBoostedGainMode(LR2021_RX_BOOSTED_GAIN); + #endif + + return true; // success + } + + float getFreqMHz() const { return freqMHz; } + + bool getRxBoostedGainMode() const { return _rx_boosted; } + + bool isReceiving() { + uint32_t irq = getIrqStatus(); + bool detected = ((irq & RADIOLIB_LR2021_IRQ_SYNCWORD_VALID) || (irq & RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED)); + return detected; + } + + uint8_t getSpreadingFactor() const { return spreadingFactor; } +}; \ No newline at end of file diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h new file mode 100644 index 000000000..879898315 --- /dev/null +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -0,0 +1,107 @@ +#pragma once + +#include "CustomLR2021.h" +#include "RadioLibWrappers.h" + +#ifndef USE_LR2021 +#define USE_LR2021 +#endif + +#ifndef LR2021_RX_BOOST_LEVEL +#define LR2021_RX_BOOST_LEVEL 7 +#endif + +class CustomLR2021Wrapper : public RadioLibWrapper { +public: + CustomLR2021Wrapper(CustomLR2021& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + + void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + ((CustomLR2021 *)_radio)->setFrequency(freq); + ((CustomLR2021 *)_radio)->setSpreadingFactor(sf); + ((CustomLR2021 *)_radio)->setBandwidth(bw); + ((CustomLR2021 *)_radio)->setCodingRate(cr); + updatePreamble(sf); + applySideDetectorConfig(); + } + + bool configSideDetectors(const uint8_t* sideDetSFs, uint8_t num, float bw) override { + LR2021LoRaSideDetector_t tmp[3]; + uint8_t sf = getSpreadingFactor(); + + if (sf >= 10 && num > 1) { return false; } // only 1 side detector allowed when primary SF >= 10 + for (int i = 0; i < num; i++) { + if (sideDetSFs[i] > 12 || sideDetSFs[i] < 5) { return false; } // must be valid SF + if (sideDetSFs[i] <= sf) { return false; } // must be < primary SF + if (sideDetSFs[i] > sf + 4) { return false; } // span must not be > 4 + + tmp[i].sf = sideDetSFs[i]; + float tSym = calcTsym(tmp[i].sf, bw); + if (tSym >= 16.0f) { + tmp[i].ldro = true; + } else { + tmp[i].ldro = false; + } + tmp[i].invertIQ = false; + tmp[i].syncWord = 0x12; + } + int16_t status = ((CustomLR2021 *)_radio)->setSideDetector(tmp, num); + RadioLibWrapper::idle(); // trigger startReceive() + MESH_DEBUG_PRINTLN("setSideDetector() returned %d", status); + + if (status == RADIOLIB_ERR_NONE) { + for (int i = 0; i < num; i++) { _sideDet[i] = tmp[i]; } + _numSideDet = num; + } else { + return false; + } + + return true; + } + + int16_t applySideDetectorConfig() { + int16_t status = ((CustomLR2021 *)_radio)->setSideDetector(_sideDet, _numSideDet); + RadioLibWrapper::idle(); // trigger startReceive() + return status; + } + + float calcTsym(uint8_t sf, float bw) { + float tSym = (float)(uint32_t(1) << sf) / (float)bw; + return tSym; + } + + bool isReceivingPacket() override { + return ((CustomLR2021 *)_radio)->isReceiving(); + } + + float getCurrentRSSI() override { + float rssi = -110; + ((CustomLR2021 *)_radio)->getRssiInst(&rssi); + return rssi; + } + + void onSendFinished() override { + RadioLibWrapper::onSendFinished(); + _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts + } + + float getLastRSSI() const override { return ((CustomLR2021 *)_radio)->getRSSI(); } + float getLastSNR() const override { return ((CustomLR2021 *)_radio)->getSNR(); } + + uint8_t getSpreadingFactor() const override { return ((CustomLR2021 *)_radio)->getSpreadingFactor(); } + + bool setRxBoostedGainMode(bool en) override { + ((CustomLR2021 *)_radio)->standby(); // LR2021 must be in standby to accept setRxBoostedGainMode + int16_t status = ((CustomLR2021 *)_radio)->setRxBoostedGainMode(en ? LR2021_RX_BOOST_LEVEL: 0); + RadioLibWrapper::idle(); // trigger startReceive() + return status == RADIOLIB_ERR_NONE; + } + + bool getRxBoostedGainMode() const override { + return ((CustomLR2021 *)_radio)->getRxBoostedGainMode(); + } + + protected: + LR2021LoRaSideDetector_t _sideDet[3]; + size_t _numSideDet = 0; + +}; diff --git a/src/helpers/radiolib/CustomSTM32WLx.h b/src/helpers/radiolib/CustomSTM32WLx.h index cbdd5c8c4..e312a5ee4 100644 --- a/src/helpers/radiolib/CustomSTM32WLx.h +++ b/src/helpers/radiolib/CustomSTM32WLx.h @@ -2,16 +2,70 @@ #include -#define SX126X_IRQ_HEADER_VALID 0b0000010000 // 4 4 valid LoRa header received -#define SX126X_IRQ_PREAMBLE_DETECTED 0x04 - class CustomSTM32WLx : public STM32WLx { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; + public: CustomSTM32WLx(STM32WLx_Module *mod) : STM32WLx(mod) { } - bool isReceiving() { - uint16_t irq = getIrqFlags(); - bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); - return detected; + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return STM32WLx::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } + + bool isReceiving() { + uint32_t irq = getIrqFlags(); + bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 + bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 + bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 + uint32_t now = millis(); + if (hdrErr) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); + } + }; \ No newline at end of file diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index 97bf6820d..a792a8775 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -15,6 +15,9 @@ public: ((CustomSTM32WLx *)_radio)->setBandwidth(bw); ((CustomSTM32WLx *)_radio)->setCodingRate(cr); updatePreamble(sf); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomSTM32WLx *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomSTM32WLx *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } bool isReceivingPacket() override { diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index ad2012290..b4ee6c97a 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -1,11 +1,14 @@ #pragma once #include - -#define SX126X_IRQ_HEADER_VALID 0b0000010000 // 4 4 valid LoRa header received -#define SX126X_IRQ_PREAMBLE_DETECTED 0x04 +#include "MeshCore.h" class CustomSX1262 : public SX1262 { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; + public: CustomSX1262(Module *mod) : SX1262(mod) { } @@ -27,6 +30,14 @@ class CustomSX1262 : public SX1262 { uint8_t cr = 5; #endif + #ifdef SX126X_USE_REGULATOR_LDO + constexpr bool useRegulatorLDO = SX126X_USE_REGULATOR_LDO; + #else + constexpr bool useRegulatorLDO = false; + #endif + + MESH_DEBUG_PRINTLN("SX1262 regulator requested: %s", useRegulatorLDO ? "LDO" : "DC-DC"); + #if defined(P_LORA_SCLK) #ifdef NRF52_PLATFORM if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } @@ -42,11 +53,12 @@ class CustomSX1262 : public SX1262 { if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); #endif #endif - int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { + MESH_DEBUG_PRINTLN("SX1262 init failed with error %d, retrying with TCXO at 0.0V", status); tcxo = 0.0f; - status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); } if (status != RADIOLIB_ERR_NONE) { Serial.print("ERROR: radio init failed: "); @@ -83,13 +95,66 @@ class CustomSX1262 : public SX1262 { writeRegister(0x8B5, &r_data, 1); #endif + MESH_DEBUG_PRINTLN("SX1262 status=0x%02X device_errors=0x%04X", getStatus(), getDeviceErrors()); + return true; // success } + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return SX1262::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + bool isReceiving() { - uint16_t irq = getIrqFlags(); - bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); - return detected; + uint32_t irq = getIrqFlags(); + bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 + bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 + bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 + uint32_t now = millis(); + if (hdrErr) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); } bool getRxBoostedGainMode() { diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index cc7bb2238..bfea50ec9 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -18,6 +18,9 @@ public: ((CustomSX1262 *)_radio)->setBandwidth(bw); ((CustomSX1262 *)_radio)->setCodingRate(cr); updatePreamble(sf); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomSX1262 *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomSX1262 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } bool isReceivingPacket() override { @@ -40,8 +43,8 @@ public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - void setRxBoostedGainMode(bool en) override { - ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en); + bool setRxBoostedGainMode(bool en) override { + return ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomSX1262 *)_radio)->getRxBoostedGainMode(); diff --git a/src/helpers/radiolib/CustomSX1268.h b/src/helpers/radiolib/CustomSX1268.h index cc541e49f..f915332dc 100644 --- a/src/helpers/radiolib/CustomSX1268.h +++ b/src/helpers/radiolib/CustomSX1268.h @@ -2,10 +2,12 @@ #include -#define SX126X_IRQ_HEADER_VALID 0b0000010000 // 4 4 valid LoRa header received -#define SX126X_IRQ_PREAMBLE_DETECTED 0x04 - class CustomSX1268 : public SX1268 { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; + public: CustomSX1268(Module *mod) : SX1268(mod) { } @@ -66,11 +68,11 @@ class CustomSX1268 : public SX1268 { setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); #endif #if defined(SX126X_RXEN) || defined(SX126X_TXEN) - #ifndef SX1262X_RXEN - #define SX1262X_RXEN RADIOLIB_NC + #ifndef SX126X_RXEN + #define SX126X_RXEN RADIOLIB_NC #endif - #ifndef SX1262X_TXEN - #define SX1262X_TXEN RADIOLIB_NC + #ifndef SX126X_TXEN + #define SX126X_TXEN RADIOLIB_NC #endif setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); #endif @@ -78,12 +80,64 @@ class CustomSX1268 : public SX1268 { return true; // success } - bool isReceiving() { - uint16_t irq = getIrqFlags(); - bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); - return detected; + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return SX1268::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } + bool isReceiving() { + uint32_t irq = getIrqFlags(); + bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 + bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 + bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 + uint32_t now = millis(); + if (hdrErr) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); + } + + bool getRxBoostedGainMode() { uint8_t rxGain = 0; readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 9ddea78f3..104ba08b2 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -18,6 +18,9 @@ public: ((CustomSX1268 *)_radio)->setBandwidth(bw); ((CustomSX1268 *)_radio)->setCodingRate(cr); updatePreamble(sf); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomSX1268 *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomSX1268 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } bool isReceivingPacket() override { @@ -37,8 +40,8 @@ public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - void setRxBoostedGainMode(bool en) override { - ((CustomSX1268 *)_radio)->setRxBoostedGainMode(en); + bool setRxBoostedGainMode(bool en) override { + return ((CustomSX1268 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomSX1268 *)_radio)->getRxBoostedGainMode(); diff --git a/src/helpers/radiolib/CustomSX1276.h b/src/helpers/radiolib/CustomSX1276.h index bee252743..e6f3270be 100644 --- a/src/helpers/radiolib/CustomSX1276.h +++ b/src/helpers/radiolib/CustomSX1276.h @@ -50,14 +50,14 @@ class CustomSX1276 : public SX1276 { setCurrentLimit(SX127X_CURRENT_LIMIT); #endif - #if defined(SX176X_RXEN) || defined(SX176X_TXEN) - #ifndef SX176X_RXEN - #define SX176X_RXEN RADIOLIB_NC + #if defined(SX127X_RXEN) || defined(SX127X_TXEN) + #ifndef SX127X_RXEN + #define SX127X_RXEN RADIOLIB_NC #endif - #ifndef SX176X_TXEN - #define SX176X_TXEN RADIOLIB_NC + #ifndef SX127X_TXEN + #define SX127X_TXEN RADIOLIB_NC #endif - setRfSwitchPins(SX176X_RXEN, SX176X_TXEN); + setRfSwitchPins(SX127X_RXEN, SX127X_TXEN); #endif setCRC(1); diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 07259bfcd..f2af3bce8 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -36,6 +36,7 @@ void RadioLibWrapper::begin() { _noise_floor = 0; _threshold = 0; + _cad_enabled = false; // start average out some samples _num_floor_samples = 0; @@ -104,6 +105,9 @@ void RadioLibWrapper::loop() { } void RadioLibWrapper::startRecv() { + #if defined(USE_LR2021) + _radio->standby(); // without this LR2021 can throw -706 when calling startReceive after hardware CAD when side detectors are enabled + #endif int err = _radio->startReceive(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; @@ -134,7 +138,11 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { last_recv_millis = millis(); } } + #if defined(USE_LR2021) + state = STATE_RX; // LR2021 stays in Rx after readData, calling startReceive while still in Rx throws -706 errors + #else state = STATE_IDLE; // need another startReceive() + #endif } if (state != STATE_RX) { @@ -233,3 +241,21 @@ float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) { return max(0.0, min(1.0, success_rate_based_on_snr * collision_penalty)); } + +PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols) { + // based on RadioLib's calculateTimeOnAir() + uint32_t tsym_us = ((uint32_t)10000 << sf) / (bw * 10); + uint32_t sfCoeff1_x4 = (sf == 5 || sf == 6) ? 25 : 17; // 6.25 : 4.25, semtech magic numbers to account for sync word + sfd + + // preamble + syncword + sfd + header + uint32_t preamble_us = (((preambleSymbols + 8) * 4 + sfCoeff1_x4) * tsym_us) / 4; + + // airtime for max packet at current radio settings + uint32_t total_us = _radio->getTimeOnAir(MAX_TRANS_UNIT); + // airtime for payload only (no preamble, header or SOF) + uint32_t payload_us = total_us > preamble_us ? total_us - preamble_us : 4000 - preamble_us; // fallback to 4 secs at worst case + // rescale payload_us for max possible CR + if (cr >= 5 && cr < 8) { payload_us = (payload_us * 8) / cr; } + + return PacketMillis {(preamble_us + 999) / 1000, (payload_us + 999) / 1000}; +} \ No newline at end of file diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 20d096f3b..38104fbaa 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -3,18 +3,26 @@ #include #include +#ifdef USE_CC310_HW_CRYPTO +#include +#endif +struct PacketMillis { + uint32_t preambleMillis; // preamble-detect -> header-valid deadline + uint32_t payloadMillis; // header-valid -> rx-done deadline +}; + class RadioLibWrapper : public mesh::Radio { protected: PhysicalLayer* _radio; mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; + bool _cad_enabled; uint16_t _num_floor_samples; int32_t _floor_sample_sum; unsigned long last_recv_millis; unsigned long last_radio_interrupt_millis; // updated on any ISR event, even CRC errors uint8_t _preamble_sf; - bool _cad_enabled; void idle() override; void startRecv() override; @@ -54,6 +62,7 @@ public: virtual uint8_t getSpreadingFactor() const { return LORA_SF; } static uint16_t preambleLengthForSF(uint8_t sf) { return sf <= 8 ? 32 : 16; } void updatePreamble(uint8_t sf) { _preamble_sf = sf; _radio->setPreambleLength(preambleLengthForSF(sf)); } + PacketMillis calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols); virtual int16_t performChannelScan(); int getNoiseFloor() const override { return _noise_floor; } @@ -77,8 +86,10 @@ public: float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, 10, packet_len); } // assume sf=10 - virtual void setRxBoostedGainMode(bool) { } + virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool getRxBoostedGainMode() const { return false; } + + virtual bool configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { return false; } }; /** @@ -91,8 +102,15 @@ public: RadioNoiseListener(PhysicalLayer& radio): _radio(&radio) { } void random(uint8_t* dest, size_t sz) override { +#ifdef USE_CC310_HW_CRYPTO + // CC310 TRNG is higher quality and environment-independent vs radio RSSI noise. + nRFCrypto.begin(); + nRFCrypto.Random.generate(dest, (uint16_t)sz); + nRFCrypto.end(); +#else for (int i = 0; i < sz; i++) { dest[i] = _radio->randomByte() ^ (::random(0, 256) & 0xFF); } +#endif } }; diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 73842d9ee..e2f0d33e7 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -807,6 +807,13 @@ void EnvironmentSensorManager::rakGPSInit(){ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin){ + #if defined(ETHERNET_ENABLED) && defined(RAK_BOARD) + if (ioPin == WB_IO2) { + // WB_IO2 powers the Ethernet module on RAK baseboards. + return false; + } + #endif + //set initial waking state pinMode(ioPin,OUTPUT); digitalWrite(ioPin,LOW); @@ -889,11 +896,11 @@ void EnvironmentSensorManager::stop_gps() { void EnvironmentSensorManager::loop() { #if ENV_INCLUDE_GPS - static long next_gps_update = 0; + static unsigned long next_gps_update = 0; if (gps_active) { _location->loop(); } - if (millis() > next_gps_update) { + if ((long)(millis() - next_gps_update) > 0) { if(gps_active){ #ifdef RAK_WISBLOCK_GPS diff --git a/src/helpers/sensors/LPPDataHelpers.h b/src/helpers/sensors/LPPDataHelpers.h index 37b50f3f1..70a036c49 100644 --- a/src/helpers/sensors/LPPDataHelpers.h +++ b/src/helpers/sensors/LPPDataHelpers.h @@ -142,7 +142,7 @@ public: case LPP_GPS: _pos += 9; break; case LPP_POLYLINE: - _pos += 8; break; // TODO: this is MINIMIUM + _pos += 8; break; // TODO: this is MINIMUM case LPP_GYROMETER: case LPP_ACCELEROMETER: _pos += 6; break; diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 1de753275..c9aee3b61 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -13,8 +13,12 @@ #endif #endif -#ifndef PIN_GPS_EN_ACTIVE - #define PIN_GPS_EN_ACTIVE HIGH +#ifndef GPS_EN_ACTIVE + #ifdef PIN_GPS_EN_ACTIVE + #define GPS_EN_ACTIVE PIN_GPS_EN_ACTIVE + #else + #define GPS_EN_ACTIVE HIGH + #endif #endif #ifndef GPS_RESET @@ -25,11 +29,11 @@ #endif #endif -#ifndef GPS_RESET_FORCE +#ifndef GPS_RESET_ACTIVE #ifdef PIN_GPS_RESET_ACTIVE - #define GPS_RESET_FORCE PIN_GPS_RESET_ACTIVE + #define GPS_RESET_ACTIVE PIN_GPS_RESET_ACTIVE #else - #define GPS_RESET_FORCE LOW + #define GPS_RESET_ACTIVE LOW #endif #endif @@ -39,48 +43,60 @@ class MicroNMEALocationProvider : public LocationProvider { mesh::RTCClock* _clock; Stream* _gps_serial; RefCountedDigitalPin* _peripher_power; + int8_t _claims = 0; int _pin_reset; int _pin_en; - long next_check = 0; + unsigned long next_check = 0; long time_valid = 0; public : MicroNMEALocationProvider(Stream& ser, mesh::RTCClock* clock = NULL, int pin_reset = GPS_RESET, int pin_en = GPS_EN,RefCountedDigitalPin* peripher_power=NULL) : - _gps_serial(&ser), nmea(_nmeaBuffer, sizeof(_nmeaBuffer)), _pin_reset(pin_reset), _pin_en(pin_en), _clock(clock), _peripher_power(peripher_power) { + nmea(_nmeaBuffer, sizeof(_nmeaBuffer)), _clock(clock), _gps_serial(&ser), _peripher_power(peripher_power), _pin_reset(pin_reset), _pin_en(pin_en) { if (_pin_reset != -1) { pinMode(_pin_reset, OUTPUT); - digitalWrite(_pin_reset, GPS_RESET_FORCE); + digitalWrite(_pin_reset, GPS_RESET_ACTIVE); } if (_pin_en != -1) { pinMode(_pin_en, OUTPUT); - digitalWrite(_pin_en, LOW); + digitalWrite(_pin_en, !GPS_EN_ACTIVE); } } + void claim() { + _claims++; + if (_peripher_power) _peripher_power->claim(); + } + + void release() { + if (_claims == 0) return; // avoid negative _claims + _claims--; + if (_peripher_power) _peripher_power->release(); + } + void begin() override { if (_peripher_power) _peripher_power->claim(); if (_pin_en != -1) { - digitalWrite(_pin_en, PIN_GPS_EN_ACTIVE); + digitalWrite(_pin_en, GPS_EN_ACTIVE); } if (_pin_reset != -1) { - digitalWrite(_pin_reset, !GPS_RESET_FORCE); + digitalWrite(_pin_reset, !GPS_RESET_ACTIVE); } } void reset() override { if (_pin_reset != -1) { - digitalWrite(_pin_reset, GPS_RESET_FORCE); + digitalWrite(_pin_reset, GPS_RESET_ACTIVE); delay(10); - digitalWrite(_pin_reset, !GPS_RESET_FORCE); + digitalWrite(_pin_reset, !GPS_RESET_ACTIVE); } } void stop() override { if (_pin_en != -1) { - digitalWrite(_pin_en, !PIN_GPS_EN_ACTIVE); + digitalWrite(_pin_en, !GPS_EN_ACTIVE); } if (_pin_reset != -1) { - digitalWrite(_pin_reset, GPS_RESET_FORCE); + digitalWrite(_pin_reset, GPS_RESET_ACTIVE); } if (_peripher_power) _peripher_power->release(); } @@ -89,7 +105,7 @@ public : // directly read the enable pin if present as gps can be // activated/deactivated outside of here ... if (_pin_en != -1) { - return digitalRead(_pin_en) == PIN_GPS_EN_ACTIVE; + return digitalRead(_pin_en) == GPS_EN_ACTIVE; } else { return true; // no enable so must be active } @@ -127,7 +143,7 @@ public : if (!isValid()) time_valid = 0; - if (millis() > next_check) { + if ((long)(millis() - next_check) > 0) { next_check = millis() + 1000; if (_time_sync_needed && time_valid > 2) { if (_clock != NULL) { diff --git a/src/helpers/sim/SimRadio.h b/src/helpers/sim/SimRadio.h new file mode 100644 index 000000000..2e7d62542 --- /dev/null +++ b/src/helpers/sim/SimRadio.h @@ -0,0 +1,75 @@ +#pragma once + +// A no-hardware stand-in for the LoRa radio, so observer firmware can boot and +// run in an emulator (e.g. Wokwi) that models the ESP32-S3 + WiFi + display but +// has no SX1262. It's a drop-in for the concrete `radio_driver` used by the +// examples: it implements the mesh::Radio interface plus the RadioLibWrapper +// methods MyMesh/main call directly (setParams, setTxPower, getRngSeed, packet +// counters, …). Transmits "succeed" instantly with no RF; nothing is ever +// received. WiFi/MQTT/CLI/display all run normally on top of it. +// +// Compiled only into *_sim builds (guarded by SIM_BUILD in the target). Never +// pulled into real firmware. + +#include +#include +#include +#if defined(ESP_PLATFORM) + #include // esp_random() +#endif + +static inline uint32_t _simRandom() { +#if defined(ESP_PLATFORM) + return esp_random(); +#else + return (uint32_t)millis() * 2654435761u; +#endif +} + +// RNG for creating a LocalIdentity without radio noise (used by radio_new_identity()). +class SimRNG : public mesh::RNG { +public: + void random(uint8_t* dest, size_t sz) override { + for (size_t i = 0; i < sz; i++) dest[i] = (uint8_t)_simRandom(); + } +}; + +class SimRadio : public mesh::Radio { + uint32_t n_recv, n_sent, n_recv_errors; + unsigned long _send_started; +public: + explicit SimRadio(mesh::MainBoard& /*board*/) : n_recv(0), n_sent(0), + n_recv_errors(0), _send_started(0) {} + + // --- mesh::Radio pure virtuals --- + void begin() override {} + int recvRaw(uint8_t* /*bytes*/, int /*sz*/) override { return 0; } // never receives + uint32_t getEstAirtimeFor(int len_bytes) override { + return (uint32_t)(len_bytes < 0 ? 0 : len_bytes) * 10 + 10; // rough, non-zero + } + float packetScore(float /*snr*/, int /*packet_len*/) override { return 0.0f; } + bool startSendRaw(const uint8_t* /*bytes*/, int /*len*/) override { + _send_started = millis(); + n_sent++; + return true; // "sent" instantly + } + bool isSendComplete() override { return true; } + void onSendFinished() override { _send_started = 0; } + bool isInRecvMode() const override { return true; } + + // --- mesh::Radio overrides with useful sim values --- + int getNoiseFloor() const override { return -110; } + uint32_t getPacketsRecvErrors() const override { return n_recv_errors; } + float getLastRSSI() const override { return -80.0f; } + float getLastSNR() const override { return 9.0f; } + + // --- concrete RadioLibWrapper surface called directly on radio_driver --- + void setParams(float /*freq*/, float /*bw*/, uint8_t /*sf*/, uint8_t /*cr*/) {} + void setTxPower(int8_t /*dbm*/) {} + uint32_t getRngSeed() { return _simRandom(); } + uint32_t getPacketsRecv() const { return n_recv; } + uint32_t getPacketsSent() const { return n_sent; } + void resetStats() { n_recv = n_sent = n_recv_errors = 0; } + void setRxBoostedGainMode(bool) {} + bool getRxBoostedGainMode() const { return false; } +}; diff --git a/src/helpers/stm32/InternalFileSystem.cpp b/src/helpers/stm32/InternalFileSystem.cpp index dc032eb96..6a7d7064e 100644 --- a/src/helpers/stm32/InternalFileSystem.cpp +++ b/src/helpers/stm32/InternalFileSystem.cpp @@ -37,7 +37,7 @@ static int _internal_flash_read(const struct lfs_config *c, lfs_block_t block, l } // Program a region in a block. The block must have previously -// been erased. Negative error codes are propogated to the user. +// been erased. Negative error codes are propagated to the user. // May return LFS_ERR_CORRUPT if the block should be considered bad. static int _internal_flash_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { @@ -62,7 +62,7 @@ static int _internal_flash_prog(const struct lfs_config *c, lfs_block_t block, l // Erase a block. A block must be erased before being programmed. // The state of an erased block is undefined. Negative error codes -// are propogated to the user. +// are propagated to the user. // May return LFS_ERR_CORRUPT if the block should be considered bad. static int _internal_flash_erase(const struct lfs_config *c, lfs_block_t block) { @@ -87,7 +87,7 @@ static int _internal_flash_erase(const struct lfs_config *c, lfs_block_t block) } // Sync the state of the underlying block device. Negative error codes -// are propogated to the user. +// are propagated to the user. static int _internal_flash_sync(const struct lfs_config *c) { return LFS_ERR_OK; // don't need sync diff --git a/src/helpers/ui/DRV2605Vibration.cpp b/src/helpers/ui/DRV2605Vibration.cpp new file mode 100644 index 000000000..3a42bb884 --- /dev/null +++ b/src/helpers/ui/DRV2605Vibration.cpp @@ -0,0 +1,47 @@ +#ifdef HAS_DRV2605 + +#include "DRV2605Vibration.h" +#include + +void DRV2605Vibration::begin() { +#ifdef PIN_DRV_EN + pinMode(PIN_DRV_EN, OUTPUT); + digitalWrite(PIN_DRV_EN, HIGH); // power up the haptic driver + delay(10); +#endif + if (!drv.begin(&Wire)) { + return; // no haptic driver found, stay silent + } +#ifdef DRV2605_USE_LRA + // LRA mode: 4x brake factor, medium loop gain, back-EMF gain 2 + drv.writeRegister8(DRV2605_REG_FEEDBACK, 0xB6); +#endif + drv.selectLibrary(1); + drv.setMode(DRV2605_MODE_INTTRIG); + _ready = true; +} + +void DRV2605Vibration::trigger(bool force) { + if (!_ready || _quiet) return; + unsigned long now = millis(); + if (!force && _last_trigger != 0 && now - _last_trigger < VIBRATION_TIMEOUT) return; + _last_trigger = now; + drv.setWaveform(0, DRV2605_EFFECT); + drv.setWaveform(1, 0); // pause + drv.setWaveform(2, DRV2605_EFFECT); + drv.setWaveform(3, 0); // end of sequence + drv.go(); +} + +void DRV2605Vibration::loop() { +} + +bool DRV2605Vibration::isVibrating() { + return false; // effects are short, treat as instantaneous +} + +void DRV2605Vibration::stop() { + if (_ready) drv.stop(); +} + +#endif // ifdef HAS_DRV2605 diff --git a/src/helpers/ui/DRV2605Vibration.h b/src/helpers/ui/DRV2605Vibration.h new file mode 100644 index 000000000..a40894be1 --- /dev/null +++ b/src/helpers/ui/DRV2605Vibration.h @@ -0,0 +1,39 @@ +#pragma once + +#ifdef HAS_DRV2605 + +#include +#include + +/* + * Vibration control class for boards where the motor is behind a + * DRV2605 haptic driver on I2C (e.g. Seeed SenseCAP MeshTracker X1). + * Same interface as GenericVibration. + */ + +#ifndef VIBRATION_TIMEOUT +#define VIBRATION_TIMEOUT 5000 // cooldown between vibrations +#endif + +#ifndef DRV2605_EFFECT +#define DRV2605_EFFECT 16 // "1000 ms alert" from the DRV2605 effect library +#endif + +class DRV2605Vibration { +public: + void begin(); // power up and init the DRV2605 + void trigger(bool force = false); // trigger vibration; force skips the cooldown + void loop(); // no-op, the DRV2605 plays effects autonomously + bool isVibrating(); + void stop(); // stop vibration immediately + void quiet(bool q) { _quiet = q; } + bool isQuiet() const { return _quiet; } + +private: + Adafruit_DRV2605 drv; + bool _ready = false; + bool _quiet = false; + unsigned long _last_trigger = 0; +}; + +#endif // ifdef HAS_DRV2605 diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index dcc5fe031..b76a1b6ca 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -3,12 +3,20 @@ #include #include +using ColorVal = uint16_t; + +class UIColor { +public: + // color definitions (by element _type_) + static ColorVal window_bkg, title_bkg, title_txt, primary_txt, secondary_txt, warning_txt, popup_bkg, popup_txt, corp_blue; +}; + class DisplayDriver { int _w, _h; protected: DisplayDriver(int w, int h) { _w = w; _h = h; } public: - enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light + //enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light int width() const { return _w; } int height() const { return _h; } @@ -18,9 +26,9 @@ public: virtual void turnOn() = 0; virtual void turnOff() = 0; virtual void clear() = 0; - virtual void startFrame(Color bkg = DARK) = 0; + virtual void startFrame(ColorVal bkg = UIColor::window_bkg) = 0; virtual void setTextSize(int sz) = 0; - virtual void setColor(Color c) = 0; + virtual void setColor(ColorVal c) = 0; virtual void setCursor(int x, int y) = 0; virtual void print(const char* str) = 0; virtual void printWordWrap(const char* str, int max_width) { print(str); } // fallback to basic print() if no override diff --git a/src/helpers/ui/E213Display.cpp b/src/helpers/ui/E213Display.cpp index 814693a06..daf26989b 100644 --- a/src/helpers/ui/E213Display.cpp +++ b/src/helpers/ui/E213Display.cpp @@ -2,6 +2,17 @@ #include "../../MeshCore.h" +// Color scheme +ColorVal UIColor::window_bkg = WHITE; +ColorVal UIColor::title_bkg = WHITE; +ColorVal UIColor::title_txt = BLACK; +ColorVal UIColor::primary_txt = BLACK; +ColorVal UIColor::secondary_txt = BLACK; +ColorVal UIColor::warning_txt = BLACK; +ColorVal UIColor::popup_bkg = WHITE; +ColorVal UIColor::popup_txt = BLACK; +ColorVal UIColor::corp_blue = BLACK; + BaseDisplay* E213Display::detectEInk() { // Test 1: Logic of BUSY pin @@ -108,16 +119,18 @@ void E213Display::clear() { display->clear(); } -void E213Display::startFrame(Color bkg) { +void E213Display::startFrame(ColorVal bkg) { display_crc.reset(); // Fill screen with white first to ensure clean background display->fillRect(0, 0, width(), height(), WHITE); - if (bkg == LIGHT) { + if (bkg == 0) { // Fill with black if light background requested (inverted for e-ink) display->fillRect(0, 0, width(), height(), BLACK); } + _color = UIColor::primary_txt; + display->setTextColor(_color); } void E213Display::setTextSize(int sz) { @@ -126,9 +139,10 @@ void E213Display::setTextSize(int sz) { display->setTextSize(sz); } -void E213Display::setColor(Color c) { - display_crc.update(c); - // implemented in individual display methods +void E213Display::setColor(ColorVal c) { + _color = c; + display_crc.update(c); + display->setTextColor(_color); } void E213Display::setCursor(int x, int y) { @@ -147,7 +161,7 @@ void E213Display::fillRect(int x, int y, int w, int h) { display_crc.update(y); display_crc.update(w); display_crc.update(h); - display->fillRect(x, y, w, h, BLACK); + display->fillRect(x, y, w, h, _color); } void E213Display::drawRect(int x, int y, int w, int h) { @@ -155,7 +169,7 @@ void E213Display::drawRect(int x, int y, int w, int h) { display_crc.update(y); display_crc.update(w); display_crc.update(h); - display->drawRect(x, y, w, h, BLACK); + display->drawRect(x, y, w, h, _color); } void E213Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) { @@ -179,7 +193,7 @@ void E213Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) { // If the bit is set, draw the pixel if (bitSet) { - display->drawPixel(x + bx, y + by, BLACK); + display->drawPixel(x + bx, y + by, _color); } } } diff --git a/src/helpers/ui/E213Display.h b/src/helpers/ui/E213Display.h index add8f11b3..32567a79a 100644 --- a/src/helpers/ui/E213Display.h +++ b/src/helpers/ui/E213Display.h @@ -16,6 +16,7 @@ class E213Display : public DisplayDriver { RefCountedDigitalPin* _periph_power; CRC32 display_crc; uint32_t last_display_crc_value = 0; + uint16_t _color; public: E213Display(RefCountedDigitalPin* periph_power = NULL) : DisplayDriver(250, 122), _periph_power(periph_power) {} @@ -30,9 +31,9 @@ public: void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char *str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/E290Display.cpp b/src/helpers/ui/E290Display.cpp index ef4df05ed..34bbcdbc5 100644 --- a/src/helpers/ui/E290Display.cpp +++ b/src/helpers/ui/E290Display.cpp @@ -2,6 +2,17 @@ #include "../../MeshCore.h" +// Color scheme +ColorVal UIColor::window_bkg = WHITE; +ColorVal UIColor::title_bkg = WHITE; +ColorVal UIColor::title_txt = BLACK; +ColorVal UIColor::primary_txt = BLACK; +ColorVal UIColor::secondary_txt = BLACK; +ColorVal UIColor::warning_txt = BLACK; +ColorVal UIColor::popup_bkg = WHITE; +ColorVal UIColor::popup_txt = BLACK; +ColorVal UIColor::corp_blue = BLACK; + bool E290Display::begin() { if (_init) return true; @@ -62,15 +73,17 @@ void E290Display::clear() { display.clear(); } -void E290Display::startFrame(Color bkg) { +void E290Display::startFrame(ColorVal bkg) { display_crc.reset(); // Fill screen with white first to ensure clean background display.fillRect(0, 0, width(), height(), WHITE); - if (bkg == LIGHT) { + if (bkg == 0) { // Fill with black if light background requested (inverted for e-ink) display.fillRect(0, 0, width(), height(), BLACK); } + _color = UIColor::primary_txt; + display.setTextColor(_color); } void E290Display::setTextSize(int sz) { @@ -79,9 +92,10 @@ void E290Display::setTextSize(int sz) { display.setTextSize(sz); } -void E290Display::setColor(Color c) { - display_crc.update(c); - // implemented in individual display methods +void E290Display::setColor(ColorVal c) { + _color = c; + display_crc.update(c); + display.setTextColor(_color); } void E290Display::setCursor(int x, int y) { @@ -100,7 +114,7 @@ void E290Display::fillRect(int x, int y, int w, int h) { display_crc.update(y); display_crc.update(w); display_crc.update(h); - display.fillRect(x, y, w, h, BLACK); + display.fillRect(x, y, w, h, _color); } void E290Display::drawRect(int x, int y, int w, int h) { @@ -108,7 +122,7 @@ void E290Display::drawRect(int x, int y, int w, int h) { display_crc.update(y); display_crc.update(w); display_crc.update(h); - display.drawRect(x, y, w, h, BLACK); + display.drawRect(x, y, w, h, _color); } void E290Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) { @@ -132,7 +146,7 @@ void E290Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) { // If the bit is set, draw the pixel if (bitSet) { - display.drawPixel(x + bx, y + by, BLACK); + display.drawPixel(x + bx, y + by, _color); } } } diff --git a/src/helpers/ui/E290Display.h b/src/helpers/ui/E290Display.h index 88bf34ff1..bf6296bb7 100644 --- a/src/helpers/ui/E290Display.h +++ b/src/helpers/ui/E290Display.h @@ -16,6 +16,7 @@ class E290Display : public DisplayDriver { RefCountedDigitalPin* _periph_power; CRC32 display_crc; uint32_t last_display_crc_value = 0; + uint16_t _color; public: E290Display(RefCountedDigitalPin* periph_power = NULL) : DisplayDriver(296, 128), _periph_power(periph_power) {} @@ -26,9 +27,9 @@ public: void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char *str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index ad47754bf..13dafa345 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -14,6 +14,18 @@ SPIClass SPI1 = SPIClass(FSPI); #endif +// Color scheme +ColorVal UIColor::window_bkg = GxEPD_WHITE; +ColorVal UIColor::title_bkg = GxEPD_WHITE; +ColorVal UIColor::title_txt = GxEPD_BLACK; +ColorVal UIColor::primary_txt = GxEPD_BLACK; +ColorVal UIColor::secondary_txt = GxEPD_BLACK; +ColorVal UIColor::warning_txt = GxEPD_BLACK; +ColorVal UIColor::popup_bkg = GxEPD_WHITE; +ColorVal UIColor::popup_txt = GxEPD_BLACK; +ColorVal UIColor::corp_blue = GxEPD_BLACK; + + bool GxEPDDisplay::begin() { display.epd2.selectSPI(SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0)); #ifdef ESP32 @@ -61,9 +73,9 @@ void GxEPDDisplay::clear() { display_crc.reset(); } -void GxEPDDisplay::startFrame(Color bkg) { - display.fillScreen(GxEPD_WHITE); - display.setTextColor(_curr_color = GxEPD_BLACK); +void GxEPDDisplay::startFrame(ColorVal bkg) { + display.fillScreen(bkg); + display.setTextColor(_curr_color = UIColor::primary_txt); display_crc.reset(); } @@ -85,14 +97,9 @@ void GxEPDDisplay::setTextSize(int sz) { } } -void GxEPDDisplay::setColor(Color c) { - display_crc.update (c); - // colours need to be inverted for epaper displays - if (c == DARK) { - display.setTextColor(_curr_color = GxEPD_WHITE); - } else { - display.setTextColor(_curr_color = GxEPD_BLACK); - } +void GxEPDDisplay::setColor(ColorVal c) { + display_crc.update (c); + display.setTextColor(_curr_color = c); } void GxEPDDisplay::setCursor(int x, int y) { diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 219b60764..c653eac4b 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -51,9 +51,9 @@ public: void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/LGFXDisplay.cpp b/src/helpers/ui/LGFXDisplay.cpp index a53cbc620..7bdd52a25 100644 --- a/src/helpers/ui/LGFXDisplay.cpp +++ b/src/helpers/ui/LGFXDisplay.cpp @@ -1,5 +1,16 @@ #include "LGFXDisplay.h" +// Color scheme +ColorVal UIColor::window_bkg = 0xFFFF; +ColorVal UIColor::title_bkg = 0x001F; +ColorVal UIColor::title_txt = 0xFFFF; +ColorVal UIColor::primary_txt = 0x0000; +ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray +ColorVal UIColor::warning_txt = 0xFD20; +ColorVal UIColor::popup_bkg = 0x07FF; // CYAN +ColorVal UIColor::popup_txt = 0x0000; +ColorVal UIColor::corp_blue = 0x001A; + bool LGFXDisplay::begin() { turnOn(); display->init(); @@ -35,45 +46,20 @@ void LGFXDisplay::clear() { buffer.clearDisplay(); } -void LGFXDisplay::startFrame(Color bkg) { +void LGFXDisplay::startFrame(ColorVal bkg) { // display->startWrite(); // display->getScanLine(); - buffer.clearDisplay(); - buffer.setTextColor(TFT_WHITE); + _color = bkg; + buffer.fillScreen(_color); + buffer.setTextColor(_color = UIColor::primary_txt); } void LGFXDisplay::setTextSize(int sz) { buffer.setTextSize(sz); } -void LGFXDisplay::setColor(Color c) { - // _color = (c != 0) ? ILI9342_WHITE : ILI9342_BLACK; - switch (c) { - case DARK: - _color = TFT_BLACK; - break; - case LIGHT: - _color = TFT_WHITE; - break; - case RED: - _color = TFT_RED; - break; - case GREEN: - _color = TFT_GREEN; - break; - case BLUE: - _color = TFT_BLUE; - break; - case YELLOW: - _color = TFT_YELLOW; - break; - case ORANGE: - _color = TFT_ORANGE; - break; - default: - _color = TFT_WHITE; - } - buffer.setTextColor(_color); +void LGFXDisplay::setColor(ColorVal c) { + buffer.setTextColor(_color = c); } void LGFXDisplay::setCursor(int x, int y) { diff --git a/src/helpers/ui/LGFXDisplay.h b/src/helpers/ui/LGFXDisplay.h index ad7212ecf..a2d660b22 100644 --- a/src/helpers/ui/LGFXDisplay.h +++ b/src/helpers/ui/LGFXDisplay.h @@ -25,9 +25,9 @@ public: void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/NV3001BDisplay.cpp b/src/helpers/ui/NV3001BDisplay.cpp new file mode 100644 index 000000000..1ff7765cb --- /dev/null +++ b/src/helpers/ui/NV3001BDisplay.cpp @@ -0,0 +1,551 @@ +#include "NV3001BDisplay.h" +#include +#include + +#ifndef SPI_FREQUENCY + #define SPI_FREQUENCY 8000000 +#endif + +#ifndef PIN_TFT_SCL + #error "PIN_TFT_SCL must be defined" +#endif + +#ifndef PIN_TFT_SDA + #error "PIN_TFT_SDA must be defined" +#endif + +#ifndef PIN_TFT_CS + #error "PIN_TFT_CS must be defined" +#endif + +#ifndef PIN_TFT_DC + #error "PIN_TFT_DC must be defined" +#endif + +#ifndef PIN_TFT_MISO + #define PIN_TFT_MISO -1 +#endif + +#ifndef PIN_TFT_RST + #define PIN_TFT_RST -1 +#endif + +#ifndef PIN_TFT_EN + #define PIN_TFT_EN -1 +#endif + +#ifndef PIN_TFT_BL + #define PIN_TFT_BL -1 +#endif + +#ifndef PIN_TFT_EN_ACTIVE + #define PIN_TFT_EN_ACTIVE LOW +#endif + +#ifndef PIN_TFT_BL_ACTIVE + #define PIN_TFT_BL_ACTIVE HIGH +#endif + +#ifndef DISPLAY_ROTATION + #define DISPLAY_ROTATION 0 +#endif + +#ifndef NV3001B_SCREEN_WIDTH + #define NV3001B_SCREEN_WIDTH 220 +#endif + +#ifndef NV3001B_SCREEN_HEIGHT + #define NV3001B_SCREEN_HEIGHT 128 +#endif + +#ifndef DISPLAY_SCALE_X + #define DISPLAY_SCALE_X ((float)NV3001B_SCREEN_WIDTH / NV3001B_LOGICAL_WIDTH) +#endif + +#ifndef DISPLAY_SCALE_Y + #define DISPLAY_SCALE_Y ((float)NV3001B_SCREEN_HEIGHT / NV3001B_LOGICAL_HEIGHT) +#endif + +#define NV3001B_SWRESET 0x01 +#define NV3001B_SLPOUT 0x11 +#define NV3001B_DISPON 0x29 +#define NV3001B_CASET 0x2A +#define NV3001B_RASET 0x2B +#define NV3001B_RAMWR 0x2C +#define NV3001B_MADCTL 0x36 +#define NV3001B_COLMOD 0x3A + +#define NV3001B_MADCTL_MY 0x80 +#define NV3001B_MADCTL_MX 0x40 +#define NV3001B_MADCTL_MV 0x20 +#define NV3001B_MADCTL_RGB 0x00 + +#ifndef NV3001B_TEXT_SIZE1_SCALE_X + #define NV3001B_TEXT_SIZE1_SCALE_X 1 +#endif + +#ifndef NV3001B_TEXT_SIZE1_SCALE_Y + #define NV3001B_TEXT_SIZE1_SCALE_Y 2 +#endif + +#ifndef NV3001B_TEXT_SIZE2_SCALE_X + #define NV3001B_TEXT_SIZE2_SCALE_X 2 +#endif + +#ifndef NV3001B_TEXT_SIZE2_SCALE_Y + #define NV3001B_TEXT_SIZE2_SCALE_Y 3 +#endif + +// Color scheme +ColorVal UIColor::window_bkg = 0xFFFF; +ColorVal UIColor::title_bkg = 0x001F; +ColorVal UIColor::title_txt = 0xFFFF; +ColorVal UIColor::primary_txt = 0x0000; +ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray +ColorVal UIColor::warning_txt = 0xFD20; +ColorVal UIColor::popup_bkg = 0x07FF; // CYAN +ColorVal UIColor::popup_txt = 0x0000; +ColorVal UIColor::corp_blue = 0x001A; + +static int scaleX(int x) { + return (int)(x * DISPLAY_SCALE_X); +} + +static int scaleY(int y) { + return (int)(y * DISPLAY_SCALE_Y); +} + +static int scaleWidth(int x, int w) { + if (w <= 0) return 0; + int scaled = scaleX(x + w) - scaleX(x); + return scaled > 0 ? scaled : 1; +} + +static int scaleHeight(int y, int h) { + if (h <= 0) return 0; + int scaled = scaleY(y + h) - scaleY(y); + return scaled > 0 ? scaled : 1; +} + +static uint8_t nv3001bMADCTL(uint8_t rotation) { + uint8_t madctl; + switch (rotation & 3) { + case 0: + madctl = NV3001B_MADCTL_MY | NV3001B_MADCTL_MV | NV3001B_MADCTL_RGB; + break; + case 1: + madctl = NV3001B_MADCTL_MY | NV3001B_MADCTL_MX | NV3001B_MADCTL_RGB; + break; + case 2: + madctl = NV3001B_MADCTL_RGB; + break; + default: + madctl = NV3001B_MADCTL_MX | NV3001B_MADCTL_MV | NV3001B_MADCTL_RGB; + break; + } + return madctl; +} + +static const uint8_t font5x7[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x00, 0x07, 0x00, 0x07, 0x00, 0x14, + 0x7f, 0x14, 0x7f, 0x14, 0x24, 0x2a, 0x7f, 0x2a, 0x12, 0x23, 0x13, 0x08, 0x64, 0x62, 0x36, 0x49, + 0x55, 0x22, 0x50, 0x00, 0x05, 0x03, 0x00, 0x00, 0x00, 0x1c, 0x22, 0x41, 0x00, 0x00, 0x41, 0x22, + 0x1c, 0x00, 0x14, 0x08, 0x3e, 0x08, 0x14, 0x08, 0x08, 0x3e, 0x08, 0x08, 0x00, 0x50, 0x30, 0x00, + 0x00, 0x08, 0x08, 0x08, 0x08, 0x08, 0x00, 0x60, 0x60, 0x00, 0x00, 0x20, 0x10, 0x08, 0x04, 0x02, + 0x3e, 0x51, 0x49, 0x45, 0x3e, 0x00, 0x42, 0x7f, 0x40, 0x00, 0x42, 0x61, 0x51, 0x49, 0x46, 0x21, + 0x41, 0x45, 0x4b, 0x31, 0x18, 0x14, 0x12, 0x7f, 0x10, 0x27, 0x45, 0x45, 0x45, 0x39, 0x3c, 0x4a, + 0x49, 0x49, 0x30, 0x01, 0x71, 0x09, 0x05, 0x03, 0x36, 0x49, 0x49, 0x49, 0x36, 0x06, 0x49, 0x49, + 0x29, 0x1e, 0x00, 0x36, 0x36, 0x00, 0x00, 0x00, 0x56, 0x36, 0x00, 0x00, 0x08, 0x14, 0x22, 0x41, + 0x00, 0x14, 0x14, 0x14, 0x14, 0x14, 0x00, 0x41, 0x22, 0x14, 0x08, 0x02, 0x01, 0x51, 0x09, 0x06, + 0x32, 0x49, 0x79, 0x41, 0x3e, 0x7e, 0x11, 0x11, 0x11, 0x7e, 0x7f, 0x49, 0x49, 0x49, 0x36, 0x3e, + 0x41, 0x41, 0x41, 0x22, 0x7f, 0x41, 0x41, 0x22, 0x1c, 0x7f, 0x49, 0x49, 0x49, 0x41, 0x7f, 0x09, + 0x09, 0x09, 0x01, 0x3e, 0x41, 0x49, 0x49, 0x7a, 0x7f, 0x08, 0x08, 0x08, 0x7f, 0x00, 0x41, 0x7f, + 0x41, 0x00, 0x20, 0x40, 0x41, 0x3f, 0x01, 0x7f, 0x08, 0x14, 0x22, 0x41, 0x7f, 0x40, 0x40, 0x40, + 0x40, 0x7f, 0x02, 0x0c, 0x02, 0x7f, 0x7f, 0x04, 0x08, 0x10, 0x7f, 0x3e, 0x41, 0x41, 0x41, 0x3e, + 0x7f, 0x09, 0x09, 0x09, 0x06, 0x3e, 0x41, 0x51, 0x21, 0x5e, 0x7f, 0x09, 0x19, 0x29, 0x46, 0x46, + 0x49, 0x49, 0x49, 0x31, 0x01, 0x01, 0x7f, 0x01, 0x01, 0x3f, 0x40, 0x40, 0x40, 0x3f, 0x1f, 0x20, + 0x40, 0x20, 0x1f, 0x3f, 0x40, 0x38, 0x40, 0x3f, 0x63, 0x14, 0x08, 0x14, 0x63, 0x07, 0x08, 0x70, + 0x08, 0x07, 0x61, 0x51, 0x49, 0x45, 0x43, 0x00, 0x7f, 0x41, 0x41, 0x00, 0x02, 0x04, 0x08, 0x10, + 0x20, 0x00, 0x41, 0x41, 0x7f, 0x00, 0x04, 0x02, 0x01, 0x02, 0x04, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x00, 0x01, 0x02, 0x04, 0x00, 0x20, 0x54, 0x54, 0x54, 0x78, 0x7f, 0x48, 0x44, 0x44, 0x38, 0x38, + 0x44, 0x44, 0x44, 0x20, 0x38, 0x44, 0x44, 0x48, 0x7f, 0x38, 0x54, 0x54, 0x54, 0x18, 0x08, 0x7e, + 0x09, 0x01, 0x02, 0x0c, 0x52, 0x52, 0x52, 0x3e, 0x7f, 0x08, 0x04, 0x04, 0x78, 0x00, 0x44, 0x7d, + 0x40, 0x00, 0x20, 0x40, 0x44, 0x3d, 0x00, 0x7f, 0x10, 0x28, 0x44, 0x00, 0x00, 0x41, 0x7f, 0x40, + 0x00, 0x7c, 0x04, 0x18, 0x04, 0x78, 0x7c, 0x08, 0x04, 0x04, 0x78, 0x38, 0x44, 0x44, 0x44, 0x38, + 0x7c, 0x14, 0x14, 0x14, 0x08, 0x08, 0x14, 0x14, 0x18, 0x7c, 0x7c, 0x08, 0x04, 0x04, 0x08, 0x48, + 0x54, 0x54, 0x54, 0x20, 0x04, 0x3f, 0x44, 0x40, 0x20, 0x3c, 0x40, 0x40, 0x20, 0x7c, 0x1c, 0x20, + 0x40, 0x20, 0x1c, 0x3c, 0x40, 0x30, 0x40, 0x3c, 0x44, 0x28, 0x10, 0x28, 0x44, 0x0c, 0x50, 0x50, + 0x50, 0x3c, 0x44, 0x64, 0x54, 0x4c, 0x44, 0x00, 0x08, 0x36, 0x41, 0x00, 0x00, 0x00, 0x7f, 0x00, + 0x00, 0x00, 0x41, 0x36, 0x08, 0x00, 0x08, 0x08, 0x2a, 0x1c, 0x08, 0x00, 0x06, 0x09, 0x09, 0x06 +}; + +static int textPixelScaleX(uint8_t size) { + return size <= 1 ? NV3001B_TEXT_SIZE1_SCALE_X : NV3001B_TEXT_SIZE2_SCALE_X; +} + +static int textPixelScaleY(uint8_t size) { + return size <= 1 ? NV3001B_TEXT_SIZE1_SCALE_Y : NV3001B_TEXT_SIZE2_SCALE_Y; +} + +static void setupOptionalOutput(int pin, int level) { + if (pin < 0) return; + + pinMode(pin, OUTPUT); + digitalWrite(pin, level); +} + +static void writeOptionalPin(int pin, int level) { + if (pin < 0) return; + + digitalWrite(pin, level); +} + +void NV3001BDisplay::writeCommand(uint8_t cmd) { + spi.beginTransaction(SPISettings(SPI_FREQUENCY, MSBFIRST, SPI_MODE0)); + digitalWrite(PIN_TFT_DC, LOW); + digitalWrite(PIN_TFT_CS, LOW); + spi.transfer(cmd); + digitalWrite(PIN_TFT_CS, HIGH); + spi.endTransaction(); +} + +void NV3001BDisplay::writeBytes(const uint8_t* data, size_t len) { + if (!data || len == 0) return; + + spi.beginTransaction(SPISettings(SPI_FREQUENCY, MSBFIRST, SPI_MODE0)); + digitalWrite(PIN_TFT_DC, HIGH); + digitalWrite(PIN_TFT_CS, LOW); + for (size_t i = 0; i < len; i++) { + spi.transfer(data[i]); + } + digitalWrite(PIN_TFT_CS, HIGH); + spi.endTransaction(); +} + +void NV3001BDisplay::writeCommandData(uint8_t cmd, const uint8_t* data, size_t len) { + writeCommand(cmd); + writeBytes(data, len); +} + +void NV3001BDisplay::setAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { + uint16_t x2 = x + w - 1; + uint16_t y2 = y + h - 1; + uint8_t data[4]; + + data[0] = x >> 8; + data[1] = x & 0xff; + data[2] = x2 >> 8; + data[3] = x2 & 0xff; + writeCommandData(NV3001B_CASET, data, sizeof(data)); + + data[0] = y >> 8; + data[1] = y & 0xff; + data[2] = y2 >> 8; + data[3] = y2 & 0xff; + writeCommandData(NV3001B_RASET, data, sizeof(data)); + + writeCommand(NV3001B_RAMWR); +} + +void NV3001BDisplay::writeColor(uint16_t rgb, uint32_t count) { + uint8_t hi = rgb >> 8; + uint8_t lo = rgb & 0xff; + + spi.beginTransaction(SPISettings(SPI_FREQUENCY, MSBFIRST, SPI_MODE0)); + digitalWrite(PIN_TFT_DC, HIGH); + digitalWrite(PIN_TFT_CS, LOW); + while (count--) { + spi.transfer(hi); + spi.transfer(lo); + } + digitalWrite(PIN_TFT_CS, HIGH); + spi.endTransaction(); +} + +void NV3001BDisplay::initPanel() { +#define CMD0(C) do { writeCommand(C); } while (0) +#define CMD1(C, A) do { const uint8_t d[] = { A }; writeCommandData(C, d, sizeof(d)); } while (0) +#define CMD2(C, A, B) do { const uint8_t d[] = { A, B }; writeCommandData(C, d, sizeof(d)); } while (0) + + CMD0(NV3001B_SWRESET); + delay(120); + CMD1(0xFF, 0xA5); + CMD1(0x41, 0x00); + CMD1(0x50, 0x02); + CMD1(0x52, 0x6E); + CMD1(0x57, 0x02); + CMD1(0x46, 0x11); + CMD2(0x47, 0x00, 0x01); + CMD2(0x8F, 0x22, 0x03); + CMD1(0x9A, 0x78); + CMD1(0x9B, 0x78); + CMD1(0x9C, 0xA0); + CMD1(0x9D, 0x17); + CMD1(0x9E, 0xC1); + CMD1(0x83, 0x5A); + CMD1(0x84, 0xB6); + CMD1(0xFF, 0xA5); + CMD1(0x85, 0x5F); + CMD1(0x6E, 0x0F); + CMD1(0x7E, 0x0F); + CMD1(0x60, 0x00); + CMD1(0x70, 0x00); + CMD1(0x6D, 0x33); + CMD1(0x7D, 0x37); + CMD1(0x61, 0x09); + CMD1(0x71, 0x0A); + CMD1(0x6C, 0x2A); + CMD1(0x7C, 0x36); + CMD1(0x62, 0x11); + CMD1(0x72, 0x10); + CMD1(0x68, 0x4E); + CMD1(0x78, 0x4E); + CMD1(0x66, 0x36); + CMD1(0x76, 0x3C); + CMD1(0x1A, 0x1C); + CMD1(0x7B, 0x14); + CMD1(0x63, 0x0D); + CMD1(0x73, 0x0A); + CMD1(0x6A, 0x16); + CMD1(0x7A, 0x12); + CMD1(0x64, 0x0B); + CMD1(0x74, 0x0A); + CMD1(0x69, 0x08); + CMD1(0x79, 0x0A); + CMD1(0x65, 0x06); + CMD1(0x75, 0x07); + CMD1(0x67, 0x23); + CMD1(0x77, 0x44); + CMD1(0xE0, 0x00); + CMD1(0xE9, 0x30); + CMD1(0xEB, 0xB7); + CMD1(0xEC, 0x00); + CMD1(0xED, 0x11); + CMD1(0xF0, 0xB7); + CMD1(0x53, 0x04); + CMD1(0x54, 0x04); + CMD1(0x55, 0x40); + CMD1(0x56, 0x40); + CMD2(0xA0, 0x60, 0x01); + CMD1(0xA1, 0x84); + CMD1(0xA2, 0x85); + CMD2(0xAB, 0x00, 0x02); + CMD2(0xAC, 0x00, 0x06); + CMD2(0xAD, 0x00, 0x03); + CMD2(0xAE, 0x00, 0x07); + CMD1(0xC7, 0x01); + CMD1(0xB9, 0x82); + CMD1(0xBA, 0x83); + CMD1(0xBB, 0x00); + CMD1(0xBC, 0x81); + CMD1(0xBD, 0x02); + CMD1(0xBE, 0x01); + CMD1(0xBF, 0x04); + CMD1(0xC0, 0x03); + CMD1(0xC8, 0x55); + CMD1(0xC9, 0xC9); + CMD1(0xCA, 0xC8); + CMD1(0xCB, 0xCB); + CMD1(0xCC, 0xCA); + CMD1(0xCD, 0x55); + CMD1(0xCE, 0xCE); + CMD1(0xCF, 0xCD); + CMD1(0xD0, 0xD0); + CMD1(0xD1, 0xCF); + CMD1(0xF2, 0x46); + CMD1(0xA8, 0x04); + CMD1(0xA9, 0xB0); + CMD1(0xAA, 0xA3); + CMD1(0xB6, 0x00); + CMD1(0xB7, 0xB0); + CMD1(0xB8, 0xA3); + CMD1(0xC4, 0x03); + CMD1(0xC5, 0xB0); + CMD1(0xC6, 0xA3); + CMD1(0x80, 0x10); + CMD1(0xFF, 0x00); + CMD1(0x35, 0x00); + CMD0(NV3001B_SLPOUT); + delay(120); + CMD1(NV3001B_COLMOD, 0x05); + CMD1(NV3001B_MADCTL, nv3001bMADCTL(DISPLAY_ROTATION)); + CMD0(NV3001B_DISPON); + delay(10); + +#undef CMD0 +#undef CMD1 +#undef CMD2 +} + +void NV3001BDisplay::fillPhysicalRect(int x, int y, int w, int h) { + if (!is_on || w <= 0 || h <= 0) return; + + if (x < 0) { + w += x; + x = 0; + } + if (y < 0) { + h += y; + y = 0; + } + if (x + w > NV3001B_SCREEN_WIDTH) w = NV3001B_SCREEN_WIDTH - x; + if (y + h > NV3001B_SCREEN_HEIGHT) h = NV3001B_SCREEN_HEIGHT - y; + if (w <= 0 || h <= 0) return; + + setAddrWindow(x, y, w, h); + writeColor(color, (uint32_t)w * h); +} + +void NV3001BDisplay::drawChar(int x, int y, char ch) { + if (ch < 32 || ch > 127) ch = '?'; + + uint16_t index = (uint16_t)(ch - 32) * 5; + int scale_x = textPixelScaleX(text_size); + int scale_y = textPixelScaleY(text_size); + for (int col = 0; col < 5; col++) { + uint8_t line = pgm_read_byte(font5x7 + index + col); + for (int row = 0; row < 7; row++) { + if (line & (1 << row)) { + fillPhysicalRect(x + col * scale_x, y + row * scale_y, scale_x, scale_y); + } + } + } +} + +bool NV3001BDisplay::begin() { + if (is_on) return true; + + if (periph_power) periph_power->claim(); + + setupOptionalOutput(PIN_TFT_EN, PIN_TFT_EN_ACTIVE); + setupOptionalOutput(PIN_TFT_BL, !PIN_TFT_BL_ACTIVE); + pinMode(PIN_TFT_CS, OUTPUT); + pinMode(PIN_TFT_DC, OUTPUT); + digitalWrite(PIN_TFT_CS, HIGH); + digitalWrite(PIN_TFT_DC, HIGH); + delay(20); + + spi.begin(PIN_TFT_SCL, PIN_TFT_MISO, PIN_TFT_SDA, PIN_TFT_CS); + if (PIN_TFT_RST >= 0) { + pinMode(PIN_TFT_RST, OUTPUT); + digitalWrite(PIN_TFT_RST, HIGH); + delay(10); + digitalWrite(PIN_TFT_RST, LOW); + delay(20); + digitalWrite(PIN_TFT_RST, HIGH); + delay(120); + } + + initPanel(); + is_on = true; + color = 0x0000; + fillPhysicalRect(0, 0, NV3001B_SCREEN_WIDTH, NV3001B_SCREEN_HEIGHT); + color = 0xffff; + text_size = 1; + cursor_x = 0; + cursor_y = 0; + writeOptionalPin(PIN_TFT_BL, PIN_TFT_BL_ACTIVE); + return true; +} + +void NV3001BDisplay::turnOn() { + begin(); +} + +void NV3001BDisplay::turnOff() { + if (!is_on) return; + + writeOptionalPin(PIN_TFT_BL, !PIN_TFT_BL_ACTIVE); + writeOptionalPin(PIN_TFT_EN, !PIN_TFT_EN_ACTIVE); + is_on = false; + if (periph_power) periph_power->release(); +} + +void NV3001BDisplay::clear() { + uint16_t saved = color; + color = UIColor::window_bkg; + fillPhysicalRect(0, 0, NV3001B_SCREEN_WIDTH, NV3001B_SCREEN_HEIGHT); + color = saved; +} + +void NV3001BDisplay::startFrame(ColorVal bkg) { + color = bkg; + fillPhysicalRect(0, 0, NV3001B_SCREEN_WIDTH, NV3001B_SCREEN_HEIGHT); + color = UIColor::primary_txt; + text_size = 1; + cursor_x = 0; + cursor_y = 0; +} + +void NV3001BDisplay::setTextSize(int sz) { + text_size = sz < 1 ? 1 : sz; +} + +void NV3001BDisplay::setColor(ColorVal c) { + color = c; +} + +void NV3001BDisplay::setCursor(int x, int y) { + cursor_x = scaleX(x); + cursor_y = scaleY(y); +} + +void NV3001BDisplay::print(const char* str) { + if (!str || !is_on) return; + + int scale_x = textPixelScaleX(text_size); + int scale_y = textPixelScaleY(text_size); + while (*str) { + if (*str == '\n') { + cursor_x = 0; + cursor_y += 8 * scale_y; + } else if (*str == '\r') { + cursor_x = 0; + } else { + drawChar(cursor_x, cursor_y, *str); + cursor_x += 6 * scale_x; + } + str++; + } +} + +void NV3001BDisplay::fillRect(int x, int y, int w, int h) { + fillPhysicalRect(scaleX(x), scaleY(y), scaleWidth(x, w), scaleHeight(y, h)); +} + +void NV3001BDisplay::drawRect(int x, int y, int w, int h) { + int x1 = scaleX(x); + int y1 = scaleY(y); + int sw = scaleWidth(x, w); + int sh = scaleHeight(y, h); + + fillPhysicalRect(x1, y1, sw, 1); + fillPhysicalRect(x1, y1 + sh - 1, sw, 1); + fillPhysicalRect(x1, y1, 1, sh); + fillPhysicalRect(x1 + sw - 1, y1, 1, sh); +} + +void NV3001BDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { + if (!bits || !is_on) return; + + int byte_width = (w + 7) / 8; + for (int j = 0; j < h; j++) { + for (int i = 0; i < w; i++) { + uint8_t byte = pgm_read_byte(bits + j * byte_width + i / 8); + if (byte & (0x80 >> (i & 7))) { + fillPhysicalRect(scaleX(x + i), scaleY(y + j), scaleWidth(x + i, 1), scaleHeight(y + j, 1)); + } + } + } +} + +uint16_t NV3001BDisplay::getTextWidth(const char* str) { + if (!str) return 0; + + uint16_t len = 0; + while (str[len] && str[len] != '\n' && str[len] != '\r') len++; + return (uint16_t)((len * 6 * textPixelScaleX(text_size)) / DISPLAY_SCALE_X); +} + +void NV3001BDisplay::endFrame() { +} diff --git a/src/helpers/ui/NV3001BDisplay.h b/src/helpers/ui/NV3001BDisplay.h new file mode 100644 index 000000000..76505b5d2 --- /dev/null +++ b/src/helpers/ui/NV3001BDisplay.h @@ -0,0 +1,68 @@ +#pragma once + +#include "DisplayDriver.h" +#include +#include + +#ifndef NV3001B_LOGICAL_WIDTH + #define NV3001B_LOGICAL_WIDTH 128 +#endif + +#ifndef NV3001B_LOGICAL_HEIGHT + #define NV3001B_LOGICAL_HEIGHT 64 +#endif + +#ifndef NV3001B_PANEL_WIDTH + #define NV3001B_PANEL_WIDTH 128 +#endif + +#ifndef NV3001B_PANEL_HEIGHT + #define NV3001B_PANEL_HEIGHT 220 +#endif + +#ifndef NV3001B_SPI_HOST + #define NV3001B_SPI_HOST HSPI +#endif + +class NV3001BDisplay : public DisplayDriver { + SPIClass spi; + RefCountedDigitalPin* periph_power; + bool is_on = false; + uint16_t color = 0xffff; + uint8_t text_size = 1; + int cursor_x = 0; + int cursor_y = 0; + + void writeCommand(uint8_t cmd); + void writeBytes(const uint8_t* data, size_t len); + void writeCommandData(uint8_t cmd, const uint8_t* data, size_t len); + void setAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h); + void writeColor(uint16_t rgb, uint32_t count); + void fillPhysicalRect(int x, int y, int w, int h); + void initPanel(); + void drawChar(int x, int y, char ch); + +public: + NV3001BDisplay(RefCountedDigitalPin* power = nullptr) : + DisplayDriver(NV3001B_LOGICAL_WIDTH, NV3001B_LOGICAL_HEIGHT), spi(NV3001B_SPI_HOST), periph_power(power) { } + + bool begin(); + static const char* driverName() { return "NV3001B"; } + static uint16_t physicalWidth() { return NV3001B_PANEL_WIDTH; } + static uint16_t physicalHeight() { return NV3001B_PANEL_HEIGHT; } + + bool isOn() override { return is_on; } + void turnOn() override; + void turnOff() override; + void clear() override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; + void setTextSize(int sz) override; + void setColor(ColorVal c) override; + void setCursor(int x, int y) override; + void print(const char* str) override; + void fillRect(int x, int y, int w, int h) override; + void drawRect(int x, int y, int w, int h) override; + void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; + uint16_t getTextWidth(const char* str) override; + void endFrame() override; +}; diff --git a/src/helpers/ui/NullDisplayDriver.cpp b/src/helpers/ui/NullDisplayDriver.cpp new file mode 100644 index 000000000..1500f5c1b --- /dev/null +++ b/src/helpers/ui/NullDisplayDriver.cpp @@ -0,0 +1,11 @@ +#include "NullDisplayDriver.h" + +ColorVal UIColor::window_bkg = 0; +ColorVal UIColor::title_bkg = 0; +ColorVal UIColor::title_txt = 0; +ColorVal UIColor::primary_txt = 0; +ColorVal UIColor::secondary_txt = 0; +ColorVal UIColor::warning_txt = 0; +ColorVal UIColor::popup_bkg = 0; +ColorVal UIColor::popup_txt = 0; +ColorVal UIColor::corp_blue = 0; diff --git a/src/helpers/ui/NullDisplayDriver.h b/src/helpers/ui/NullDisplayDriver.h index 2a9670bd2..ff214c883 100644 --- a/src/helpers/ui/NullDisplayDriver.h +++ b/src/helpers/ui/NullDisplayDriver.h @@ -11,9 +11,9 @@ public: void turnOn() override { } void turnOff() override { } void clear() override { } - void startFrame(Color bkg = DARK) override { } + void startFrame(ColorVal bkg = UIColor::window_bkg) override { } void setTextSize(int sz) override { } - void setColor(Color c) override { } + void setColor(ColorVal c) override { } void setCursor(int x, int y) override { } void print(const char* str) override { } void fillRect(int x, int y, int w, int h) override { } diff --git a/src/helpers/ui/OLEDDisplay.cpp b/src/helpers/ui/OLEDDisplay.cpp index a5d647a7f..be4912a53 100644 --- a/src/helpers/ui/OLEDDisplay.cpp +++ b/src/helpers/ui/OLEDDisplay.cpp @@ -1166,7 +1166,7 @@ char DefaultFontTableLookup(const uint8_t ch) { uint8_t last = LASTCHAR; // get last char LASTCHAR = ch; - switch (last) { // conversion depnding on first UTF8-character + switch (last) { // conversion depending on first UTF8-character case 0xC2: return (uint8_t) ch; case 0xC3: return (uint8_t) (ch | 0xC0); case 0x82: if (ch == 0xAC) return (uint8_t) 0x80; // special case Euro-symbol diff --git a/src/helpers/ui/RotaryInput.h b/src/helpers/ui/RotaryInput.h new file mode 100644 index 000000000..2dda695be --- /dev/null +++ b/src/helpers/ui/RotaryInput.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +enum class RotaryInputEvent : uint8_t { + None, + Next, + Prev, +}; + +class RotaryInput { +public: + virtual ~RotaryInput() = default; + + virtual bool begin() = 0; + virtual RotaryInputEvent poll() = 0; + virtual bool isReady() const = 0; +}; diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index f383bb005..c3840c02a 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -9,9 +9,23 @@ bool SH1106Display::i2c_probe(TwoWire &wire, uint8_t addr) return (error == 0); } +// Color scheme +ColorVal UIColor::window_bkg = SH110X_BLACK; +ColorVal UIColor::title_bkg = SH110X_BLACK; +ColorVal UIColor::title_txt = SH110X_WHITE; +ColorVal UIColor::primary_txt = SH110X_WHITE; +ColorVal UIColor::secondary_txt = SH110X_WHITE; +ColorVal UIColor::warning_txt = SH110X_WHITE; +ColorVal UIColor::popup_bkg = SH110X_BLACK; +ColorVal UIColor::popup_txt = SH110X_WHITE; +ColorVal UIColor::corp_blue = SH110X_WHITE; + bool SH1106Display::begin() { - return display.begin(DISPLAY_ADDRESS, true) && i2c_probe(Wire, DISPLAY_ADDRESS); + // Wire must already be initialised by board.begin() before this is called. + // Boards with non-standard SH1106 addresses should define DISPLAY_ADDRESS + // in their variant/platformio configuration. + return i2c_probe(Wire, DISPLAY_ADDRESS) && display.begin(DISPLAY_ADDRESS, true); } void SH1106Display::turnOn() @@ -32,7 +46,7 @@ void SH1106Display::clear() display.display(); } -void SH1106Display::startFrame(Color bkg) +void SH1106Display::startFrame(ColorVal bkg) { display.clearDisplay(); // TODO: apply 'bkg' _color = SH110X_WHITE; @@ -46,9 +60,9 @@ void SH1106Display::setTextSize(int sz) display.setTextSize(sz); } -void SH1106Display::setColor(Color c) +void SH1106Display::setColor(ColorVal c) { - _color = (c != 0) ? SH110X_WHITE : SH110X_BLACK; + _color = c; display.setTextColor(_color); } @@ -74,7 +88,7 @@ void SH1106Display::drawRect(int x, int y, int w, int h) void SH1106Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) { - display.drawBitmap(x, y, bits, w, h, SH110X_WHITE); + display.drawBitmap(x, y, bits, w, h, _color); } uint16_t SH1106Display::getTextWidth(const char *str) diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index b52a6adfb..4e269d5ea 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -30,9 +30,9 @@ public: void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char *str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index 464b2642a..ab2d77fbc 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -6,6 +6,17 @@ bool SSD1306Display::i2c_probe(TwoWire& wire, uint8_t addr) { return (error == 0); } +// Color scheme +ColorVal UIColor::window_bkg = SSD1306_BLACK; +ColorVal UIColor::title_bkg = SSD1306_BLACK; +ColorVal UIColor::title_txt = SSD1306_WHITE; +ColorVal UIColor::primary_txt = SSD1306_WHITE; +ColorVal UIColor::secondary_txt = SSD1306_WHITE; +ColorVal UIColor::warning_txt = SSD1306_WHITE; +ColorVal UIColor::popup_bkg = SSD1306_BLACK; +ColorVal UIColor::popup_txt = SSD1306_WHITE; +ColorVal UIColor::corp_blue = SSD1306_WHITE; + bool SSD1306Display::begin() { if (!_isOn) { if (_peripher_power) _peripher_power->claim(); @@ -44,7 +55,7 @@ void SSD1306Display::clear() { display.display(); } -void SSD1306Display::startFrame(Color bkg) { +void SSD1306Display::startFrame(ColorVal bkg) { display.clearDisplay(); // TODO: apply 'bkg' _color = SSD1306_WHITE; display.setTextColor(_color); @@ -56,8 +67,8 @@ void SSD1306Display::setTextSize(int sz) { display.setTextSize(sz); } -void SSD1306Display::setColor(Color c) { - _color = (c != 0) ? SSD1306_WHITE : SSD1306_BLACK; +void SSD1306Display::setColor(ColorVal c) { + _color = c; display.setTextColor(_color); } @@ -78,7 +89,7 @@ void SSD1306Display::drawRect(int x, int y, int w, int h) { } void SSD1306Display::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { - display.drawBitmap(x, y, bits, w, h, SSD1306_WHITE); + display.drawBitmap(x, y, bits, w, h, _color); } uint16_t SSD1306Display::getTextWidth(const char* str) { diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index d843da85b..5dabbc58c 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -35,9 +35,9 @@ public: void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index a6087dd81..413ea7ec4 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -1,12 +1,413 @@ #include "ST7735Display.h" -#ifndef DISPLAY_ROTATION - #define DISPLAY_ROTATION 2 +//#include + +// Optimised ST7735 display driver, derived from Adafruit_ST7735 library. + +#define ST_CMD_DELAY 0x80 // special signifier for command lists + +#define ST77XX_NOP 0x00 +#define ST77XX_SWRESET 0x01 +#define ST77XX_RDDID 0x04 +#define ST77XX_RDDST 0x09 + +#define ST77XX_SLPIN 0x10 +#define ST77XX_SLPOUT 0x11 +#define ST77XX_PTLON 0x12 +#define ST77XX_NORON 0x13 + +#define ST77XX_INVOFF 0x20 +#define ST77XX_INVON 0x21 +#define ST77XX_DISPOFF 0x28 +#define ST77XX_DISPON 0x29 +#define ST77XX_CASET 0x2A +#define ST77XX_RASET 0x2B +#define ST77XX_RAMWR 0x2C +#define ST77XX_RAMRD 0x2E + +#define ST77XX_PTLAR 0x30 +#define ST77XX_TEOFF 0x34 +#define ST77XX_TEON 0x35 +#define ST77XX_MADCTL 0x36 +#define ST77XX_COLMOD 0x3A + +#define ST77XX_MADCTL_MY 0x80 +#define ST77XX_MADCTL_MX 0x40 +#define ST77XX_MADCTL_MV 0x20 +#define ST77XX_MADCTL_ML 0x10 +#define ST77XX_MADCTL_RGB 0x00 + +#define ST77XX_RDID1 0xDA +#define ST77XX_RDID2 0xDB +#define ST77XX_RDID3 0xDC +#define ST77XX_RDID4 0xDD + +// Some ready-made 16-bit ('565') color settings: +#define ST77XX_BLACK 0x0000 +#define ST77XX_WHITE 0xFFFF +#define ST77XX_RED 0xF800 +#define ST77XX_GREEN 0x07E0 +#define ST77XX_BLUE 0x001F +#define ST77XX_CYAN 0x07FF +#define ST77XX_MAGENTA 0xF81F +#define ST77XX_YELLOW 0xFFE0 +#define ST77XX_ORANGE 0xFC00 + + +// some flags for initR() :( +#define INITR_GREENTAB 0x00 +#define INITR_REDTAB 0x01 +#define INITR_BLACKTAB 0x02 +#define INITR_18GREENTAB INITR_GREENTAB +#define INITR_18REDTAB INITR_REDTAB +#define INITR_18BLACKTAB INITR_BLACKTAB +#define INITR_144GREENTAB 0x01 +#define INITR_MINI160x80 0x04 +#define INITR_HALLOWING 0x05 +#define INITR_MINI160x80_PLUGIN 0x06 + +// Some register settings +#define ST7735_MADCTL_BGR 0x08 +#define ST7735_MADCTL_MH 0x04 + +#define ST7735_FRMCTR1 0xB1 +#define ST7735_FRMCTR2 0xB2 +#define ST7735_FRMCTR3 0xB3 +#define ST7735_INVCTR 0xB4 +#define ST7735_DISSET5 0xB6 + +#define ST7735_PWCTR1 0xC0 +#define ST7735_PWCTR2 0xC1 +#define ST7735_PWCTR3 0xC2 +#define ST7735_PWCTR4 0xC3 +#define ST7735_PWCTR5 0xC4 +#define ST7735_VMCTR1 0xC5 + +#define ST7735_PWCTR6 0xFC + +#define ST7735_GMCTRP1 0xE0 +#define ST7735_GMCTRN1 0xE1 + +// Some ready-made 16-bit ('565') color settings: +#define ST7735_BLACK ST77XX_BLACK +#define ST7735_WHITE ST77XX_WHITE +#define ST7735_RED ST77XX_RED +#define ST7735_GREEN ST77XX_GREEN +#define ST7735_BLUE ST77XX_BLUE +#define ST7735_CYAN ST77XX_CYAN +#define ST7735_MAGENTA ST77XX_MAGENTA +#define ST7735_YELLOW ST77XX_YELLOW +#define ST7735_ORANGE ST77XX_ORANGE + +static TFT_eSPI lcd = TFT_eSPI(160, 80); +static uint32_t curr_color; + +// HELTEC_TRACKER_V1_1 added to upstream's HSPI guard (d30d8ed7): that board uses +// the same ST7735 panel and also needs a dedicated HSPI instance. Without it the +// #else branch references SPI1, which is not instantiated on ESP32, so every +// heltec_tracker_v1_1 env fails to compile. +#if defined(HELTEC_LORA_V3) || defined(HELTEC_TRACKER_V2) || defined(HELTEC_TRACKER_V1_1) + static SPIClass tft_spi(HSPI); + #define _spi (&tft_spi) +#else + #define _spi (&SPI1) #endif +SPISettings _spiSettings = SPISettings(40000000, MSBFIRST, SPI_MODE0); + +// clang-format off +static const uint8_t PROGMEM + Bcmd[] = { // Init commands for 7735B screens + 18, // 18 commands in list: + ST77XX_SWRESET, ST_CMD_DELAY, // 1: Software reset, no args, w/delay + 50, // 50 ms delay + ST77XX_SLPOUT, ST_CMD_DELAY, // 2: Out of sleep mode, no args, w/delay + 255, // 255 = max (500 ms) delay + ST77XX_COLMOD, 1+ST_CMD_DELAY, // 3: Set color mode, 1 arg + delay: + 0x05, // 16-bit color + 10, // 10 ms delay + ST7735_FRMCTR1, 3+ST_CMD_DELAY, // 4: Frame rate control, 3 args + delay: + 0x00, // fastest refresh + 0x06, // 6 lines front porch + 0x03, // 3 lines back porch + 10, // 10 ms delay + ST77XX_MADCTL, 1, // 5: Mem access ctl (directions), 1 arg: + 0x08, // Row/col addr, bottom-top refresh + ST7735_DISSET5, 2, // 6: Display settings #5, 2 args: + 0x15, // 1 clk cycle nonoverlap, 2 cycle gate + // rise, 3 cycle osc equalize + 0x02, // Fix on VTL + ST7735_INVCTR, 1, // 7: Display inversion control, 1 arg: + 0x0, // Line inversion + ST7735_PWCTR1, 2+ST_CMD_DELAY, // 8: Power control, 2 args + delay: + 0x02, // GVDD = 4.7V + 0x70, // 1.0uA + 10, // 10 ms delay + ST7735_PWCTR2, 1, // 9: Power control, 1 arg, no delay: + 0x05, // VGH = 14.7V, VGL = -7.35V + ST7735_PWCTR3, 2, // 10: Power control, 2 args, no delay: + 0x01, // Opamp current small + 0x02, // Boost frequency + ST7735_VMCTR1, 2+ST_CMD_DELAY, // 11: Power control, 2 args + delay: + 0x3C, // VCOMH = 4V + 0x38, // VCOML = -1.1V + 10, // 10 ms delay + ST7735_PWCTR6, 2, // 12: Power control, 2 args, no delay: + 0x11, 0x15, + ST7735_GMCTRP1,16, // 13: Gamma Adjustments (pos. polarity), 16 args + delay: + 0x09, 0x16, 0x09, 0x20, // (Not entirely necessary, but provides + 0x21, 0x1B, 0x13, 0x19, // accurate colors) + 0x17, 0x15, 0x1E, 0x2B, + 0x04, 0x05, 0x02, 0x0E, + ST7735_GMCTRN1,16+ST_CMD_DELAY, // 14: Gamma Adjustments (neg. polarity), 16 args + delay: + 0x0B, 0x14, 0x08, 0x1E, // (Not entirely necessary, but provides + 0x22, 0x1D, 0x18, 0x1E, // accurate colors) + 0x1B, 0x1A, 0x24, 0x2B, + 0x06, 0x06, 0x02, 0x0F, + 10, // 10 ms delay + ST77XX_CASET, 4, // 15: Column addr set, 4 args, no delay: + 0x00, 0x02, // XSTART = 2 + 0x00, 0x81, // XEND = 129 + ST77XX_RASET, 4, // 16: Row addr set, 4 args, no delay: + 0x00, 0x02, // XSTART = 1 + 0x00, 0x81, // XEND = 160 + ST77XX_NORON, ST_CMD_DELAY, // 17: Normal display on, no args, w/delay + 10, // 10 ms delay + ST77XX_DISPON, ST_CMD_DELAY, // 18: Main screen turn on, no args, delay + 255 }, // 255 = max (500 ms) delay + + Rcmd1[] = { // 7735R init, part 1 (red or green tab) + 14, // 14 commands in list: + /*ST77XX_SWRESET, ST_CMD_DELAY, // 1: Software reset, 0 args, w/delay + 150, */ // 150 ms delay + ST77XX_SLPOUT, ST_CMD_DELAY, // 2: Out of sleep mode, 0 args, w/delay + 120, // 120 ms delay + ST7735_FRMCTR1, 3, // 3: Framerate ctrl - normal mode, 3 arg: + 0x01, 0x2C, 0x2D, // Rate = fosc/(1x2+40) * (LINE+2C+2D) + ST7735_FRMCTR2, 3, // 4: Framerate ctrl - idle mode, 3 args: + 0x01, 0x2C, 0x2D, // Rate = fosc/(1x2+40) * (LINE+2C+2D) + ST7735_FRMCTR3, 6, // 5: Framerate - partial mode, 6 args: + 0x01, 0x2C, 0x2D, // Dot inversion mode + 0x01, 0x2C, 0x2D, // Line inversion mode + ST7735_INVCTR, 1, // 6: Display inversion ctrl, 1 arg: + 0x07, // No inversion + ST7735_PWCTR1, 3, // 7: Power control, 3 args, no delay: + 0xA2, + 0x02, // -4.6V + 0x84, // AUTO mode + ST7735_PWCTR2, 1, // 8: Power control, 1 arg, no delay: + 0xC5, // VGH25=2.4C VGSEL=-10 VGH=3 * AVDD + ST7735_PWCTR3, 2, // 9: Power control, 2 args, no delay: + 0x0A, // Opamp current small + 0x00, // Boost frequency + ST7735_PWCTR4, 2, // 10: Power control, 2 args, no delay: + 0x8A, // BCLK/2, + 0x2A, // opamp current small & medium low + ST7735_PWCTR5, 2, // 11: Power control, 2 args, no delay: + 0x8A, 0xEE, + ST7735_VMCTR1, 1, // 12: Power control, 1 arg, no delay: + 0x0E, + ST77XX_INVOFF, 0, // 13: Don't invert display, no args + ST77XX_MADCTL, 1, // 14: Mem access ctl (directions), 1 arg: + 0xC8, // row/col addr, bottom-top refresh + ST77XX_COLMOD, 1, // 15: set color mode, 1 arg, no delay: + 0x05 }, // 16-bit color + + Rcmd2green[] = { // 7735R init, part 2 (green tab only) + 2, // 2 commands in list: + ST77XX_CASET, 4, // 1: Column addr set, 4 args, no delay: + 0x00, 0x02, // XSTART = 0 + 0x00, 0x7F+0x02, // XEND = 127 + ST77XX_RASET, 4, // 2: Row addr set, 4 args, no delay: + 0x00, 0x01, // XSTART = 0 + 0x00, 0x9F+0x01 }, // XEND = 159 + + Rcmd2red[] = { // 7735R init, part 2 (red tab only) + 2, // 2 commands in list: + ST77XX_CASET, 4, // 1: Column addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x7F, // XEND = 127 + ST77XX_RASET, 4, // 2: Row addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x9F }, // XEND = 159 + + Rcmd2green144[] = { // 7735R init, part 2 (green 1.44 tab) + 2, // 2 commands in list: + ST77XX_CASET, 4, // 1: Column addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x7F, // XEND = 127 + ST77XX_RASET, 4, // 2: Row addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x7F }, // XEND = 127 + + Rcmd2green160x80[] = { // 7735R init, part 2 (mini 160x80) + 2, // 2 commands in list: + ST77XX_CASET, 4, // 1: Column addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x4F, // XEND = 79 + ST77XX_RASET, 4, // 2: Row addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x9F }, // XEND = 159 + + Rcmd2green160x80plugin[] = { // 7735R init, part 2 (mini 160x80 with plugin FPC) + 3, // 3 commands in list: + ST77XX_INVON, 0, // 1: Display is inverted + ST77XX_CASET, 4, // 2: Column addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x4F, // XEND = 79 + ST77XX_RASET, 4, // 3: Row addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x9F }, // XEND = 159 + + Rcmd2invert[] = { // Tracker V1, part 2 + 1, // 1 command in list: + ST77XX_INVON, 0 }, // 1: Display is inverted + + Rcmd3[] = { // 7735R init, part 3 (red or green tab) + 2, // 2 commands in list: + ST7735_GMCTRP1, 16 , // 1: Gamma Adjustments (pos. polarity), 16 args + delay: + 0x02, 0x1c, 0x07, 0x12, // (Not entirely necessary, but provides + 0x37, 0x32, 0x29, 0x2d, // accurate colors) + 0x29, 0x25, 0x2B, 0x39, + 0x00, 0x01, 0x03, 0x10, + ST7735_GMCTRN1, 16 , // 2: Gamma Adjustments (neg. polarity), 16 args + delay: + 0x03, 0x1d, 0x07, 0x06, // (Not entirely necessary, but provides + 0x2E, 0x2C, 0x29, 0x2D, // accurate colors) + 0x2E, 0x2E, 0x37, 0x3F, + 0x00, 0x00, 0x02, 0x10 }; // 100 ms delay + +static int16_t _xstart = 0; ///< Internal framebuffer X offset +static int16_t _ystart = 0; ///< Internal framebuffer Y offset +static uint8_t _colstart = 0; ///< Some displays need this changed to offset +static uint8_t _rowstart = 0; ///< Some displays need this changed to offset +static uint8_t rotation = 0; +static int16_t _width = 0; ///< Display width as modified by current rotation +static int16_t _height = 0; ///< Display height as modified by current rotation + +static void set_CS(uint8_t level) { + //if (_cs != (uint8_t) -1) { + digitalWrite(PIN_TFT_CS, level); + //} +} +static void sendCommand(uint8_t com) { + set_CS(HIGH); + digitalWrite(PIN_TFT_DC, LOW); + set_CS(LOW); + _spi->beginTransaction(_spiSettings); + _spi->transfer(com); + _spi->endTransaction(); + set_CS(HIGH); + digitalWrite(PIN_TFT_DC, HIGH); +} + +static void WriteData(uint8_t data) { + digitalWrite(PIN_TFT_CS, LOW); + _spi->beginTransaction(_spiSettings); + _spi->transfer(data); + _spi->endTransaction(); + digitalWrite(PIN_TFT_CS, HIGH); +} +static void SPI_WRITE32(uint32_t l) { + _spi->transfer(l >> 24); + _spi->transfer(l >> 16); + _spi->transfer(l >> 8); + _spi->transfer(l); +} +static void writeCommand(uint8_t cmd) { + digitalWrite(PIN_TFT_DC, LOW); + _spi->transfer(cmd); + digitalWrite(PIN_TFT_DC, HIGH); +} + +static void displayInit(const uint8_t *addr) { + uint8_t numCommands, cmd, numArgs; + uint16_t ms; + + numCommands = pgm_read_byte(addr++); // Number of commands to follow + while (numCommands--) { // For each command... + cmd = pgm_read_byte(addr++); // Read command + numArgs = pgm_read_byte(addr++); // Number of args to follow + ms = numArgs & ST_CMD_DELAY; // If hibit set, delay follows args + numArgs &= ~ST_CMD_DELAY; // Mask out delay bit + sendCommand(cmd); + for (int k = 0; k < numArgs; k++) { + WriteData(addr[k]); + } + addr += numArgs; + + if (ms) { + ms = pgm_read_byte(addr++); // Read post-command delay time (ms) + if (ms == 255) + ms = 500; // If 255, delay for 500 ms + delay(ms); + } + } +} + +static void setRotation(uint8_t m) { + uint8_t madctl = 0; + + rotation = m & 3; // can't be higher than 3 + + switch (rotation) { + case 0: + madctl = ST77XX_MADCTL_MX | ST77XX_MADCTL_MY | ST7735_MADCTL_BGR; + + _height = 160; + _width = 80; + _xstart = _colstart; + _ystart = _rowstart; + break; + case 1: + madctl = ST77XX_MADCTL_MY | ST77XX_MADCTL_MV | ST7735_MADCTL_BGR; + + _width = 160; + _height = 80; + _ystart = _colstart; + _xstart = _rowstart; + break; + case 2: + madctl = ST7735_MADCTL_BGR; + + _height = 160; + _width = 80; + _xstart = _colstart; + _ystart = _rowstart; + break; + case 3: + madctl = ST77XX_MADCTL_MX | ST77XX_MADCTL_MV | ST7735_MADCTL_BGR; + + _width = 160; + _height = 80; + _ystart = _colstart; + _xstart = _rowstart; + break; + } + + sendCommand(ST77XX_MADCTL); + WriteData(madctl); +} + +static void setAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { + x += _xstart; + y += _ystart; + uint32_t xa = ((uint32_t)x << 16) | (x + w - 1); + uint32_t ya = ((uint32_t)y << 16) | (y + h - 1); + + writeCommand(ST77XX_CASET); // Column addr set + SPI_WRITE32(xa); + + writeCommand(ST77XX_RASET); // Row addr set + SPI_WRITE32(ya); + + writeCommand(ST77XX_RAMWR); // write to RAM +} + #define SCALE_X 1.25f // 160 / 128 #define SCALE_Y 1.25f // 80 / 64 +static TFT_eSprite *sprite = NULL; + bool ST7735Display::i2c_probe(TwoWire& wire, uint8_t addr) { return true; /* @@ -16,53 +417,121 @@ bool ST7735Display::i2c_probe(TwoWire& wire, uint8_t addr) { */ } +#ifndef PIN_TFT_LEDA_CTL_ACTIVE + #define PIN_TFT_LEDA_CTL_ACTIVE HIGH +#endif + +// Color scheme +ColorVal UIColor::window_bkg = ST77XX_WHITE; +ColorVal UIColor::title_bkg = ST77XX_BLUE; +ColorVal UIColor::title_txt = ST77XX_WHITE; +ColorVal UIColor::primary_txt = ST77XX_BLACK; +ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray +ColorVal UIColor::warning_txt = ST77XX_ORANGE; +ColorVal UIColor::popup_bkg = ST77XX_CYAN; +ColorVal UIColor::popup_txt = ST77XX_BLACK; +ColorVal UIColor::corp_blue = 0x001A; + bool ST7735Display::begin() { + if (!sprite) { + // alloc offscreen canvas + sprite = new TFT_eSprite(&lcd); + if (sprite) { + if (sprite->createSprite(160, 80)) { + sprite->fillScreen(ST77XX_BLACK); + sprite->setTextColor(curr_color = ST77XX_WHITE); + } else { + Serial.printf("ST7735Display: failed to alloc canvas pixels"); + } + } else { + Serial.printf("ST7735Display: failed to alloc canvas"); + } + } + if (!_isOn) { if (_peripher_power) _peripher_power->claim(); + delay(100); // TEMP!! + pinMode(PIN_TFT_RST, OUTPUT); + pinMode(PIN_TFT_CS, OUTPUT); + pinMode(PIN_TFT_DC, OUTPUT); pinMode(PIN_TFT_LEDA_CTL, OUTPUT); -#if defined(PIN_TFT_LEDA_CTL_ACTIVE) - digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE); -#else - digitalWrite(PIN_TFT_LEDA_CTL, HIGH); -#endif - digitalWrite(PIN_TFT_RST, HIGH); -#if defined(HELTEC_T1) - display.initR(INITR_MINI160x80); - display.setRotation(DISPLAY_ROTATION); -#elif defined(HELTEC_TRACKER_V2) || defined(HELTEC_T096) - display.initR(INITR_MINI160x80); - display.setRotation(DISPLAY_ROTATION); - uint8_t madctl = ST77XX_MADCTL_MY | ST77XX_MADCTL_MV |ST7735_MADCTL_BGR;//Adjust color to BGR - display.sendCommand(ST77XX_MADCTL, &madctl, 1); +#ifdef ESP_PLATFORM + _spi->begin(PIN_TFT_SCL, -1 /* _miso */, PIN_TFT_SDA /* _mosi */, -1); #else - display.initR(INITR_MINI160x80_PLUGIN); - display.setRotation(DISPLAY_ROTATION); + _spi->begin(); #endif - display.setSPISpeed(40000000); - display.fillScreen(ST77XX_BLACK); - display.setTextColor(ST77XX_WHITE); - display.setTextSize(2); - display.cp437(true); // Use full 256 char 'Code Page 437' font + _spi->setClockDivider(SPI_CLOCK_DIV2); + + _height = 80; + _width = 160; +#if defined(HELTEC_LORA_V3) // Tracker v1 + _colstart = 26; + _rowstart = 1; +#else + _colstart = 24; + _rowstart = 0; +#endif + + _resetAndInit(); + + sendCommand(ST77XX_DISPON); _isOn = true; } return true; } +void ST7735Display::_resetAndInit() { + // Pulse Reset low for 10ms + digitalWrite(PIN_TFT_RST, HIGH); + delay(2); + digitalWrite(PIN_TFT_RST, LOW); + delay(10); + digitalWrite(PIN_TFT_RST, HIGH); + delay(2); + + // run init commands + displayInit(Rcmd1); +#if defined(HELTEC_TRACKER_V2) || defined(HELTEC_T096) + displayInit(Rcmd2green160x80); + //uint8_t madctl = ST77XX_MADCTL_MY | ST77XX_MADCTL_MV |ST7735_MADCTL_BGR;//Adjust color to BGR + //display.sendCommand(ST77XX_MADCTL, &madctl, 1); +#elif defined(HELTEC_LORA_V3) // Tracker v1 + displayInit(Rcmd2invert); // invert RGB +#endif + displayInit(Rcmd3); + setRotation(DISPLAY_ROTATION); + + // clear the buffer before display on + sprite->fillScreen(ST77XX_BLACK); + endFrame(); + + // turn on backlight + digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE); + +} + void ST7735Display::turnOn() { - ST7735Display::begin(); + if (!_isOn) { + if (_peripher_power) _peripher_power->claim(); + _resetAndInit(); + sendCommand(ST77XX_DISPON); + + // Now turn on the backlight + // digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE); + _isOn = true; + } } void ST7735Display::turnOff() { if (_isOn) { - digitalWrite(PIN_TFT_RST, LOW); -#if defined(PIN_TFT_LEDA_CTL_ACTIVE) + sendCommand(ST77XX_DISPOFF); + + //digitalWrite(PIN_TFT_RST, LOW); + // Now turn off the backlight digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE); -#else - digitalWrite(PIN_TFT_LEDA_CTL, LOW); -#endif _isOn = false; if (_peripher_power) _peripher_power->release(); @@ -70,78 +539,65 @@ void ST7735Display::turnOff() { } void ST7735Display::clear() { - //Serial.println("DBG: display.Clear"); - display.fillScreen(ST77XX_BLACK); + sprite->fillScreen(ST77XX_BLACK); } -void ST7735Display::startFrame(Color bkg) { - display.fillScreen(0x00); - display.setTextColor(ST77XX_WHITE); - display.setTextSize(1); // This one affects size of Please wait... message - display.cp437(true); // Use full 256 char 'Code Page 437' font +void ST7735Display::startFrame(ColorVal bkg) { + sprite->fillScreen(bkg); + sprite->setTextColor(curr_color = UIColor::primary_txt); + sprite->setFreeFont(); + sprite->setTextSize(1); // This one affects size of Please wait... message + //sprite->cp437(true); // Use full 256 char 'Code Page 437' font } void ST7735Display::setTextSize(int sz) { - display.setTextSize(sz); + sprite->setTextSize(sz); } -void ST7735Display::setColor(Color c) { - switch (c) { - case DisplayDriver::DARK : - _color = ST77XX_BLACK; - break; - case DisplayDriver::LIGHT : - _color = ST77XX_WHITE; - break; - case DisplayDriver::RED : - _color = ST77XX_RED; - break; - case DisplayDriver::GREEN : - _color = ST77XX_GREEN; - break; - case DisplayDriver::BLUE : - _color = ST77XX_BLUE; - break; - case DisplayDriver::YELLOW : - _color = ST77XX_YELLOW; - break; - case DisplayDriver::ORANGE : - _color = ST77XX_ORANGE; - break; - default: - _color = ST77XX_WHITE; - break; - } - display.setTextColor(_color); +void ST7735Display::setColor(ColorVal c) { + curr_color = c; + sprite->setTextColor(curr_color); } void ST7735Display::setCursor(int x, int y) { - display.setCursor(x*SCALE_X, y*SCALE_Y); + sprite->setCursor(x*SCALE_X, y*SCALE_Y); } void ST7735Display::print(const char* str) { - display.print(str); + sprite->print(str); } void ST7735Display::fillRect(int x, int y, int w, int h) { - display.fillRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, _color); + sprite->fillRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, curr_color); } void ST7735Display::drawRect(int x, int y, int w, int h) { - display.drawRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, _color); + sprite->drawRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, curr_color); } void ST7735Display::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { - display.drawBitmap(x*SCALE_X, y*SCALE_Y, bits, w, h, _color); + sprite->drawBitmap(x*SCALE_X, y*SCALE_Y, bits, w, h, curr_color); } uint16_t ST7735Display::getTextWidth(const char* str) { - int16_t x1, y1; - uint16_t w, h; - display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); - return w / SCALE_X; + return sprite->textWidth(str) / SCALE_X; } void ST7735Display::endFrame() { - // display.display(); + // blit the canvas buffer to LCD + set_CS(LOW); + _spi->beginTransaction(_spiSettings); + uint16_t x, y; + uint16_t* pixels = (uint16_t *) ((TFT_eSprite *) sprite)->getPointer(); + for (y = 0; y < 80; y++, pixels += 160) { + setAddrWindow(0, y, 160, 1); +#ifdef ESP_PLATFORM + _spi->transferBytes((uint8_t *)pixels, NULL, 2 * 160); +#else + _spi->transfer(pixels, NULL, 2 * 160); +#endif + } + + _spi->endTransaction(); + set_CS(HIGH); } diff --git a/src/helpers/ui/ST7735Display.h b/src/helpers/ui/ST7735Display.h index 94797503f..6e289b219 100644 --- a/src/helpers/ui/ST7735Display.h +++ b/src/helpers/ui/ST7735Display.h @@ -3,28 +3,25 @@ #include "DisplayDriver.h" #include #include -#include -#include +#include "TFT_eSPI.h" #include class ST7735Display : public DisplayDriver { - Adafruit_ST7735 display; bool _isOn; - uint16_t _color; RefCountedDigitalPin* _peripher_power; bool i2c_probe(TwoWire& wire, uint8_t addr); public: #ifdef USE_PIN_TFT ST7735Display(RefCountedDigitalPin* peripher_power=NULL) : DisplayDriver(128, 64), - display(PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_SDA, PIN_TFT_SCL, PIN_TFT_RST), + // display(PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_SDA, PIN_TFT_SCL, PIN_TFT_RST), _peripher_power(peripher_power) { _isOn = false; } #else ST7735Display(RefCountedDigitalPin* peripher_power=NULL) : DisplayDriver(128, 64), - display(&SPI1, PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST), + // display(&SPI1, PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST), _peripher_power(peripher_power) { _isOn = false; @@ -36,9 +33,9 @@ public: void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; @@ -46,4 +43,7 @@ public: void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; uint16_t getTextWidth(const char* str) override; void endFrame() override; + +protected: + void _resetAndInit(); }; diff --git a/src/helpers/ui/ST7789Display.cpp b/src/helpers/ui/ST7789Display.cpp index f7d20b8ab..7d039a131 100644 --- a/src/helpers/ui/ST7789Display.cpp +++ b/src/helpers/ui/ST7789Display.cpp @@ -18,6 +18,25 @@ #define SCALE_Y 2.109375f // 135 / 64 #endif +#ifdef DISPLAY_SCALE_X + #define SCALE_X DISPLAY_SCALE_X +#endif + +#ifdef DISPLAY_SCALE_Y + #define SCALE_Y DISPLAY_SCALE_Y +#endif + +// Color scheme +ColorVal UIColor::window_bkg = OLEDDISPLAY_COLOR::BLACK; +ColorVal UIColor::title_bkg = OLEDDISPLAY_COLOR::BLACK; +ColorVal UIColor::title_txt = OLEDDISPLAY_COLOR::WHITE; +ColorVal UIColor::primary_txt = OLEDDISPLAY_COLOR::WHITE; +ColorVal UIColor::secondary_txt = OLEDDISPLAY_COLOR::WHITE; +ColorVal UIColor::warning_txt = OLEDDISPLAY_COLOR::WHITE; +ColorVal UIColor::popup_bkg = OLEDDISPLAY_COLOR::BLACK; +ColorVal UIColor::popup_txt = OLEDDISPLAY_COLOR::WHITE; +ColorVal UIColor::corp_blue = OLEDDISPLAY_COLOR::WHITE; + bool ST7789Display::begin() { if(!_isOn) { pinMode(PIN_TFT_VDD_CTL, OUTPUT); @@ -32,6 +51,9 @@ bool ST7789Display::begin() { display.init(); display.landscapeScreen(); + #ifdef DISPLAY_FLIP_VERTICALLY + display.flipScreenVertically(); + #endif display.displayOn(); setCursor(0,0); @@ -49,6 +71,9 @@ void ST7789Display::turnOn() { // Re-initialize the display display.init(); display.displayOn(); + #ifdef DISPLAY_FLIP_VERTICALLY + display.flipScreenVertically(); + #endif delay(20); // Now turn on the backlight @@ -76,10 +101,9 @@ void ST7789Display::clear() { display.clear(); } -void ST7789Display::startFrame(Color bkg) { - display.clear(); - _color = ST77XX_WHITE; - display.setRGB(_color); +void ST7789Display::startFrame(ColorVal bkg) { + display.clear(); // TODO: use bkg + setColor(UIColor::primary_txt); display.setFont(ArialMT_Plain_16); } @@ -96,38 +120,10 @@ void ST7789Display::setTextSize(int sz) { } } -void ST7789Display::setColor(Color c) { - switch (c) { - case DisplayDriver::DARK : - _color = ST77XX_BLACK; - display.setColor(OLEDDISPLAY_COLOR::BLACK); - break; -#if 0 - case DisplayDriver::LIGHT : - _color = ST77XX_WHITE; - break; - case DisplayDriver::RED : - _color = ST77XX_RED; - break; - case DisplayDriver::GREEN : - _color = ST77XX_GREEN; - break; - case DisplayDriver::BLUE : - _color = ST77XX_BLUE; - break; - case DisplayDriver::YELLOW : - _color = ST77XX_YELLOW; - break; - case DisplayDriver::ORANGE : - _color = ST77XX_ORANGE; - break; -#endif - default: - _color = ST77XX_WHITE; - display.setColor(OLEDDISPLAY_COLOR::WHITE); - break; - } - display.setRGB(_color); +void ST7789Display::setColor(ColorVal c) { + _color = c; + display.setColor((OLEDDISPLAY_COLOR)_color); + display.setRGB(_color == OLEDDISPLAY_COLOR::WHITE ? ST77XX_WHITE : ST77XX_BLACK); } void ST7789Display::setCursor(int x, int y) { diff --git a/src/helpers/ui/ST7789Display.h b/src/helpers/ui/ST7789Display.h index cb56ff8a7..580ad463f 100644 --- a/src/helpers/ui/ST7789Display.h +++ b/src/helpers/ui/ST7789Display.h @@ -14,8 +14,10 @@ class ST7789Display : public DisplayDriver { bool i2c_probe(TwoWire& wire, uint8_t addr); public: -#ifdef HELTEC_VISION_MASTER_T190 +#if defined(HELTEC_VISION_MASTER_T190) ST7789Display() : DisplayDriver(128, 64), display(&SPI, PIN_TFT_RST, PIN_TFT_DC, PIN_TFT_CS, GEOMETRY_RAWMODE, 320, 170,PIN_TFT_SDA,-1,PIN_TFT_SCL) {_isOn = false;} +#elif defined(THINKNODE_M9) + ST7789Display() : DisplayDriver(128, 64), display(&SPI, ST7789_RESET, ST7789_RS, ST7789_CS, GEOMETRY_RAWMODE, 320, 240, ST7789_SDA, ST7789_MISO, ST7789_SCK) {_isOn = false;} #else ST7789Display() : DisplayDriver(128, 64), display(&SPI1, PIN_TFT_RST, PIN_TFT_DC, PIN_TFT_CS, GEOMETRY_RAWMODE, 240, 135) {_isOn = false;} #endif @@ -25,9 +27,9 @@ public: void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void printWordWrap(const char* str, int max_width) override; diff --git a/src/helpers/ui/ST7789LCDDisplay.cpp b/src/helpers/ui/ST7789LCDDisplay.cpp index dc75e9631..a9f30dd56 100644 --- a/src/helpers/ui/ST7789LCDDisplay.cpp +++ b/src/helpers/ui/ST7789LCDDisplay.cpp @@ -1,5 +1,9 @@ #include "ST7789LCDDisplay.h" +#ifndef PIN_TFT_MISO + #define PIN_TFT_MISO -1 +#endif + #ifndef DISPLAY_ROTATION #define DISPLAY_ROTATION 3 #endif @@ -19,6 +23,17 @@ bool ST7789LCDDisplay::i2c_probe(TwoWire& wire, uint8_t addr) { return true; } +// Color scheme +ColorVal UIColor::window_bkg = ST77XX_WHITE; +ColorVal UIColor::title_bkg = ST77XX_BLUE; +ColorVal UIColor::title_txt = ST77XX_WHITE; +ColorVal UIColor::primary_txt = ST77XX_BLACK; +ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray +ColorVal UIColor::warning_txt = ST77XX_ORANGE; +ColorVal UIColor::popup_bkg = ST77XX_CYAN; +ColorVal UIColor::popup_txt = ST77XX_BLACK; +ColorVal UIColor::corp_blue = 0x001A; + bool ST7789LCDDisplay::begin() { if (!_isOn) { if (_peripher_power) _peripher_power->claim(); @@ -29,8 +44,8 @@ bool ST7789LCDDisplay::begin() { } // Im not sure if this is just a t-deck problem or not, if your display is slow try this. - #if defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) - displaySPI.begin(PIN_TFT_SCL, -1, PIN_TFT_SDA, PIN_TFT_CS); + #if defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) || defined(HELTEC_V4_R8_TFT) + displaySPI.begin(PIN_TFT_SCL, PIN_TFT_MISO, PIN_TFT_SDA, PIN_TFT_CS); #endif display.init(DISPLAY_WIDTH, DISPLAY_HEIGHT); @@ -74,9 +89,9 @@ void ST7789LCDDisplay::clear() { display.fillScreen(ST77XX_BLACK); } -void ST7789LCDDisplay::startFrame(Color bkg) { - display.fillScreen(ST77XX_BLACK); - display.setTextColor(ST77XX_WHITE); +void ST7789LCDDisplay::startFrame(ColorVal bkg) { + display.fillScreen(bkg); + display.setTextColor(_color = UIColor::primary_txt); display.setTextSize(1 * DISPLAY_SCALE_X); // This one affects size of Please wait... message display.cp437(true); // Use full 256 char 'Code Page 437' font } @@ -85,34 +100,8 @@ void ST7789LCDDisplay::setTextSize(int sz) { display.setTextSize(sz * DISPLAY_SCALE_X); } -void ST7789LCDDisplay::setColor(Color c) { - switch (c) { - case DisplayDriver::DARK : - _color = ST77XX_BLACK; - break; - case DisplayDriver::LIGHT : - _color = ST77XX_WHITE; - break; - case DisplayDriver::RED : - _color = ST77XX_RED; - break; - case DisplayDriver::GREEN : - _color = ST77XX_GREEN; - break; - case DisplayDriver::BLUE : - _color = ST77XX_BLUE; - break; - case DisplayDriver::YELLOW : - _color = ST77XX_YELLOW; - break; - case DisplayDriver::ORANGE : - _color = ST77XX_ORANGE; - break; - default: - _color = ST77XX_WHITE; - break; - } - display.setTextColor(_color); +void ST7789LCDDisplay::setColor(ColorVal c) { + display.setTextColor(_color = c); } void ST7789LCDDisplay::setCursor(int x, int y) { diff --git a/src/helpers/ui/ST7789LCDDisplay.h b/src/helpers/ui/ST7789LCDDisplay.h index 5b960ca19..b5127d350 100644 --- a/src/helpers/ui/ST7789LCDDisplay.h +++ b/src/helpers/ui/ST7789LCDDisplay.h @@ -8,7 +8,7 @@ #include class ST7789LCDDisplay : public DisplayDriver { - #if defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) + #if defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) || defined(HELTEC_V4_R8_TFT) SPIClass displaySPI; #endif Adafruit_ST7789 display; @@ -25,7 +25,7 @@ public: { _isOn = false; } -#elif defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) +#elif defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) || defined(HELTEC_V4_R8_TFT) ST7789LCDDisplay(RefCountedDigitalPin* peripher_power=NULL) : DisplayDriver(128, 64), displaySPI(HSPI), display(&displaySPI, PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST), @@ -47,9 +47,9 @@ public: void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/U8g2Display.cpp b/src/helpers/ui/U8g2Display.cpp new file mode 100644 index 000000000..e8d7fe2f1 --- /dev/null +++ b/src/helpers/ui/U8g2Display.cpp @@ -0,0 +1,12 @@ +#include "U8g2Display.h" + +// Color scheme +ColorVal UIColor::window_bkg = 0; +ColorVal UIColor::title_bkg = 0; +ColorVal UIColor::title_txt = 1; +ColorVal UIColor::primary_txt = 1; +ColorVal UIColor::secondary_txt = 1; +ColorVal UIColor::warning_txt = 1; +ColorVal UIColor::popup_bkg = 0; +ColorVal UIColor::popup_txt = 1; +ColorVal UIColor::corp_blue = 1; diff --git a/src/helpers/ui/U8g2Display.h b/src/helpers/ui/U8g2Display.h index 73c589363..a87ee19b3 100644 --- a/src/helpers/ui/U8g2Display.h +++ b/src/helpers/ui/U8g2Display.h @@ -72,10 +72,10 @@ public: _u8g2.sendBuffer(); } - void startFrame(Color bkg = DARK) override { - _u8g2.clearBuffer(); - _drawColor = 1; - _u8g2.setDrawColor(1); + void startFrame(ColorVal bkg = UIColor::window_bkg) override { + _u8g2.clearBuffer(); // TODO: apply 'bkg' color + _drawColor = UIColor::primary_txt; + _u8g2.setDrawColor(_drawColor); applyFont(1); } @@ -83,8 +83,8 @@ public: applyFont(sz); } - void setColor(Color c) override { - _drawColor = (c != DARK) ? 1 : 0; + void setColor(ColorVal c) override { + _drawColor = c; _u8g2.setDrawColor(_drawColor); } @@ -94,22 +94,18 @@ public: } void print(const char* str) override { - _u8g2.setDrawColor(_drawColor); _u8g2.drawStr(_cursorX, _cursorY, str); } void fillRect(int x, int y, int w, int h) override { - _u8g2.setDrawColor(_drawColor); _u8g2.drawBox(x, y, w, h); } void drawRect(int x, int y, int w, int h) override { - _u8g2.setDrawColor(_drawColor); _u8g2.drawFrame(x, y, w, h); } void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { - _u8g2.setDrawColor(1); _u8g2.drawXBM(x, y, w, h, bits); } diff --git a/test/README.md b/test/README.md new file mode 100644 index 000000000..711e64840 --- /dev/null +++ b/test/README.md @@ -0,0 +1,56 @@ +# Host unit tests + +Fast, hardware-free unit tests for the fork's pure logic, run on the host with +GoogleTest via PlatformIO's `native` environment. They cover the extractable +observer/WebConfig logic (validation, preset table, topic templates, key +parsing) — the parts that don't depend on the ESP32, radio, or network stack. +Integration behavior (AsyncTCP transport, WiFi/MQTT, SoftAP) is exercised +separately; see "Local testing without hardware" in `MQTT_IMPLEMENTATION.md`. + +## Running + +```sh +pio test -e native # all suites +pio test -e native -f test_webconfig_keys # a single suite +``` + +A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions +passed). PlatformIO's "0 test cases" line is just its Unity-style counter and +does not reflect the GoogleTest count — run the built binary directly +(`.pio/build/native/program`) to see the per-assertion breakdown. + +## Suites + +| Suite | Source under test | Covers | +|-------|-------------------|--------| +| `test_mqtt_presets` | `src/helpers/MQTTPresets.h` | preset lookup; table integrity (unique names, non-empty URLs, JWT-audience invariant, names fit the slot buffer); `mqttPresetNeedsSlotCredentials`; slot-count constants | +| `test_observer_validation` | `src/helpers/MQTTObserverValidation.h` | IATA (exactly 3 alphanumerics), owner key (64 hex), NTP hostname, and the buffer-fit check behind the #17 length validation — including boundaries and nulls | +| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, admin-password classification/validation, slot-index bounds, and the short-key out-of-bounds guard (attacker-supplied keys) | +| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz | +| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank all types except raw; required identifiers; invalid inputs/slots; exact buffer boundaries | +| `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover | +| `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover | +| `test_mqtt_packet_filter` | `src/helpers/MQTTPacketFilter.h` | per-slot 0-15 allowlist parsing/formatting, numeric and named spellings; exact bounds; membership; candidate/eligible split and retry-completion policy; pre-queue union gate; default-mask detection | +| `test_mqtt_runtime_buffer_lifecycle` | `src/helpers/MQTTRuntimeBufferLifecycle.h` | idempotent allocation/release; partial-allocation degradation; retry of only missing buffers | +| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation; shortest-payload write policy (default filters stay downgrade-readable) | +| `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation | +| `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads | +| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) | + +## Conventions (and how to add a suite) + +- Each `test/test_/` directory builds into its **own** GoogleTest program + and must define its own `main()` (`::testing::InitGoogleTest` + `RUN_ALL_TESTS`). +- Tests are **host-only**: include only pure headers. Arduino/crypto stubs live + in `test/mocks/` (on the include path via `-I test/mocks`). +- Firmware headers are included from `src` (via `-I src`, e.g. + `#include "helpers/MQTTPresets.h"`). Some are guarded or ESP-flavored, so a + suite may need shims **before** the include — e.g. `test_mqtt_presets` does + `#define WITH_MQTT_BRIDGE 1` (the preset table is behind that flag) and + `#define PROGMEM` (the embedded CA-cert strings are PROGMEM-qualified). +- To add a suite: create `test/test_/test_.cpp` with a `main()`, and + add any host-only source it links to the `native` env's `build_src_filter` in + `platformio.ini` (header-only code needs no source entry). No other wiring. +- Keep logic testable by extracting pure functions into headers (as + `MQTTObserverValidation.h` / `WebConfigKeys.h` / `MQTTTopicTemplate.h` do) and + having the firmware call the same functions. diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h new file mode 100644 index 000000000..77499fe41 --- /dev/null +++ b/test/mocks/Arduino.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include "Stream.h" + +inline uint32_t g_mock_millis = 0; + +using std::isnan; + +inline uint32_t millis() { + return g_mock_millis; +} + +inline void delay(uint32_t ms) { + g_mock_millis += ms; +} diff --git a/test/mocks/CayenneLPP.h b/test/mocks/CayenneLPP.h new file mode 100644 index 000000000..9d51c0fcf --- /dev/null +++ b/test/mocks/CayenneLPP.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +class CayenneLPP { +public: + explicit CayenneLPP(size_t) {} + const uint8_t* getBuffer() const { return nullptr; } + uint16_t getSize() const { return 0; } +}; diff --git a/test/mocks/Identity.h b/test/mocks/Identity.h new file mode 100644 index 000000000..5c3d25e94 --- /dev/null +++ b/test/mocks/Identity.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include "Utils.h" + +namespace mesh { + +class Identity { +public: + uint8_t pub_key[PUB_KEY_SIZE]; + + Identity() { + std::memset(pub_key, 0, sizeof(pub_key)); + } + + explicit Identity(const uint8_t* src) { + std::memcpy(pub_key, src, PUB_KEY_SIZE); + } + + bool verify(const uint8_t*, const uint8_t*, int) const { + return true; + } +}; + +class LocalIdentity : public Identity { +public: + LocalIdentity() : Identity() {} + + void sign(uint8_t* sig, const uint8_t*, int) const { + std::memset(sig, 0x5A, SIGNATURE_SIZE); + } + + void calcSharedSecret(uint8_t* secret, const uint8_t*) const { + std::memset(secret, 0x11, PUB_KEY_SIZE); + } +}; + +} diff --git a/test/mocks/Mesh.h b/test/mocks/Mesh.h new file mode 100644 index 000000000..b6c263c19 --- /dev/null +++ b/test/mocks/Mesh.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +namespace mesh { + +class Radio { +public: + virtual ~Radio() = default; + virtual bool isReceiving() { return false; } + virtual uint32_t getEstAirtimeFor(uint16_t) { return 10; } + virtual bool startSendRaw(const uint8_t*, uint16_t) { return true; } + virtual bool isSendComplete() { return true; } + virtual void onSendFinished() {} + virtual int16_t getNoiseFloor() { return -120; } +}; + +class MainBoard { +public: + virtual ~MainBoard() = default; + virtual uint16_t getBattMilliVolts() { return 4200; } + virtual float getMCUTemperature() { return 25.0f; } + virtual const char* getManufacturerName() { return "mock-board"; } + virtual void reboot() {} +}; + +} diff --git a/test/mocks/SHA256.h b/test/mocks/SHA256.h index b6e551a07..0cefe5c6e 100644 --- a/test/mocks/SHA256.h +++ b/test/mocks/SHA256.h @@ -3,12 +3,33 @@ #include #include -// Mock SHA256 class for testing -// Provides minimal interface to allow Utils.cpp to compile +// Mock SHA256 for native testing — deterministic but not cryptographic. +// finalize() writes real (non-garbage) output so calculatePacketHash() produces +// distinguishable results for packets with different payloads. +#include + class SHA256 { + uint8_t _state[32]; + size_t _len; public: - void update(const uint8_t* data, size_t len) {} - void finalize(uint8_t* hash, size_t hashLen) {} + SHA256() : _len(0) { memset(_state, 0, sizeof(_state)); } + + void update(const void* data, size_t len) { + const uint8_t* bytes = static_cast(data); + for (size_t i = 0; i < len; i++) { + uint8_t b = bytes[i]; + _state[_len % 32] ^= b; + _state[(_len + 1) % 32] += (uint8_t)((b >> 1) | (b << 7)); + _len++; + } + } + + void finalize(uint8_t* hash, size_t hashLen) { + for (size_t i = 0; i < hashLen; i++) { + hash[i] = _state[i % 32]; + } + } + void resetHMAC(const uint8_t* key, size_t keyLen) {} void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* hash, size_t hashLen) {} }; diff --git a/test/mocks/Stream.h b/test/mocks/Stream.h index 195a30297..56675607e 100644 --- a/test/mocks/Stream.h +++ b/test/mocks/Stream.h @@ -1,10 +1,73 @@ #pragma once +#include +#include +#include + // Mock Stream class for native testing // Provides minimal interface needed by Utils.h -class Stream { +#define DEC 10 +#define HEX 16 +#define OCT 8 +#define BIN 2 + +class Print +{ public: - virtual void print(char c) {} - virtual void print(const char* str) {} + virtual size_t write(uint8_t b) { return 1; } + size_t write(const char *str) + { + if(str == NULL) { + return 0; + } + return write((const uint8_t *) str, strlen(str)); + } + virtual size_t write(const uint8_t *buffer, size_t size) { + size_t t = 0; + for (int i = 0; i < size; i++) { t += write(buffer[i]); } + return t; + } + size_t write(const char *buffer, size_t size) + { + return write((const uint8_t *) buffer, size); + } + + virtual size_t print(unsigned char b, int r = DEC) { return 0; } + virtual size_t print(int v, int r = DEC) { return 0; } + virtual size_t print(unsigned int v, int r = DEC) { return 0; } + virtual size_t print(long v, int r = DEC) { return 0; } + virtual size_t print(unsigned long v, int r = DEC) { return 0; } + virtual size_t print(long long v, int r = DEC) { return 0; } + virtual size_t print(unsigned long long v, int r = DEC) { return 0; } + virtual size_t print(double v, int p = 2) { return 0; } + + size_t print(char c) { return write(c); } + size_t print(const char* str) { return write(str); } + + //size_t println(void) { return 0; } + + virtual void flush() { /* Empty implementation for backward compatibility */ } }; + +class Stream: public Print +{ +public: + virtual ~Stream() = default; + virtual int available() { return 0; } + virtual int availableForWrite() { return 0; } + virtual int read() { return -1; } + virtual int peek() { return 0; } + + virtual size_t readBytes(char *buffer, size_t length) { + size_t i = 0; + while (i < length && available()) { + buffer[i++] = read(); + } + return i; + } + virtual size_t readBytes(uint8_t *buffer, size_t length) + { + return readBytes((char *) buffer, length); + } +}; \ No newline at end of file diff --git a/test/mocks/Utils.h b/test/mocks/Utils.h new file mode 100644 index 000000000..9bb6b060b --- /dev/null +++ b/test/mocks/Utils.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include + +#define PUB_KEY_SIZE 32 +#define PRV_KEY_SIZE 64 +#define SIGNATURE_SIZE 64 +#define CIPHER_MAC_SIZE 16 + +namespace mesh { + +class RNG { +public: + virtual ~RNG() = default; + virtual void random(uint8_t* dest, size_t sz) = 0; +}; + +class Utils { +public: + static void sha256(uint8_t* hash, size_t hash_len, const uint8_t*, int) { + std::memset(hash, 0, hash_len); + } + + static int encryptThenMAC(const uint8_t*, uint8_t* dest, const uint8_t* src, int src_len) { + int out_len = src_len + CIPHER_MAC_SIZE; + std::memset(dest, 0xAA, CIPHER_MAC_SIZE); + std::memcpy(dest + CIPHER_MAC_SIZE, src, src_len); + return out_len; + } + + static int MACThenDecrypt(const uint8_t*, uint8_t* dest, const uint8_t* src, int src_len) { + if (src_len < CIPHER_MAC_SIZE) { + return 0; + } + int out_len = src_len - CIPHER_MAC_SIZE; + std::memcpy(dest, src + CIPHER_MAC_SIZE, out_len); + return out_len; + } +}; + +} diff --git a/test/mocks/helpers/SensorManager.h b/test/mocks/helpers/SensorManager.h new file mode 100644 index 000000000..d1a41cb5d --- /dev/null +++ b/test/mocks/helpers/SensorManager.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include "CayenneLPP.h" + +class SensorManager { +public: + virtual ~SensorManager() = default; + virtual bool querySensors(uint8_t, CayenneLPP&) { return false; } +}; diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp new file mode 100644 index 000000000..7a13f487e --- /dev/null +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -0,0 +1,180 @@ +#include +#include "helpers/ConfigSerializer.h" + +#define TEST_INT_S "56" +#define TEST_INT 56 +#define TEST_FLOAT_S "-6.123" +#define TEST_FLOAT -6.1230f +#define TEST_DOUBLE_S "12.123456" +#define TEST_DOUBLE 12.123456 + +class MockInputStream : public Stream { + const char* _text; + int pos, len; +public: + MockInputStream(const char* text) : _text(text) { pos = 0; len = strlen(text); } + int available() override { return len - pos; } + int read() override { if (pos < len) { return _text[pos++]; } return -1; } + int peek() override { if (pos < len) { return _text[pos]; } return -1; } +}; + +class MockPrintStream : public Stream { + int len = 0; + uint8_t _buf[1024]; +public: + size_t write(uint8_t b) override { + if (len < sizeof(_buf)) { + _buf[len++] = b; + return 1; + } + return 0; + } + + size_t print(unsigned char b, int r) override { if (b == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(int v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(unsigned int v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(unsigned long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(long long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(unsigned long long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(double v, int p = 2) override { + if (p == 6) return Print::print(TEST_DOUBLE_S); + if (p == 4) return Print::print(TEST_FLOAT_S); + return 0; + } + + int getLength() const { return len; } + const uint8_t* getBytes() const { return _buf; } +}; + +class TestStruct : public ConfigSerializer { + protected: + void structure() override { + def("age", age); + def("flags", flags); + def("name", name, sizeof(name)); + } + public: + int32_t age; + char name[16]; + uint8_t flags; +}; + +// ── saveSerial: basic ─────────────────────────────────────────────────────── + +TEST(ConfigSerializer, SaveSerial_Basic) { + MockPrintStream s; + TestStruct data; + + data.age = TEST_INT; + data.flags = TEST_INT; + strcpy(data.name, "Scott"); + + bool success = data.saveSerial(s); + EXPECT_TRUE(success); + + auto l = s.getLength(); + const char* expect = "{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"Scott\"}"; + EXPECT_EQ(strlen(expect), l); + + bool match = memcmp(s.getBytes(), expect, l) == 0; + EXPECT_TRUE(match); +} + + +TEST(ConfigSerializer, SaveSerial_EscChars) { + MockPrintStream s; + TestStruct data; + + data.age = TEST_INT; + data.flags = TEST_INT; + strcpy(data.name, "\"Scott\"\n"); + + bool success = data.saveSerial(s); + EXPECT_TRUE(success); + + auto l = s.getLength(); + const char* expect = "{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"\\\"Scott\\\"\\n\"}"; + EXPECT_EQ(strlen(expect), l); + + bool match = memcmp(s.getBytes(), expect, l) == 0; + EXPECT_TRUE(match); +} + +// ── loadSerial: basic ─────────────────────────────────────────────────────── + +TEST(ConfigSerializer, LoadSerial_Basic) { + MockInputStream s("{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"Scott\"}"); + TestStruct data; + + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + + EXPECT_EQ(TEST_INT, data.age); + EXPECT_EQ(TEST_INT, data.flags); + bool match = strcmp("Scott", data.name) == 0; + EXPECT_TRUE(match); +} + +TEST(ConfigSerializer, LoadSerial_HandleWhitespace) { + MockInputStream s(" { age: " TEST_INT_S " , flags: " TEST_INT_S " , name: \"Scott\" } "); + TestStruct data; + + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + + EXPECT_EQ(TEST_INT, data.age); + EXPECT_EQ(TEST_INT, data.flags); + bool match = strcmp("Scott", data.name) == 0; + EXPECT_TRUE(match); +} + +TEST(ConfigSerializer, LoadSerial_EscChars) { + MockInputStream s("{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"\\\"Scott\\\"\\n\"}"); + TestStruct data; + + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + + bool match = strcmp("\"Scott\"\n", data.name) == 0; + EXPECT_TRUE(match); +} + +TEST(ConfigSerializer, LoadSerial_UnmatchedBraces) { + MockInputStream s("{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"Scott\""); + TestStruct data; + + bool success = data.loadSerial(s); + EXPECT_FALSE(success); +} + +TEST(ConfigSerializer, LoadSerial_MissingCommas) { + MockInputStream s("{age:" TEST_INT_S " flags:" TEST_INT_S " name:\"Scott\"}"); + TestStruct data; + + bool success = data.loadSerial(s); + EXPECT_FALSE(success); +} + +TEST(ConfigSerializer, LoadSerial_IgnoreUnknowns) { + MockInputStream s("{age:" TEST_INT_S ",xxx:" TEST_INT_S ",name:\"Scott\"}"); + TestStruct data; + data.flags = 1; + + // should ignore the 'xxx' property + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + + EXPECT_EQ(TEST_INT, data.age); + EXPECT_EQ(1, data.flags); // flags should be unmodified + bool match = strcmp("Scott", data.name) == 0; + EXPECT_TRUE(match); +} + + +// ── main ─────────────────────────────────────────────────────── + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_kiss_modem/test_tx_backpressure.cpp b/test/test_kiss_modem/test_tx_backpressure.cpp new file mode 100644 index 000000000..39a5e3b74 --- /dev/null +++ b/test/test_kiss_modem/test_tx_backpressure.cpp @@ -0,0 +1,294 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "KissModem.h" + +static constexpr int TEST_TX_AVAILABLE_BYTES = 4096; +static constexpr size_t TEST_DEFAULT_MAX_WRITE_CHUNK = SIZE_MAX; +static constexpr size_t TEST_PARTIAL_WRITE_CHUNK = 2; +static constexpr int TEST_PARTIAL_WRITE_FLUSH_LOOPS = 3; +static constexpr uint8_t TEST_SNR = 8; +static constexpr uint8_t TEST_RSSI = 200; + +class BlockingStream : public Stream { +public: + void pushRx(const std::vector& bytes) { + std::lock_guard lock(_mutex); + for (uint8_t b : bytes) { + _rx.push(b); + } + } + + void setBlockWrites(bool blocked) { + { + std::lock_guard lock(_mutex); + _block_writes = blocked; + } + _cv.notify_all(); + } + + bool isWriteBlocked() const { + return _entered_block.load(); + } + + size_t writesCount() const { + std::lock_guard lock(_mutex); + return _writes.size(); + } + + std::vector writesSnapshot() const { + std::lock_guard lock(_mutex); + return _writes; + } + + int availableForWrite() override { + std::lock_guard lock(_mutex); + return _block_writes ? 0 : TEST_TX_AVAILABLE_BYTES; + } + + void setMaxWriteChunk(size_t chunk) { + std::lock_guard lock(_mutex); + _max_write_chunk = chunk; + } + + size_t write(const uint8_t* buffer, size_t size) override { + std::unique_lock lock(_mutex); + while (_block_writes) { + _entered_block.store(true); + _cv.wait(lock); + } + const size_t chunk = (size < _max_write_chunk) ? size : _max_write_chunk; + for (size_t i = 0; i < chunk; i++) { + _writes.push_back(buffer[i]); + } + return chunk; + } + + size_t write(uint8_t b) override { + return write(&b, 1); + } + + int available() override { + std::lock_guard lock(_mutex); + return static_cast(_rx.size()); + } + + int read() override { + std::lock_guard lock(_mutex); + if (_rx.empty()) { + return -1; + } + int b = _rx.front(); + _rx.pop(); + return b; + } + +private: + mutable std::mutex _mutex; + std::condition_variable _cv; + std::queue _rx; + std::vector _writes; + bool _block_writes = false; + std::atomic _entered_block = false; + size_t _max_write_chunk = TEST_DEFAULT_MAX_WRITE_CHUNK; +}; + +class FakeRNG : public mesh::RNG { +public: + void random(uint8_t* dest, size_t sz) override { + for (size_t i = 0; i < sz; i++) { + dest[i] = 0; + } + } +}; + +class FakeRadio : public mesh::Radio { +public: + bool isReceiving() override { return false; } + uint32_t getEstAirtimeFor(uint16_t) override { return 10; } + bool startSendRaw(const uint8_t*, uint16_t) override { + _start_send_count++; + return _start_send_result; + } + bool isSendComplete() override { return _send_complete; } + void onSendFinished() override { _send_finished_count++; } + int16_t getNoiseFloor() override { return -120; } + + void setStartSendResult(bool result) { _start_send_result = result; } + void setSendComplete(bool complete) { _send_complete = complete; } + int startSendCount() const { return _start_send_count; } + int sendFinishedCount() const { return _send_finished_count; } + +private: + bool _start_send_result = true; + bool _send_complete = true; + int _start_send_count = 0; + int _send_finished_count = 0; +}; + +class FakeBoard : public mesh::MainBoard { +public: + uint16_t getBattMilliVolts() override { return 4200; } + float getMCUTemperature() override { return 24.0f; } + const char* getManufacturerName() override { return "test-board"; } + void reboot() override {} +}; + +class FakeSensors : public SensorManager { +public: + bool querySensors(uint8_t, CayenneLPP&) override { return false; } +}; + +class KissModemFixture : public ::testing::Test { +protected: + BlockingStream serial; + mesh::LocalIdentity identity; + FakeRNG rng; + FakeRadio radio; + FakeBoard board; + FakeSensors sensors; + KissModem modem; + + KissModemFixture() + : modem(serial, identity, rng, radio, board, sensors) { + modem.begin(); + } + + static std::vector dataFrame(const std::vector& packet) { + std::vector frame = {KISS_FEND, KISS_CMD_DATA}; + frame.insert(frame.end(), packet.begin(), packet.end()); + frame.push_back(KISS_FEND); + return frame; + } + + void advanceToTxSending() { + modem.loop(); + modem.loop(); + delay((uint32_t)KISS_DEFAULT_TXDELAY * 10); + modem.loop(); + } +}; + +TEST_F(KissModemFixture, PingResponseShouldNotStallLoopUnderTxBackpressure) { + serial.setBlockWrites(true); + serial.pushRx({KISS_FEND, KISS_CMD_SETHARDWARE, HW_CMD_PING, KISS_FEND}); + + auto future = std::async(std::launch::async, [this]() { + modem.loop(); + }); + + auto status = future.wait_for(std::chrono::milliseconds(100)); + EXPECT_EQ(status, std::future_status::ready) << "KissModem::loop blocked in serial write under TX backpressure"; + EXPECT_FALSE(serial.isWriteBlocked()) << "KissModem entered blocking write path"; + + serial.setBlockWrites(false); + future.wait(); + modem.loop(); + EXPECT_GT(serial.writesCount(), 0U) << "KissModem did not flush queued response after backpressure cleared"; +} + +TEST_F(KissModemFixture, PingResponseKeepsStandardKissFraming) { + serial.pushRx({KISS_FEND, KISS_CMD_SETHARDWARE, HW_CMD_PING, KISS_FEND}); + modem.loop(); + + const std::vector expected = {KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP(HW_CMD_PING), KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, PingResponseKeepsFramingWithPartialBulkWrites) { + serial.setMaxWriteChunk(TEST_PARTIAL_WRITE_CHUNK); + serial.pushRx({KISS_FEND, KISS_CMD_SETHARDWARE, HW_CMD_PING, KISS_FEND}); + for (int i = 0; i < TEST_PARTIAL_WRITE_FLUSH_LOOPS; i++) { + modem.loop(); + } + + const std::vector expected = {KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP(HW_CMD_PING), KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, PacketAndMetaAreQueuedTogetherUnderBackpressure) { + static constexpr uint8_t TEST_PACKET[] = {0x01, 0x02, 0x03}; + + serial.setBlockWrites(true); + modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, TEST_PACKET, sizeof(TEST_PACKET)); + serial.setBlockWrites(false); + modem.loop(); + modem.loop(); + + const std::vector expected = { + KISS_FEND, KISS_CMD_DATA, TEST_PACKET[0], TEST_PACKET[1], TEST_PACKET[2], KISS_FEND, + KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, TEST_SNR, TEST_RSSI, KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, RadioTxCompletionAdvancesWhileHostOutputIsBackedUp) { + serial.pushRx(dataFrame({0x42})); + advanceToTxSending(); + ASSERT_EQ(radio.startSendCount(), 1); + + serial.setBlockWrites(true); + modem.loop(); + EXPECT_EQ(radio.sendFinishedCount(), 1); + EXPECT_TRUE(modem.isTxBusy()); + + serial.setBlockWrites(false); + modem.loop(); + + const std::vector expected = {KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_TX_DONE, 0x01, KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); + EXPECT_FALSE(modem.isTxBusy()); +} + +TEST_F(KissModemFixture, QueueFullReportsBusyWithoutDroppingQueuedFrames) { + static constexpr uint8_t TEST_PACKET_ONE[] = {0x11, 0x12}; + static constexpr uint8_t TEST_PACKET_TWO[] = {0x21, 0x22}; + + serial.setBlockWrites(true); + modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, TEST_PACKET_ONE, sizeof(TEST_PACKET_ONE)); + modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, TEST_PACKET_TWO, sizeof(TEST_PACKET_TWO)); + serial.setBlockWrites(false); + modem.loop(); + + const std::vector expected = { + KISS_FEND, KISS_CMD_DATA, TEST_PACKET_ONE[0], TEST_PACKET_ONE[1], KISS_FEND, + KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, TEST_SNR, TEST_RSSI, KISS_FEND, + KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_ERROR, HW_ERR_TX_BUSY, KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, QueuedEncoderEscapesKissSpecialBytes) { + static constexpr uint8_t TEST_PACKET[] = {KISS_FEND, KISS_FESC, 0x01}; + + modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, TEST_PACKET, sizeof(TEST_PACKET)); + + const std::vector expected = { + KISS_FEND, KISS_CMD_DATA, KISS_FESC, KISS_TFEND, KISS_FESC, KISS_TFESC, 0x01, KISS_FEND, + KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, TEST_SNR, TEST_RSSI, KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, MaxPacketWorstCaseEscapingFitsQueuedFrame) { + std::vector packet(KISS_MAX_PACKET_SIZE, KISS_FEND); + std::vector expected = {KISS_FEND, KISS_CMD_DATA}; + for (size_t i = 0; i < packet.size(); i++) { + expected.push_back(KISS_FESC); + expected.push_back(KISS_TFEND); + } + expected.push_back(KISS_FEND); + expected.insert(expected.end(), {KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, TEST_SNR, TEST_RSSI, KISS_FEND}); + + modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, packet.data(), packet.size()); + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mesh_tables/test_simple_mesh_tables.cpp b/test/test_mesh_tables/test_simple_mesh_tables.cpp new file mode 100644 index 000000000..46b477d94 --- /dev/null +++ b/test/test_mesh_tables/test_simple_mesh_tables.cpp @@ -0,0 +1,103 @@ +#include +#include "helpers/SimpleMeshTables.h" + +using namespace mesh; + +// Build a packet that calculatePacketHash() distinguishes by payload content. +// header selects ROUTE_TYPE_FLOOD so isRouteDirect() returns false. +static Packet makeFloodPacket(uint8_t seed) { + Packet p; + p.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT); + p.payload[0] = seed; + p.payload_len = 1; + p.path_len = 0; + return p; +} + +static Packet makeDirectPacket(uint8_t seed) { + Packet p; + p.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT); + p.payload[0] = seed; + p.payload_len = 1; + p.path_len = 0; + return p; +} + +// ── wasSeen: pure query ─────────────────────────────────────────────────────── + +TEST(SimpleMeshTables, WasSeen_ReturnsFalseForUnseen) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + EXPECT_FALSE(t.wasSeen(&p)); +} + +// wasSeen shouldn't change state +TEST(SimpleMeshTables, WasSeen_IsPureQuery_DoesNotInsert) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + EXPECT_FALSE(t.wasSeen(&p)); + EXPECT_FALSE(t.wasSeen(&p)); +} + +// ── markSeen + wasSeen ─────────────────────────────────────────────────────── + +TEST(SimpleMeshTables, MarkSeen_MakesWasSeenReturnTrue) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + t.markSeen(&p); + EXPECT_TRUE(t.wasSeen(&p)); +} + +TEST(SimpleMeshTables, MarkSeen_DoesNotAffectOtherPackets) { + SimpleMeshTables t; + Packet p1 = makeFloodPacket(0x01); + Packet p2 = makeFloodPacket(0x02); + t.markSeen(&p1); + EXPECT_FALSE(t.wasSeen(&p2)); +} + +// Canonical pattern used at every onRecvPacket call site: +// if (!wasSeen(pkt)) { markSeen(pkt); process(pkt); } +TEST(SimpleMeshTables, QueryThenMark_WorksCorrectly) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + EXPECT_FALSE(t.wasSeen(&p)); + t.markSeen(&p); + EXPECT_TRUE(t.wasSeen(&p)); +} + +// ── dup stats ──────────────────────────────────────────────────────────────── + +TEST(SimpleMeshTables, WasSeen_IncrementsFloodDupStat) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + t.markSeen(&p); + t.wasSeen(&p); + EXPECT_EQ(1u, t.getNumFloodDups()); + EXPECT_EQ(0u, t.getNumDirectDups()); +} + +TEST(SimpleMeshTables, WasSeen_IncrementsDirectDupStat) { + SimpleMeshTables t; + Packet p = makeDirectPacket(0x01); + t.markSeen(&p); + t.wasSeen(&p); + EXPECT_EQ(0u, t.getNumFloodDups()); + EXPECT_EQ(1u, t.getNumDirectDups()); +} + +// ── clear ──────────────────────────────────────────────────────────────────── + +TEST(SimpleMeshTables, Clear_RemovesSeenPacket) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + t.markSeen(&p); + ASSERT_TRUE(t.wasSeen(&p)); + t.clear(&p); + EXPECT_FALSE(t.wasSeen(&p)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp b/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp new file mode 100644 index 000000000..496a1c434 --- /dev/null +++ b/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp @@ -0,0 +1,248 @@ +#include +#include +#include + +#include "helpers/MQTTConnectionPolicy.h" + +namespace Policy = MQTTConnectionPolicy; + +TEST(MQTTConnectionPolicy, ElapsedTimeHandlesNormalAndWrappedClocks) { + EXPECT_EQ(4000U, Policy::elapsedMs(5000U, 1000U)); + + const uint32_t before_wrap = std::numeric_limits::max() - 99U; + EXPECT_EQ(150U, Policy::elapsedMs(50U, before_wrap)); +} + +TEST(MQTTConnectionPolicy, CrossSlotReconnectGuardHasExactBoundary) { + EXPECT_TRUE(Policy::reconnectGuardActive(14999U, 0U)); + EXPECT_FALSE(Policy::reconnectGuardActive(15000U, 0U)); + + const uint32_t last = std::numeric_limits::max() - 9999U; + EXPECT_TRUE(Policy::reconnectGuardActive(4999U, last)); + EXPECT_FALSE(Policy::reconnectGuardActive(5000U, last)); +} + +TEST(MQTTConnectionPolicy, StableResetRequiresARealStartAndFullWindow) { + EXPECT_FALSE(Policy::stableConnection(500000U, 0U)); + EXPECT_FALSE(Policy::stableConnection(120999U, 1000U)); + EXPECT_TRUE(Policy::stableConnection(121000U, 1000U)); +} + +TEST(MQTTConnectionPolicy, StableResetWindowSurvivesMillisRollover) { + const uint32_t connected_at = std::numeric_limits::max() - 59999U; + EXPECT_FALSE(Policy::stableConnection(59999U, connected_at)); + EXPECT_TRUE(Policy::stableConnection(60000U, connected_at)); +} + +TEST(MQTTConnectionPolicy, BackoffLadderSaturatesAtFiveMinutes) { + EXPECT_EQ(10000U, Policy::reconnectBackoffMs(0)); + EXPECT_EQ(30000U, Policy::reconnectBackoffMs(1)); + EXPECT_EQ(60000U, Policy::reconnectBackoffMs(2)); + EXPECT_EQ(120000U, Policy::reconnectBackoffMs(3)); + EXPECT_EQ(300000U, Policy::reconnectBackoffMs(4)); + EXPECT_EQ(300000U, Policy::reconnectBackoffMs(5)); + EXPECT_EQ(300000U, Policy::reconnectBackoffMs(255)); +} + +TEST(MQTTConnectionPolicy, LaterSlotsReceiveThreeSecondStagger) { + EXPECT_EQ(10000U, Policy::reconnectDelayMs(0, 0)); + EXPECT_EQ(13000U, Policy::reconnectDelayMs(0, 1)); + EXPECT_EQ(315000U, Policy::reconnectDelayMs(5, 5)); +} + +TEST(MQTTConnectionPolicy, ReconnectDueUsesDelayBoundaryAndWrapSafeElapsedTime) { + EXPECT_FALSE(Policy::reconnectDue(12999U, 0U, 0, 1)); + EXPECT_TRUE(Policy::reconnectDue(13000U, 0U, 0, 1)); + + const uint32_t last = std::numeric_limits::max() - 4999U; + EXPECT_FALSE(Policy::reconnectDue(4999U, last, 0, 0)); + EXPECT_TRUE(Policy::reconnectDue(5000U, last, 0, 0)); +} + +TEST(MQTTConnectionPolicy, BackoffAdvanceClimbsThenCountsFailuresAtMaximum) { + Policy::BackoffAdvance first = Policy::advanceBackoff(0, 0); + EXPECT_EQ(1, first.reconnect_backoff); + EXPECT_EQ(0, first.max_backoff_failures); + EXPECT_FALSE(first.circuit_breaker_tripped); + EXPECT_TRUE(first.should_reconnect); + + Policy::BackoffAdvance enters_maximum = Policy::advanceBackoff(4, 0); + EXPECT_EQ(5, enters_maximum.reconnect_backoff); + EXPECT_EQ(0, enters_maximum.max_backoff_failures); + EXPECT_FALSE(enters_maximum.circuit_breaker_tripped); + EXPECT_TRUE(enters_maximum.should_reconnect); + + Policy::BackoffAdvance first_max_failure = Policy::advanceBackoff(5, 0); + EXPECT_EQ(5, first_max_failure.reconnect_backoff); + EXPECT_EQ(1, first_max_failure.max_backoff_failures); + EXPECT_FALSE(first_max_failure.circuit_breaker_tripped); + EXPECT_TRUE(first_max_failure.should_reconnect); +} + +TEST(MQTTConnectionPolicy, ThirdFailureAtMaximumTripsWithoutAnotherHandshake) { + Policy::BackoffAdvance result = Policy::advanceBackoff(5, 2); + EXPECT_EQ(5, result.reconnect_backoff); + EXPECT_EQ(3, result.max_backoff_failures); + EXPECT_TRUE(result.circuit_breaker_tripped); + EXPECT_FALSE(result.should_reconnect); +} + +TEST(MQTTConnectionPolicy, CircuitBreakerProbeHasExactThirtyMinuteBoundary) { + EXPECT_FALSE(Policy::circuitBreakerProbeDue(1799999U, 0U)); + EXPECT_TRUE(Policy::circuitBreakerProbeDue(1800000U, 0U)); + + const uint32_t last = std::numeric_limits::max() - 899999U; + EXPECT_FALSE(Policy::circuitBreakerProbeDue(899999U, last)); + EXPECT_TRUE(Policy::circuitBreakerProbeDue(900000U, last)); +} + +TEST(MQTTConnectionPolicy, JwtLifetimeUsesCappedPerSlotStagger) { + EXPECT_EQ(86400U, Policy::jwtLifetimeSecs(86400U, 0)); + EXPECT_EQ(86100U, Policy::jwtLifetimeSecs(86400U, 1)); + EXPECT_EQ(84900U, Policy::jwtLifetimeSecs(86400U, 5)); +} + +TEST(MQTTConnectionPolicy, ShortJwtLifetimeUsesFivePercentPerSlot) { + EXPECT_EQ(3300U, Policy::jwtLifetimeSecs(3300U, 0)); + EXPECT_EQ(3135U, Policy::jwtLifetimeSecs(3300U, 1)); + EXPECT_EQ(2970U, Policy::jwtLifetimeSecs(3300U, 2)); + EXPECT_EQ(85U, Policy::jwtLifetimeSecs(100U, 3)); +} + +TEST(MQTTConnectionPolicy, JwtLifetimeCannotUnderflowForUnexpectedSlotInput) { + EXPECT_EQ(0U, Policy::jwtLifetimeSecs(100U, 255)); +} + +TEST(MQTTConnectionPolicy, RenewalBufferHasOneMinuteFloorAndFiveMinuteCap) { + EXPECT_EQ(60U, Policy::renewalBufferSecs(0U)); + EXPECT_EQ(60U, Policy::renewalBufferSecs(599U)); + EXPECT_EQ(60U, Policy::renewalBufferSecs(600U)); + EXPECT_EQ(299U, Policy::renewalBufferSecs(2999U)); + EXPECT_EQ(300U, Policy::renewalBufferSecs(3000U)); + EXPECT_EQ(300U, Policy::renewalBufferSecs(86400U)); +} + +TEST(MQTTConnectionPolicy, UnsynchronizedClockOnlyCreatesAMissingToken) { + EXPECT_TRUE(Policy::tokenNeedsRenewal(false, 0U, 0U, 300U)); + EXPECT_FALSE(Policy::tokenNeedsRenewal(false, 0U, 1735693200U, 300U)); +} + +TEST(MQTTConnectionPolicy, SyncedClockRenewsInvalidExpiredOrImminentTokens) { + const uint32_t expires = 1735693200U; + EXPECT_TRUE(Policy::tokenNeedsRenewal(true, 1735689000U, 0U, 300U)); + EXPECT_TRUE(Policy::tokenNeedsRenewal(true, 1735689000U, 999999999U, 300U)); + EXPECT_FALSE(Policy::tokenNeedsRenewal(true, expires - 301U, expires, 300U)); + EXPECT_TRUE(Policy::tokenNeedsRenewal(true, expires - 300U, expires, 300U)); + EXPECT_TRUE(Policy::tokenNeedsRenewal(true, expires, expires, 300U)); + EXPECT_TRUE(Policy::tokenNeedsRenewal(true, expires + 1U, expires, 300U)); +} + +TEST(MQTTConnectionPolicy, RenewalThrottleHasExactBoundaryAndHandlesRollover) { + EXPECT_FALSE(Policy::renewalAttemptAllowed(59999U, 0U)); + EXPECT_TRUE(Policy::renewalAttemptAllowed(60000U, 0U)); + + const uint32_t last = std::numeric_limits::max() - 29999U; + EXPECT_FALSE(Policy::renewalAttemptAllowed(29999U, last)); + EXPECT_TRUE(Policy::renewalAttemptAllowed(30000U, last)); +} + +TEST(MQTTConnectionPolicy, JwtClockNeedsNtpOrAReasonableWallClock) { + EXPECT_FALSE(Policy::jwtClockAvailable(false, Policy::kJwtClockThreshold - 1U)); + EXPECT_TRUE(Policy::jwtClockAvailable(false, Policy::kJwtClockThreshold)); + EXPECT_TRUE(Policy::jwtClockAvailable(true, 0U)); +} + +TEST(MQTTConnectionPolicy, WifiBackoffLadderStartsAtFifteenSecondsAndSaturates) { + EXPECT_EQ(15000U, Policy::wifiReconnectBackoffMs(0)); + EXPECT_EQ(30000U, Policy::wifiReconnectBackoffMs(1)); + EXPECT_EQ(60000U, Policy::wifiReconnectBackoffMs(2)); + EXPECT_EQ(120000U, Policy::wifiReconnectBackoffMs(3)); + EXPECT_EQ(300000U, Policy::wifiReconnectBackoffMs(4)); + // Clamps at the 300 s rung for the saturated attempt count and beyond. + EXPECT_EQ(300000U, Policy::wifiReconnectBackoffMs(5)); + EXPECT_EQ(300000U, Policy::wifiReconnectBackoffMs(200)); +} + +TEST(MQTTConnectionPolicy, WifiBackoffAttemptClimbsThenSaturatesAtFive) { + uint8_t attempt = 0; + for (uint8_t expected = 1; expected <= 5; ++expected) { + attempt = Policy::nextWifiBackoffAttempt(attempt); + EXPECT_EQ(expected, attempt); + } + // Saturated: never advances past 5 (index stays clamped at the 300 s rung). + EXPECT_EQ(5U, Policy::nextWifiBackoffAttempt(attempt)); + EXPECT_EQ(5U, Policy::nextWifiBackoffAttempt(5)); +} + +TEST(MQTTConnectionPolicy, WifiReconnectRequiresBothDownAndSinceAttemptToClearRung) { + const uint32_t down_since = 1000U; + const uint32_t last_attempt = 1000U; + const uint8_t attempt = 0; // 15 s rung + // Neither interval has elapsed yet. + EXPECT_FALSE(Policy::wifiReconnectDue(1000U + 14999U, down_since, last_attempt, attempt)); + // Down long enough, but an attempt was made only 5 s ago (since-attempt short). + EXPECT_FALSE(Policy::wifiReconnectDue(1000U + 15000U, down_since, 1000U + 10000U, attempt)); + // Both cleared at the exact boundary: due. + EXPECT_TRUE(Policy::wifiReconnectDue(1000U + 15000U, down_since, last_attempt, attempt)); +} + +TEST(MQTTConnectionPolicy, WifiReconnectDueSurvivesMillisRollover) { + const uint32_t down_since = std::numeric_limits::max() - 100U; + const uint32_t last_attempt = down_since; + const uint8_t attempt = 0; // 15 s rung + const uint32_t now = down_since + 15000U; // wraps past zero + EXPECT_TRUE(Policy::wifiReconnectDue(now, down_since, last_attempt, attempt)); + EXPECT_FALSE(Policy::wifiReconnectDue(down_since + 14999U, down_since, last_attempt, attempt)); +} + +// --- classifySlotActivation: the "will this slot connect on this hardware" rule +// the CLI uses to warn at `set mqttN.preset` time. Non-PSRAM Heltec V3 is the +// motivating case: RUNTIME_MQTT_SLOTS=3 but only 2 concurrent connections. --- + +using Policy::SlotActivation; + +TEST(SlotActivation, NonPsramV3FirstTwoConnectThirdIsOverCap) { + // slot_count = 3 (runtime array), max_active = 2 (concurrent cap). + const bool enabled[6] = {true, true, true, false, false, false}; + EXPECT_EQ(SlotActivation::Connects, Policy::classifySlotActivation(0, enabled, 3, 2)); + EXPECT_EQ(SlotActivation::Connects, Policy::classifySlotActivation(1, enabled, 3, 2)); + // slot 3 (index 2) is the case in the field log: iterated, but skipped. + EXPECT_EQ(SlotActivation::OverActiveCap, Policy::classifySlotActivation(2, enabled, 3, 2)); +} + +TEST(SlotActivation, NonPsramSlotsBeyondRuntimeArrayAreNeverIterated) { + const bool enabled[6] = {true, true, true, true, false, false}; + // mqtt4/5/6 (index 3-5) are outside the 3-slot runtime array on non-PSRAM. + EXPECT_EQ(SlotActivation::BeyondArray, Policy::classifySlotActivation(3, enabled, 3, 2)); + EXPECT_EQ(SlotActivation::BeyondArray, Policy::classifySlotActivation(5, enabled, 3, 2)); +} + +TEST(SlotActivation, RankCountsOnlyEnabledLowerSlots) { + // Only slot index 2 enabled (1 and 2 disabled): it is the first enabled slot, + // so it connects even though its index equals the cap. + const bool enabled[6] = {false, false, true, false, false, false}; + EXPECT_EQ(SlotActivation::Connects, Policy::classifySlotActivation(2, enabled, 3, 2)); + + // Gap in the middle: slots 0 and 2 enabled, slot 1 off. Slot 2 is rank 2 <= 2. + const bool enabled2[6] = {true, false, true, false, false, false}; + EXPECT_EQ(SlotActivation::Connects, Policy::classifySlotActivation(2, enabled2, 3, 2)); +} + +TEST(SlotActivation, PsramFiveOfSixConnect) { + // PSRAM: slot_count = 6, max_active = 5. Sixth enabled slot is over the cap. + const bool enabled[6] = {true, true, true, true, true, true}; + EXPECT_EQ(SlotActivation::Connects, Policy::classifySlotActivation(4, enabled, 6, 5)); + EXPECT_EQ(SlotActivation::OverActiveCap, Policy::classifySlotActivation(5, enabled, 6, 5)); +} + +TEST(SlotActivation, DisabledAndOutOfRangeSlots) { + const bool enabled[6] = {true, false, true, false, false, false}; + EXPECT_EQ(SlotActivation::Disabled, Policy::classifySlotActivation(1, enabled, 3, 2)); + EXPECT_EQ(SlotActivation::Disabled, Policy::classifySlotActivation(-1, enabled, 3, 2)); + EXPECT_EQ(SlotActivation::Disabled, Policy::classifySlotActivation(0, nullptr, 3, 2)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_lifecycle/test_mqtt_lifecycle.cpp b/test/test_mqtt_lifecycle/test_mqtt_lifecycle.cpp new file mode 100644 index 000000000..b5dec2f3c --- /dev/null +++ b/test/test_mqtt_lifecycle/test_mqtt_lifecycle.cpp @@ -0,0 +1,450 @@ +#include + +#include +#include + +#include "helpers/MQTTLifecycle.h" + +// Phase 4 teardown-focused test seam for the MQTT bridge lifecycle. Every case +// below maps to a bullet in STABILITY_TESTABILITY_HANDOFF.md's "Required +// teardown-focused tests" list or its "OTA Teardown Barrier" scenarios. The +// behavior encoded here is derived from the current MQTTBridge control flow +// (Phase 0 "derive from code + flag" discipline); it is the contract Phase 5 +// must preserve when it wires the real bridge into MQTTLifecycle::Coordinator. + +namespace L = MQTTLifecycle; + +namespace { + +// Recording Ops double with a settable clock and an ordered call log. +struct FakeOps : public L::Ops { + uint32_t now = 0; + int start_task_calls = 0; + int deliver_stop_calls = 0; + int release_calls = 0; + int stop_complete_calls = 0; + bool last_stop_clean = false; + std::vector log; + + uint32_t nowMs() override { return now; } + void startTask() override { + start_task_calls++; + log.push_back("startTask"); + } + void deliverStop() override { + deliver_stop_calls++; + log.push_back("deliverStop"); + } + void releaseResources() override { + release_calls++; + log.push_back("release"); + } + void onStopComplete(bool clean) override { + stop_complete_calls++; + last_stop_clean = clean; + log.push_back(clean ? "otaClean" : "otaDirty"); + } +}; + +const uint32_t kStopTimeoutMs = 5000; + +void bringUpToRunning(L::Coordinator& c) { + ASSERT_TRUE(c.requestStart()); + ASSERT_EQ(L::State::Starting, c.state()); + ASSERT_TRUE(c.onTaskStarted()); + ASSERT_EQ(L::State::Running, c.state()); +} + +} // namespace + +// --- Normal ordering ------------------------------------------------------- + +// Handoff Phase 0: "Normal begin() -> connect -> end() ordering." +TEST(MQTTLifecycle, NormalStartRunStopCycle) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + + EXPECT_EQ(L::State::Stopped, c.state()); + ASSERT_TRUE(c.requestStart()); + EXPECT_EQ(L::State::Starting, c.state()); + ASSERT_TRUE(c.onTaskStarted()); + EXPECT_EQ(L::State::Running, c.state()); + ASSERT_TRUE(c.requestStop()); + EXPECT_EQ(L::State::StopRequested, c.state()); + ASSERT_TRUE(c.onTaskStopped()); + EXPECT_EQ(L::State::Stopped, c.state()); + + // create_task, then deliver_stop, then a single post-ack resource release + // and a clean OTA-barrier signal — in that order. + const std::vector expected = {"startTask", "deliverStop", + "release", "otaClean"}; + EXPECT_EQ(expected, ops.log); + EXPECT_EQ(1, ops.start_task_calls); + EXPECT_EQ(1, ops.deliver_stop_calls); + EXPECT_EQ(1, ops.release_calls); + EXPECT_TRUE(ops.last_stop_clean); +} + +// --- Idempotency: "Duplicate stop", "Idempotent start and stop requests" --- + +TEST(MQTTLifecycle, StartIsIdempotent) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + + ASSERT_TRUE(c.requestStart()); + // Second start while Starting is a no-op — no extra task creation. + EXPECT_FALSE(c.requestStart()); + ASSERT_TRUE(c.onTaskStarted()); + // And a no-op while Running. + EXPECT_FALSE(c.requestStart()); + EXPECT_EQ(1, ops.start_task_calls); +} + +TEST(MQTTLifecycle, DuplicateStopIsIdempotent) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + bringUpToRunning(c); + + ASSERT_TRUE(c.requestStop()); + // Duplicate stop requests do not re-deliver the stop. + EXPECT_FALSE(c.requestStop()); + EXPECT_FALSE(c.requestStop()); + EXPECT_EQ(1, ops.deliver_stop_calls); + + ASSERT_TRUE(c.onTaskStopped()); + // A stop while already Stopped is also a no-op. + EXPECT_FALSE(c.requestStop()); + EXPECT_EQ(1, ops.release_calls); +} + +// --- Partial / early lifecycle -------------------------------------------- + +// Handoff: "stop before full initialization". +TEST(MQTTLifecycle, StopBeforeFullInitialization) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + + ASSERT_TRUE(c.requestStart()); + EXPECT_EQ(L::State::Starting, c.state()); + // Stop arrives before StartCompleted. + ASSERT_TRUE(c.requestStop()); + EXPECT_EQ(L::State::StopRequested, c.state()); + EXPECT_EQ(1, ops.deliver_stop_calls); + // No resources released until the task acknowledges. + EXPECT_EQ(0, ops.release_calls); + ASSERT_TRUE(c.onTaskStopped()); + EXPECT_EQ(L::State::Stopped, c.state()); + EXPECT_EQ(1, ops.release_calls); +} + +// Handoff Phase 2/Phase 0: "Partial initialization failures ... releases only +// resources owned by that attempt." The rollback releases, but no OTA-barrier +// completion is signalled for a failed start. +TEST(MQTTLifecycle, StartFailureRollsBackResources) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + + ASSERT_TRUE(c.requestStart()); + ASSERT_TRUE(c.onTaskStartFailed()); + EXPECT_EQ(L::State::Stopped, c.state()); + EXPECT_EQ(1, ops.release_calls); + EXPECT_EQ(0, ops.stop_complete_calls); + // Recoverable: a subsequent start is accepted. + EXPECT_TRUE(c.mayRestart()); + EXPECT_TRUE(c.requestStart()); + EXPECT_EQ(2, ops.start_task_calls); +} + +// Handoff: "restart after stop". +TEST(MQTTLifecycle, RestartAfterStop) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + + bringUpToRunning(c); + ASSERT_TRUE(c.requestStop()); + ASSERT_TRUE(c.onTaskStopped()); + EXPECT_TRUE(c.mayRestart()); + + ASSERT_TRUE(c.requestStart()); + EXPECT_EQ(L::State::Starting, c.state()); + ASSERT_TRUE(c.onTaskStarted()); + EXPECT_EQ(L::State::Running, c.state()); + EXPECT_EQ(2, ops.start_task_calls); +} + +// --- Timeout / fallback ---------------------------------------------------- + +// Handoff: "Timeout/fallback behavior when the MQTT task or client does not +// acknowledge." Models the reviewed fallback replacing the abrupt vTaskDelete. +TEST(MQTTLifecycle, StopTimeoutFiresReviewedFallback) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + bringUpToRunning(c); + + ops.now = 1000; + ASSERT_TRUE(c.requestStop()); + EXPECT_EQ(L::State::StopRequested, c.state()); + + // Before the deadline, tick() is inert. + ops.now = 1000 + kStopTimeoutMs - 1; + c.tick(); + EXPECT_EQ(L::State::StopRequested, c.state()); + EXPECT_EQ(0, ops.release_calls); + + // At the deadline the fallback fires: forced release, dirty OTA signal. + ops.now = 1000 + kStopTimeoutMs; + c.tick(); + EXPECT_EQ(L::State::Stopped, c.state()); + EXPECT_TRUE(c.stopTimedOut()); + EXPECT_EQ(1, ops.release_calls); + EXPECT_EQ(1, ops.stop_complete_calls); + EXPECT_FALSE(ops.last_stop_clean); + + // A late ack after the fallback does not double-release. + EXPECT_FALSE(c.onTaskStopped()); + EXPECT_EQ(1, ops.release_calls); +} + +TEST(MQTTLifecycle, TickWithoutPendingStopIsNoop) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + bringUpToRunning(c); + + ops.now = 1'000'000; // far past any deadline + c.tick(); + EXPECT_EQ(L::State::Running, c.state()); + EXPECT_EQ(0, ops.release_calls); +} + +TEST(MQTTLifecycle, AckBeforeTimeoutPreemptsFallback) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + bringUpToRunning(c); + + ops.now = 100; + ASSERT_TRUE(c.requestStop()); + ops.now = 100 + kStopTimeoutMs / 2; + ASSERT_TRUE(c.onTaskStopped()); + EXPECT_TRUE(ops.last_stop_clean); + EXPECT_FALSE(c.stopTimedOut()); + + // A later tick past the original deadline must not fire a second time. + ops.now = 100 + kStopTimeoutMs * 10; + c.tick(); + EXPECT_EQ(1, ops.release_calls); + EXPECT_EQ(1, ops.stop_complete_calls); +} + +// --- Ownership: no access after release ------------------------------------ + +// Handoff: "No client, queue, buffer, or task access after its owner releases +// it," and "A completion acknowledgment before the loop task releases queues, +// buffers, or other shared resources." +TEST(MQTTLifecycle, ResourcesReleasedOnlyAfterAcknowledgment) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + bringUpToRunning(c); + + EXPECT_TRUE(c.mayTouchOwnedState()); + ASSERT_TRUE(c.requestStop()); + // Still safe to touch owned state — resources are not yet released. + EXPECT_TRUE(c.mayTouchOwnedState()); + EXPECT_EQ(0, ops.release_calls); + + ASSERT_TRUE(c.onStopBegan()); + EXPECT_EQ(L::State::Stopping, c.state()); + EXPECT_TRUE(c.mayTouchOwnedState()); + EXPECT_EQ(0, ops.release_calls); + + ASSERT_TRUE(c.onTaskStopped()); + // Owner has released; nothing may touch owned state now. + EXPECT_FALSE(c.mayTouchOwnedState()); + EXPECT_EQ(1, ops.release_calls); +} + +// Handoff: "Cessation of new connects, publishes, retries, and +// reconfigurations" once a stop is requested. +TEST(MQTTLifecycle, NewWorkCeasesOnceStopRequested) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + + EXPECT_FALSE(c.acceptsNewWork()); // Stopped + ASSERT_TRUE(c.requestStart()); + EXPECT_TRUE(c.acceptsNewWork()); // Starting + ASSERT_TRUE(c.onTaskStarted()); + EXPECT_TRUE(c.acceptsNewWork()); // Running + + ASSERT_TRUE(c.requestStop()); + EXPECT_FALSE(c.acceptsNewWork()); // StopRequested + ASSERT_TRUE(c.onStopBegan()); + EXPECT_FALSE(c.acceptsNewWork()); // Stopping + ASSERT_TRUE(c.onTaskStopped()); + EXPECT_FALSE(c.acceptsNewWork()); // Stopped +} + +// --- Stop while doing X (the activity matrix) ------------------------------ + +// Handoff: "Stop while connecting, connected, publishing, retrying, renewing a +// token, running NTP, and applying a slot change." Each activity resolves to +// the same ownership contract: the stop is delivered, new work ceases, and +// resources survive until the task acknowledges. "connecting" happens during +// Starting (before StartCompleted); the rest are Running-state activities. +TEST(MQTTLifecycle, StopDuringEveryActivityHonorsTheContract) { + struct Activity { + const char* label; + bool running; // true => Running-state activity, false => Starting + }; + const Activity activities[] = { + {"connecting", false}, {"connected", true}, + {"publishing", true}, {"retrying", true}, + {"renewingToken", true}, {"runningNtp", true}, + {"applyingSlotChange", true}, + }; + + for (const auto& a : activities) { + SCOPED_TRACE(a.label); + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + + ASSERT_TRUE(c.requestStart()); + if (a.running) { + ASSERT_TRUE(c.onTaskStarted()); + ASSERT_EQ(L::State::Running, c.state()); + } else { + ASSERT_EQ(L::State::Starting, c.state()); + } + + ASSERT_TRUE(c.requestStop()); + EXPECT_EQ(1, ops.deliver_stop_calls); + EXPECT_FALSE(c.acceptsNewWork()); + EXPECT_EQ(0, ops.release_calls); // not until ack + + ASSERT_TRUE(c.onTaskStopped()); + EXPECT_EQ(L::State::Stopped, c.state()); + EXPECT_EQ(1, ops.release_calls); + EXPECT_TRUE(ops.last_stop_clean); + } +} + +// --- Callback delivery timing ---------------------------------------------- + +// Handoff: "Callback delivered before stop, during stop, after disconnect, and +// after the stop acknowledgment." A callback consults mayTouchOwnedState() +// before touching owned state; only the post-acknowledgment callback is a +// no-op. ("after disconnect" is modeled as StopBegan -> Stopping, i.e. the +// task tearing the client down.) +TEST(MQTTLifecycle, CallbackGuardTracksResourceOwnership) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + bringUpToRunning(c); + + EXPECT_TRUE(c.mayTouchOwnedState()); // callback before stop + + ASSERT_TRUE(c.requestStop()); + EXPECT_TRUE(c.mayTouchOwnedState()); // callback during stop (pre-teardown) + + ASSERT_TRUE(c.onStopBegan()); + EXPECT_TRUE(c.mayTouchOwnedState()); // callback after disconnect begins + + ASSERT_TRUE(c.onTaskStopped()); + EXPECT_FALSE(c.mayTouchOwnedState()); // callback after stop-ack => no-op +} + +// --- OTA teardown barrier (release-critical) ------------------------------- + +// Handoff OTA barrier: "firmware erase/write must not begin until MQTT shutdown +// has reached a safe acknowledgment point." +TEST(MQTTLifecycle, OtaFlashBlockedUntilCleanStopAcknowledged) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + bringUpToRunning(c); + + EXPECT_FALSE(c.mayBeginFlash()); // Running + ASSERT_TRUE(c.requestStop()); + EXPECT_FALSE(c.mayBeginFlash()); // StopRequested — not yet safe + ASSERT_TRUE(c.onStopBegan()); + EXPECT_FALSE(c.mayBeginFlash()); // Stopping — still not safe + + ASSERT_TRUE(c.onTaskStopped()); + EXPECT_TRUE(c.mayBeginFlash()); // clean stop => flashing permitted + EXPECT_TRUE(ops.last_stop_clean); +} + +// Handoff OTA barrier: "MQTT stop times out: OTA aborts safely rather than +// writing under uncertain ownership." +TEST(MQTTLifecycle, OtaAbortsWhenStopTimesOut) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + bringUpToRunning(c); + + ops.now = 500; + ASSERT_TRUE(c.requestStop()); + ops.now = 500 + kStopTimeoutMs; + c.tick(); + + EXPECT_EQ(L::State::Stopped, c.state()); + EXPECT_FALSE(c.mayBeginFlash()); // dirty stop => flashing withheld + EXPECT_FALSE(ops.last_stop_clean); + + // A fresh clean start/stop cycle clears the latch and re-enables flashing. + ASSERT_TRUE(c.requestStart()); + EXPECT_FALSE(c.stopTimedOut()); + ASSERT_TRUE(c.onTaskStarted()); + ASSERT_TRUE(c.requestStop()); + ASSERT_TRUE(c.onTaskStopped()); + EXPECT_TRUE(c.mayBeginFlash()); +} + +// Handoff OTA barrier: "the bridge must not be restarted while flash writing is +// active" — expressed here as: no restart while a stop is in progress. +TEST(MQTTLifecycle, NoRestartWhileStopInProgress) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + bringUpToRunning(c); + + ASSERT_TRUE(c.requestStop()); + EXPECT_FALSE(c.mayRestart()); + EXPECT_FALSE(c.requestStart()); // rejected while StopRequested + ASSERT_TRUE(c.onStopBegan()); + EXPECT_FALSE(c.mayRestart()); + EXPECT_FALSE(c.requestStart()); // rejected while Stopping + + ASSERT_TRUE(c.onTaskStopped()); + EXPECT_TRUE(c.mayRestart()); + EXPECT_TRUE(c.requestStart()); +} + +// Handoff OTA barrier: "Repeated failed OTA attempts do not ... leave the +// bridge permanently stopped." +TEST(MQTTLifecycle, RepeatedFailedStopsLeaveBridgeRestartable) { + FakeOps ops; + L::Coordinator c(ops, kStopTimeoutMs); + + for (int attempt = 0; attempt < 3; ++attempt) { + SCOPED_TRACE(attempt); + ASSERT_TRUE(c.requestStart()); + ASSERT_TRUE(c.onTaskStarted()); + ops.now += 1000; + ASSERT_TRUE(c.requestStop()); + ops.now += kStopTimeoutMs; + c.tick(); // times out (dirty) + EXPECT_EQ(L::State::Stopped, c.state()); + EXPECT_TRUE(c.mayRestart()); // never permanently stuck + } + EXPECT_EQ(3, ops.start_task_calls); +} + +// --- Diagnostics ----------------------------------------------------------- + +TEST(MQTTLifecycle, StateAndEventNamesAreStable) { + EXPECT_STREQ("Stopped", L::stateName(L::State::Stopped)); + EXPECT_STREQ("Running", L::stateName(L::State::Running)); + EXPECT_STREQ("StopRequested", L::stateName(L::State::StopRequested)); + EXPECT_STREQ("StartRequested", L::eventName(L::Event::StartRequested)); + EXPECT_STREQ("StopTimedOut", L::eventName(L::Event::StopTimedOut)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_packet_filter/test_mqtt_packet_filter.cpp b/test/test_mqtt_packet_filter/test_mqtt_packet_filter.cpp new file mode 100644 index 000000000..d09085bdd --- /dev/null +++ b/test/test_mqtt_packet_filter/test_mqtt_packet_filter.cpp @@ -0,0 +1,303 @@ +#include + +#include "helpers/MQTTPacketFilter.h" + +namespace Filter = MQTTPacketFilter; + +TEST(MQTTPacketFilter, EmptyAndAllMeanEveryType) { + for (const char* value : {"", " ", "\tall\r\n"}) { + uint16_t mask = 0; + ASSERT_TRUE(Filter::parse(value, &mask)) << value; + EXPECT_EQ(Filter::kAllPacketTypes, mask); + } +} + +TEST(MQTTPacketFilter, NoneMeansNoTypes) { + uint16_t mask = Filter::kAllPacketTypes; + ASSERT_TRUE(Filter::parse(" none ", &mask)); + EXPECT_EQ(0u, mask); +} + +TEST(MQTTPacketFilter, ParsesDecimalCsvWithWhitespaceAndDuplicates) { + uint16_t mask = 0; + ASSERT_TRUE(Filter::parse(" 2, 4,\t15,2 ", &mask)); + EXPECT_EQ(static_cast((1u << 2) | (1u << 4) | (1u << 15)), mask); +} + +TEST(MQTTPacketFilter, FullNumericListCanonicalizesToAll) { + uint16_t mask = 0; + ASSERT_TRUE(Filter::parse("0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15", &mask)); + EXPECT_EQ(Filter::kAllPacketTypes, mask); + char output[Filter::kFilterTextSize]; + ASSERT_TRUE(Filter::format(mask, output, sizeof(output))); + EXPECT_STREQ("all", output); +} + +TEST(MQTTPacketFilter, ParsesPayloadTypeNames) { + uint16_t mask = 0; + ASSERT_TRUE(Filter::parse("advert", &mask)); + EXPECT_EQ(static_cast(1u << 4), mask); + + ASSERT_TRUE(Filter::parse(" txt_msg , grp_txt ", &mask)); + EXPECT_EQ(static_cast((1u << 2) | (1u << 5)), mask); + + // Names and numbers are interchangeable within one list. + ASSERT_TRUE(Filter::parse("advert,2,raw_custom", &mask)); + EXPECT_EQ(static_cast((1u << 4) | (1u << 2) | (1u << 15)), mask); + + // Output stays numeric regardless of how the value was spelled. + char output[Filter::kFilterTextSize]; + ASSERT_TRUE(Filter::format(mask, output, sizeof(output))); + EXPECT_STREQ("2,4,15", output); +} + +TEST(MQTTPacketFilter, EveryNamedTypeResolvesToItsPacketHeaderValue) { + // Pins the table against src/Packet.h's PAYLOAD_TYPE_* values. + const struct { const char* name; uint8_t type; } expected[] = { + {"req", 0}, {"response", 1}, {"txt_msg", 2}, {"ack", 3}, {"advert", 4}, + {"grp_txt", 5}, {"grp_data", 6}, {"anon_req", 7}, {"path", 8}, + {"trace", 9}, {"multipart", 10}, {"control", 11}, {"raw_custom", 15}, + }; + for (const auto& e : expected) { + uint16_t mask = 0; + ASSERT_TRUE(Filter::parse(e.name, &mask)) << e.name; + EXPECT_EQ(static_cast(1u << e.type), mask) << e.name; + } + // Reserved types have no name and stay reachable by number only. + size_t count = 0; + const Filter::NamedPacketType* names = Filter::namedPacketTypes(&count); + EXPECT_EQ(sizeof(expected) / sizeof(expected[0]), count); + for (size_t i = 0; i < count; ++i) { + EXPECT_TRUE(names[i].type < 12 || names[i].type == 15) << names[i].name; + } +} + +TEST(MQTTPacketFilter, RejectsMalformedOrOutOfRangeValuesWithoutChangingOutput) { + const char* invalid[] = { + ",", "1,", ",1", "1,,2", "1, ,2", "-1", "+1", "0x2", "16", + "999999999999999999999", "ALL", "All", "all,2", "none,2", "2-4", "2x", + // Names are lowercase, exact, and are not whole-value keywords. + "ADVERT", "Advert", "advert_", "adver", "advertx", "reserved", "12_reserved", + "2,all", "advert,none", "advert advert", "advert;2", + }; + for (const char* value : invalid) { + uint16_t mask = 0x1234; + EXPECT_FALSE(Filter::parse(value, &mask)) << value; + EXPECT_EQ(0x1234, mask) << value; + } + EXPECT_FALSE(Filter::parse(nullptr, nullptr)); +} + +TEST(MQTTPacketFilter, FormatsSubsetsInAscendingOrder) { + const uint16_t mask = static_cast( + (1u << 15) | (1u << 2) | (1u << 10) | (1u << 0)); + char output[Filter::kFilterTextSize]; + ASSERT_TRUE(Filter::format(mask, output, sizeof(output))); + EXPECT_STREQ("0,2,10,15", output); +} + +TEST(MQTTPacketFilter, FormatsNoneAndHonorsExactBufferBoundaries) { + char none[5]; + ASSERT_TRUE(Filter::format(0, none, sizeof(none))); + EXPECT_STREQ("none", none); + + char too_small[4] = {'x', 'x', 'x', '\0'}; + EXPECT_FALSE(Filter::format(0, too_small, sizeof(too_small))); + EXPECT_STREQ("", too_small); + + char all_types[Filter::kFilterTextSize]; + const uint16_t subset = static_cast(Filter::kAllPacketTypes & ~(1u << 14)); + ASSERT_TRUE(Filter::format(subset, all_types, sizeof(all_types))); + EXPECT_STREQ("0,1,2,3,4,5,6,7,8,9,10,11,12,13,15", all_types); + char one_short[34]; + EXPECT_FALSE(Filter::format(subset, one_short, sizeof(one_short))); + EXPECT_STREQ("", one_short); +} + +TEST(MQTTPacketFilter, MembershipIsBoundedToFourBitTypes) { + EXPECT_TRUE(Filter::allows(static_cast(1u << 0), 0)); + EXPECT_TRUE(Filter::allows(static_cast(1u << 15), 15)); + EXPECT_FALSE(Filter::allows(Filter::kAllPacketTypes, 16)); + EXPECT_FALSE(Filter::allows(0, 0)); +} + +TEST(MQTTPacketFilter, EligibilityIgnoresConnectionButRequiresTopicAndFilter) { + const uint16_t only_advert = static_cast(1u << 4); + EXPECT_TRUE(Filter::slotEligible(true, true, only_advert, 4)); + EXPECT_FALSE(Filter::slotEligible(false, true, only_advert, 4)); + EXPECT_FALSE(Filter::slotEligible(true, false, only_advert, 4)); + EXPECT_FALSE(Filter::slotEligible(true, true, only_advert, 2)); +} + +TEST(MQTTPacketFilter, AllFilteredOrTopicIncompatibleTargetsCompleteIntentionally) { + const uint16_t only_text = static_cast(1u << 2); + const bool filtered = Filter::slotEligible(true, true, only_text, 4); + const bool topic_incompatible = + Filter::slotEligible(true, false, Filter::kAllPacketTypes, 4); + EXPECT_FALSE(filtered || topic_incompatible); + EXPECT_TRUE(Filter::publishComplete(filtered || topic_incompatible, false)); +} + +TEST(MQTTPacketFilter, EligibleDisconnectedTargetStillRequiresRetry) { + const uint16_t only_advert = static_cast(1u << 4); + // Connection state is intentionally absent from slotEligible(): the first + // slot remains a target while disconnected. A second connected slot whose + // filter rejects the advert cannot turn that into intentional completion. + const bool disconnected_eligible = + Filter::slotEligible(true, true, only_advert, 4); + const bool connected_filtered = + Filter::slotEligible(true, true, static_cast(1u << 2), 4); + ASSERT_TRUE(disconnected_eligible); + ASSERT_FALSE(connected_filtered); + EXPECT_FALSE(Filter::publishComplete( + disconnected_eligible || connected_filtered, false)); +} + +TEST(MQTTPacketFilter, PublishFailureRetriesAndAnySuccessCompletes) { + EXPECT_FALSE(Filter::publishComplete(true, false)); + EXPECT_TRUE(Filter::publishComplete(true, true)); + EXPECT_TRUE(Filter::publishComplete(false, true)); // defensive: success wins +} + +TEST(MQTTPacketFilter, UnsupportedRawPathCannotHideEligiblePacketFailure) { + const bool packet_eligible = true; + const bool packet_published = false; + const bool raw_eligible = false; // e.g. MeshRank has no raw topic + const bool raw_published = false; + + EXPECT_FALSE(Filter::publishComplete( + packet_eligible || raw_eligible, + packet_published || raw_published)); + EXPECT_TRUE(Filter::publishComplete(false, false)); +} + +TEST(MQTTPacketFilter, PacketAndRawCanShareTheSameMaskDecision) { + const uint16_t text_and_advert = static_cast((1u << 2) | (1u << 4)); + for (uint8_t type = 0; type <= 15; ++type) { + const bool expected = type == 2 || type == 4; + EXPECT_EQ(expected, Filter::allows(text_and_advert, type)) << unsigned(type); + } +} + +TEST(MQTTPacketFilter, CandidateIsTheTopicFreePartOfEligibility) { + const uint16_t only_advert = static_cast(1u << 4); + EXPECT_TRUE(Filter::slotCandidate(true, only_advert, 4)); + EXPECT_FALSE(Filter::slotCandidate(false, only_advert, 4)); + EXPECT_FALSE(Filter::slotCandidate(true, only_advert, 2)); + // A candidate is exactly an eligible slot minus the topic-support term. The + // bridge evaluates the cheap half first and only builds a topic for slots + // that pass it, so the two must stay in this exact relationship. + for (int enabled = 0; enabled <= 1; ++enabled) { + for (int topic = 0; topic <= 1; ++topic) { + for (uint8_t type = 0; type <= 15; ++type) { + EXPECT_EQ(Filter::slotCandidate(enabled != 0, only_advert, type) && topic != 0, + Filter::slotEligible(enabled != 0, topic != 0, only_advert, type)); + } + } + } +} + +// The gate has to resolve topic support before anything expensive runs: a +// connected slot that cannot form a topic (MeshRank raw, or a meshcore preset +// with no IATA) must reach "intentionally complete" without a serialisation. +TEST(MQTTPacketFilter, TopicIncompatibleTargetsAreCompleteWithoutPublishing) { + const uint16_t all = Filter::kAllPacketTypes; + for (uint8_t type = 0; type <= 15; ++type) { + const bool eligible = Filter::slotEligible(true, false, all, type); + EXPECT_FALSE(eligible) << unsigned(type); + EXPECT_TRUE(Filter::publishComplete(eligible, false)) << unsigned(type); + } + // With topic support restored the same slot becomes a retry target again. + EXPECT_TRUE(Filter::slotEligible(true, true, all, 4)); + EXPECT_FALSE(Filter::publishComplete(Filter::slotEligible(true, true, all, 4), false)); +} + +TEST(MQTTPacketFilter, EnabledUnionIgnoresDisabledSlotsAndGatesTheQueue) { + const uint16_t masks[3] = { + static_cast(1u << 2), // text only + static_cast(1u << 4), // adverts only + Filter::kAllPacketTypes, // everything, but disabled below + }; + const bool enabled[3] = {true, true, false}; + + const uint16_t combined = Filter::enabledUnion(masks, enabled, 3); + EXPECT_EQ(static_cast((1u << 2) | (1u << 4)), combined); + EXPECT_TRUE(Filter::allows(combined, 2)); + EXPECT_TRUE(Filter::allows(combined, 4)); + EXPECT_FALSE(Filter::allows(combined, 3)); // no enabled slot wants ACKs + + // A null `enabled` array counts every slot; no slots means no destination. + EXPECT_EQ(Filter::kAllPacketTypes, Filter::enabledUnion(masks, nullptr, 3)); + EXPECT_EQ(0u, Filter::enabledUnion(masks, enabled, 0)); + EXPECT_EQ(0u, Filter::enabledUnion(nullptr, enabled, 3)); + + // The union must never reject a type any single slot would have published. + for (uint8_t type = 0; type <= 15; ++type) { + const bool any_slot_allows = + (enabled[0] && Filter::allows(masks[0], type)) || + (enabled[1] && Filter::allows(masks[1], type)) || + (enabled[2] && Filter::allows(masks[2], type)); + EXPECT_EQ(any_slot_allows, Filter::allows(combined, type)) << unsigned(type); + } +} + +TEST(MQTTPacketFilter, AllMasksDefaultDrivesTheShortPrefsPayload) { + uint16_t masks[6]; + for (int i = 0; i < 6; ++i) masks[i] = Filter::kAllPacketTypes; + EXPECT_TRUE(Filter::allMasksDefault(masks, 6)); + EXPECT_TRUE(Filter::allMasksDefault(nullptr, 6)); + EXPECT_TRUE(Filter::allMasksDefault(masks, 0)); + + // Any slot off the default — including "none" — opts into the longer payload. + for (int i = 0; i < 6; ++i) { + masks[i] = 0; + EXPECT_FALSE(Filter::allMasksDefault(masks, 6)) << i; + masks[i] = static_cast(Filter::kAllPacketTypes & ~1u); + EXPECT_FALSE(Filter::allMasksDefault(masks, 6)) << i; + masks[i] = Filter::kAllPacketTypes; + } + EXPECT_TRUE(Filter::allMasksDefault(masks, 6)); +} + +// countTypes() backs the diag line's fallback when the exact list won't fit, +// so it has to agree with what format() would have emitted. +TEST(MQTTPacketFilter, CountTypesMatchesTheFormattedList) { + EXPECT_EQ(0, Filter::countTypes(0)); + EXPECT_EQ(16, Filter::countTypes(Filter::kAllPacketTypes)); + EXPECT_EQ(1, Filter::countTypes(static_cast(1u << 15))); + + for (uint32_t mask = 0; mask <= 0xFFFFu; ++mask) { + uint8_t expected = 0; + for (uint8_t type = 0; type <= 15; ++type) { + if ((mask & (1u << type)) != 0) expected++; + } + ASSERT_EQ(expected, Filter::countTypes(static_cast(mask))) << mask; + } +} + +// The diag fallback exists because a clipped list parses as a real, different +// allowlist. Pin that hazard so the fallback is never "simplified" away. +TEST(MQTTPacketFilter, ClippedListStillParsesAsADifferentAllowlist) { + const uint16_t configured = static_cast(Filter::kAllPacketTypes & ~1u); + char text[Filter::kFilterTextSize]; + ASSERT_TRUE(Filter::format(configured, text, sizeof(text))); + ASSERT_STREQ("1,2,3,4,5,6,7,8,9,10,11,12,13,14,15", text); + + // Drop the trailing "15" the way a bounded reply buffer would. + char clipped[Filter::kFilterTextSize]; + memcpy(clipped, text, sizeof(clipped)); + clipped[strlen("1,2,3,4,5,6,7,8,9,10,11,12,13,14,")] = '\0'; + + uint16_t misread = 0; + ASSERT_FALSE(Filter::parse(clipped, &misread)); // trailing comma is rejected + clipped[strlen("1,2,3,4,5,6,7,8,9,10,11,12,13,14")] = '\0'; + ASSERT_TRUE(Filter::parse(clipped, &misread)); // but one char earlier reads clean + EXPECT_NE(configured, misread) << "a clipped list must not be mistaken for the real one"; + EXPECT_EQ(14, Filter::countTypes(misread)); + EXPECT_EQ(15, Filter::countTypes(configured)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_packet_queue_policy/test_mqtt_packet_queue_policy.cpp b/test/test_mqtt_packet_queue_policy/test_mqtt_packet_queue_policy.cpp new file mode 100644 index 000000000..ed2824155 --- /dev/null +++ b/test/test_mqtt_packet_queue_policy/test_mqtt_packet_queue_policy.cpp @@ -0,0 +1,180 @@ +#include + +#include + +#include "helpers/MQTTPacketQueuePolicy.h" + +namespace QueuePolicy = MQTTPacketQueuePolicy; + +TEST(MQTTPacketQueuePolicy, EnqueuesWhileCapacityRemains) { + EXPECT_EQ(QueuePolicy::EnqueueAction::Enqueue, + QueuePolicy::enqueueAction(0, 6)); + EXPECT_EQ(QueuePolicy::EnqueueAction::Enqueue, + QueuePolicy::enqueueAction(5, 6)); +} + +TEST(MQTTPacketQueuePolicy, EvictsOldestAtOrAboveCapacity) { + EXPECT_EQ(QueuePolicy::EnqueueAction::EvictOldestThenEnqueue, + QueuePolicy::enqueueAction(6, 6)); + EXPECT_EQ(QueuePolicy::EnqueueAction::EvictOldestThenEnqueue, + QueuePolicy::enqueueAction(7, 6)); +} + +TEST(MQTTPacketQueuePolicy, RejectsQueueWithZeroCapacity) { + EXPECT_EQ(QueuePolicy::EnqueueAction::Reject, + QueuePolicy::enqueueAction(0, 0)); +} + +TEST(MQTTPacketQueuePolicy, DisconnectedQueueFlushesAtExactStaleBoundary) { + const uint32_t started = 1000; + EXPECT_FALSE(QueuePolicy::shouldFlushDisconnected( + started + QueuePolicy::kDisconnectedStaleMs - 1, started)); + EXPECT_TRUE(QueuePolicy::shouldFlushDisconnected( + started + QueuePolicy::kDisconnectedStaleMs, started)); + EXPECT_TRUE(QueuePolicy::shouldFlushDisconnected( + started + QueuePolicy::kDisconnectedStaleMs + 1, started)); +} + +TEST(MQTTPacketQueuePolicy, ZeroDisconnectedTimestampMeansNotStarted) { + EXPECT_FALSE(QueuePolicy::shouldFlushDisconnected(UINT32_MAX, 0)); +} + +TEST(MQTTPacketQueuePolicy, DisconnectedStaleTimerSurvivesMillisWrap) { + const uint32_t started = UINT32_MAX - 100; + EXPECT_FALSE(QueuePolicy::shouldFlushDisconnected(198, started, 300)); + EXPECT_TRUE(QueuePolicy::shouldFlushDisconnected(199, started, 300)); +} + +TEST(MQTTPacketQueuePolicy, DrainIsGentleThroughFivePackets) { + for (size_t count = 0; count <= QueuePolicy::kBacklogThreshold; ++count) { + const QueuePolicy::DrainBudget budget = QueuePolicy::drainBudget(count); + EXPECT_EQ(QueuePolicy::kGentleDrainCount, budget.max_packets) << count; + EXPECT_EQ(QueuePolicy::kGentleDrainBudgetMs, budget.max_time_ms) << count; + } +} + +TEST(MQTTPacketQueuePolicy, DrainBurstsAboveFivePackets) { + const QueuePolicy::DrainBudget budget = + QueuePolicy::drainBudget(QueuePolicy::kBacklogThreshold + 1); + EXPECT_EQ(QueuePolicy::kBurstDrainCount, budget.max_packets); + EXPECT_EQ(QueuePolicy::kBurstDrainBudgetMs, budget.max_time_ms); +} + +TEST(MQTTPacketQueuePolicy, DrainTimeBudgetUsesInclusiveBoundary) { + EXPECT_TRUE(QueuePolicy::drainTimeAvailable(129, 100, 30)); + EXPECT_TRUE(QueuePolicy::drainTimeAvailable(130, 100, 30)); + EXPECT_FALSE(QueuePolicy::drainTimeAvailable(131, 100, 30)); +} + +TEST(MQTTPacketQueuePolicy, DrainTimeBudgetSurvivesMillisWrap) { + const uint32_t started = UINT32_MAX - 10; + EXPECT_TRUE(QueuePolicy::drainTimeAvailable(19, started, 30)); + EXPECT_FALSE(QueuePolicy::drainTimeAvailable(20, started, 30)); +} + +TEST(MQTTPacketQueuePolicy, NewPacketIsReadyWithoutRetryDeadline) { + EXPECT_TRUE(QueuePolicy::retryReady(100, 0, 0)); + EXPECT_TRUE(QueuePolicy::retryReady(100, 500, 0)); +} + +TEST(MQTTPacketQueuePolicy, RetryBecomesReadyAtExactDeadline) { + EXPECT_FALSE(QueuePolicy::retryReady(499, 500, 1)); + EXPECT_TRUE(QueuePolicy::retryReady(500, 500, 1)); + EXPECT_TRUE(QueuePolicy::retryReady(501, 500, 1)); +} + +TEST(MQTTPacketQueuePolicy, RetryDeadlineSurvivesMillisWrap) { + const uint32_t deadline = 100; + EXPECT_FALSE(QueuePolicy::retryReady(UINT32_MAX - 50, deadline, 1)); + EXPECT_FALSE(QueuePolicy::retryReady(99, deadline, 1)); + EXPECT_TRUE(QueuePolicy::retryReady(100, deadline, 1)); +} + +TEST(MQTTPacketQueuePolicy, WrappedZeroIsARealRetryDeadline) { + EXPECT_FALSE(QueuePolicy::retryReady(UINT32_MAX, 0, 1)); + EXPECT_TRUE(QueuePolicy::retryReady(0, 0, 1)); +} + +TEST(MQTTPacketQueuePolicy, SuccessfulPublishCompletesWithoutChangingAttempts) { + const QueuePolicy::RetryDecision decision = + QueuePolicy::retryDecision(true, 2, 1234); + EXPECT_EQ(QueuePolicy::RetryAction::Complete, decision.action); + EXPECT_EQ(2, decision.retry_attempts); + EXPECT_EQ(0U, decision.delay_ms); + EXPECT_EQ(0U, decision.next_retry_ms); +} + +TEST(MQTTPacketQueuePolicy, FailedPublishSchedulesBoundedRetry) { + const QueuePolicy::RetryDecision minimum = + QueuePolicy::retryDecision(false, 0, 400); + EXPECT_EQ(QueuePolicy::RetryAction::Schedule, minimum.action); + EXPECT_EQ(1, minimum.retry_attempts); + EXPECT_EQ(300U, minimum.delay_ms); + EXPECT_EQ(700U, minimum.next_retry_ms); + + const QueuePolicy::RetryDecision maximum = + QueuePolicy::retryDecision(false, 1, 599); + EXPECT_EQ(QueuePolicy::RetryAction::Schedule, maximum.action); + EXPECT_EQ(2, maximum.retry_attempts); + EXPECT_EQ(499U, maximum.delay_ms); + EXPECT_EQ(1098U, maximum.next_retry_ms); +} + +TEST(MQTTPacketQueuePolicy, ThirdFailedPublishSchedulesFinalRetry) { + const QueuePolicy::RetryDecision decision = + QueuePolicy::retryDecision(false, 2, 1000); + EXPECT_EQ(QueuePolicy::RetryAction::Schedule, decision.action); + EXPECT_EQ(QueuePolicy::kMaxQos0RetryAttempts, decision.retry_attempts); +} + +TEST(MQTTPacketQueuePolicy, FailureAfterFinalRetryDropsPacket) { + const QueuePolicy::RetryDecision decision = + QueuePolicy::retryDecision(false, QueuePolicy::kMaxQos0RetryAttempts, 1000); + EXPECT_EQ(QueuePolicy::RetryAction::Drop, decision.action); + EXPECT_EQ(QueuePolicy::kMaxQos0RetryAttempts, decision.retry_attempts); + EXPECT_EQ(0U, decision.delay_ms); + EXPECT_EQ(0U, decision.next_retry_ms); +} + +TEST(MQTTPacketQueuePolicy, RetrySchedulingDeadlineMayWrapToZero) { + // This timestamp has jitter 98, so its 398 ms delay wraps to exactly zero. + const uint32_t now = UINT32_MAX - 397; + ASSERT_EQ(98U, now % QueuePolicy::kRetryDelayJitterMs); + const QueuePolicy::RetryDecision decision = + QueuePolicy::retryDecision(false, 0, now); + EXPECT_EQ(QueuePolicy::RetryAction::Schedule, decision.action); + EXPECT_EQ(398U, decision.delay_ms); + EXPECT_EQ(0U, decision.next_retry_ms); + EXPECT_FALSE(QueuePolicy::retryReady(UINT32_MAX, decision.next_retry_ms, + decision.retry_attempts)); + EXPECT_TRUE(QueuePolicy::retryReady(0, decision.next_retry_ms, + decision.retry_attempts)); +} + +TEST(MQTTPacketQueuePolicy, PartialPublishCountsAsDeliveredEitherWay) { + EXPECT_TRUE(QueuePolicy::queuedPacketPublished(true, true)); + EXPECT_TRUE(QueuePolicy::queuedPacketPublished(true, false)); // packet ok, raw failed + EXPECT_TRUE(QueuePolicy::queuedPacketPublished(false, true)); // raw ok, packet failed + EXPECT_FALSE(QueuePolicy::queuedPacketPublished(false, false)); // neither reached a slot +} + +TEST(MQTTPacketQueuePolicy, PublishOutcomePairingDrivesRetryDecision) { + // packet succeeds / raw fails -> completed, no retry. + QueuePolicy::RetryDecision d = + QueuePolicy::retryDecision(QueuePolicy::queuedPacketPublished(true, false), 0, 1234U); + EXPECT_EQ(QueuePolicy::RetryAction::Complete, d.action); + + // raw succeeds / packet fails -> also completed. + d = QueuePolicy::retryDecision(QueuePolicy::queuedPacketPublished(false, true), 0, 1234U); + EXPECT_EQ(QueuePolicy::RetryAction::Complete, d.action); + + // both fail on a fresh packet -> scheduled for a bounded retry. + d = QueuePolicy::retryDecision(QueuePolicy::queuedPacketPublished(false, false), 0, 1234U); + EXPECT_EQ(QueuePolicy::RetryAction::Schedule, d.action); + EXPECT_EQ(1U, d.retry_attempts); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp b/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp new file mode 100644 index 000000000..a3b0866fe --- /dev/null +++ b/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp @@ -0,0 +1,480 @@ +#include + +#include +#include +#include +#include +#include + +#include "helpers/MQTTPayloadBuilder.h" + +namespace { + +constexpr const char* kTimestamp = "2026-07-18T12:34:56.123456+00:00"; + +struct RejectAllJsonAllocations : ArduinoJson::Allocator { + void* allocate(size_t) override { return nullptr; } + void deallocate(void*) override {} + void* reallocate(void*, size_t) override { return nullptr; } +}; + +static int buildMinimalStatus(JsonDocument& scratch, char* buffer, size_t buffer_size) { + return MQTTPayloadBuilder::buildStatusMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", "Heltec V3", "v1.16.0", + "915.000000,62.5,7,5", "MeshCore", "online", kTimestamp, + buffer, buffer_size); +} + +static int buildRepresentativePacket(JsonDocument& scratch, const char* direction, + float score, const uint8_t* path, int path_hops, + int path_hash_size, const char* raw, + char* buffer, size_t buffer_size) { + return MQTTPayloadBuilder::buildPacketMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, direction, + "12:34:56", "18/07/2026", 42, 4, "D", 20, raw, + 10.26f, -87, score, "89ABCDEF01234567", path, path_hops, + path_hash_size, 64, buffer, buffer_size); +} + +TEST(MQTTPayloadBuilder, MinimalStatusHasExactRequiredContract) { + JsonDocument scratch; + char buffer[768]; + int len = buildMinimalStatus(scratch, buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + EXPECT_EQ(static_cast(len), strlen(buffer)); + EXPECT_STREQ( + "{\"status\":\"online\",\"timestamp\":\"2026-07-18T12:34:56.123456+00:00\"," + "\"origin\":\"DEN Repeater\",\"origin_id\":\"0123456789ABCDEF\"," + "\"model\":\"Heltec V3\",\"firmware_version\":\"v1.16.0\"," + "\"radio\":\"915.000000,62.5,7,5\",\"client_version\":\"MeshCore\"}", + buffer); + + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_FALSE(parsed["repeat"].is()); + EXPECT_FALSE(parsed["stats"].is()); +} + +TEST(MQTTPayloadBuilder, StatusIncludesRepeatAndEveryRequestedStatistic) { + JsonDocument scratch; + char buffer[1024]; + int len = MQTTPayloadBuilder::buildStatusMessage( + scratch, "node", "id", "model", "firmware", "radio", "client", "online", + kTimestamp, buffer, sizeof(buffer), 4200, 86400, 3, 6, -112, + 11, 22, 4, 180864, 31, 47, "on"); + + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("on", parsed["repeat"].as()); + JsonObject stats = parsed["stats"].as(); + ASSERT_FALSE(stats.isNull()); + EXPECT_EQ(4200, stats["battery_mv"].as()); + EXPECT_EQ(86400, stats["uptime_secs"].as()); + EXPECT_EQ(3, stats["errors"].as()); + EXPECT_EQ(6, stats["queue_len"].as()); + EXPECT_EQ(-112, stats["noise_floor"].as()); + EXPECT_EQ(11, stats["tx_air_secs"].as()); + EXPECT_EQ(22, stats["rx_air_secs"].as()); + EXPECT_EQ(4, stats["recv_errors"].as()); + EXPECT_EQ(180864, stats["internal_heap"].as()); + EXPECT_EQ(31, stats["packets_sent"].as()); + EXPECT_EQ(47, stats["packets_received"].as()); +} + +TEST(MQTTPayloadBuilder, StatusOmissionSentinelsRemainOmitted) { + JsonDocument scratch; + char buffer[768]; + int len = MQTTPayloadBuilder::buildStatusMessage( + scratch, "node", "id", "model", "firmware", "radio", "client", "online", + kTimestamp, buffer, sizeof(buffer), -1, -1, -1, -1, -999, + -1, -1, -1, -1, -1, -1, nullptr); + + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_FALSE(parsed["stats"].is()); + EXPECT_FALSE(parsed["repeat"].is()); +} + +TEST(MQTTPayloadBuilder, StringsAreEscapedAndRoundTrip) { + const char* origin = "node \"north\"\\rack\nline"; + const char* model = "Heltec\tV3"; + JsonDocument scratch; + char buffer[1024]; + int len = MQTTPayloadBuilder::buildStatusMessage( + scratch, origin, "id", model, "v1", "radio", "client", "online", + kTimestamp, buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + EXPECT_NE(std::string::npos, std::string(buffer).find("\\\"north\\\"")); + EXPECT_NE(std::string::npos, std::string(buffer).find("\\\\rack\\nline")); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ(origin, parsed["origin"].as()); + EXPECT_STREQ(model, parsed["model"].as()); +} + +TEST(MQTTPayloadBuilder, RxPacketIncludesMetricsScaledScoreAndPath) { + const uint8_t path[] = {0xAA, 0xBB, 0x01, 0x2F}; + JsonDocument scratch; + char buffer[2048]; + int len = buildRepresentativePacket( + scratch, "rx", 0.125f, path, 2, 2, "A0B1C2D3", buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("PACKET", parsed["type"].as()); + EXPECT_STREQ("rx", parsed["direction"].as()); + EXPECT_STREQ("42", parsed["len"].as()); + EXPECT_STREQ("4", parsed["packet_type"].as()); + EXPECT_STREQ("20", parsed["payload_len"].as()); + EXPECT_STREQ("10.3", parsed["SNR"].as()); + EXPECT_STREQ("-87", parsed["RSSI"].as()); + EXPECT_STREQ("125", parsed["score"].as()); + JsonArray parsed_path = parsed["path"].as(); + ASSERT_EQ(2U, parsed_path.size()); + EXPECT_STREQ("aabb", parsed_path[0].as()); + EXPECT_STREQ("012f", parsed_path[1].as()); +} + +TEST(MQTTPayloadBuilder, TxPacketOmitsReceiveOnlyMetricsAndAbsentPath) { + JsonDocument scratch; + char buffer[2048]; + int len = buildRepresentativePacket( + scratch, "tx", 0.5f, nullptr, 0, 0, "A0B1", buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_FALSE(parsed["SNR"].is()); + EXPECT_FALSE(parsed["RSSI"].is()); + EXPECT_FALSE(parsed["score"].is()); + EXPECT_FALSE(parsed["path"].is()); +} + +TEST(MQTTPayloadBuilder, RxPacketOmitsUnknownNanScore) { + JsonDocument scratch; + char buffer[2048]; + int len = buildRepresentativePacket( + scratch, "rx", std::nanf(""), nullptr, 0, 0, "A0B1", buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_TRUE(parsed["SNR"].is()); + EXPECT_TRUE(parsed["RSSI"].is()); + EXPECT_FALSE(parsed["score"].is()); +} + +TEST(MQTTPayloadBuilder, RawMessageHasExactContractAndEscapesData) { + char buffer[512]; + int len = MQTTPayloadBuilder::buildRawMessage( + "node \"A\"", "id\\1", kTimestamp, "AA\nBB", buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + EXPECT_EQ(static_cast(len), strlen(buffer)); + EXPECT_STREQ( + "{\"origin\":\"node \\\"A\\\"\",\"origin_id\":\"id\\\\1\"," + "\"timestamp\":\"2026-07-18T12:34:56.123456+00:00\"," + "\"type\":\"RAW\",\"data\":\"AA\\nBB\"}", + buffer); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("AA\nBB", parsed["data"].as()); +} + +TEST(MQTTPayloadBuilder, ExactOutputSizeSucceedsAndOneByteShortFailsCleanly) { + JsonDocument scratch; + char reference[768]; + int reference_len = buildMinimalStatus(scratch, reference, sizeof(reference)); + ASSERT_GT(reference_len, 0); + + std::vector exact(static_cast(reference_len) + 1); + EXPECT_EQ(reference_len, buildMinimalStatus(scratch, exact.data(), exact.size())); + EXPECT_STREQ(reference, exact.data()); + + std::vector short_buffer(static_cast(reference_len), 'x'); + EXPECT_EQ(0, buildMinimalStatus(scratch, short_buffer.data(), short_buffer.size())); + EXPECT_EQ('\0', short_buffer[0]); + EXPECT_EQ(0, buildMinimalStatus(scratch, short_buffer.data(), 1)); + EXPECT_EQ('\0', short_buffer[0]); + EXPECT_EQ(0, buildMinimalStatus(scratch, nullptr, exact.size())); +} + +TEST(MQTTPayloadBuilder, MaximumRepresentativePacketAndRawPayloadsRemainValid) { + uint8_t path[64]; + for (size_t i = 0; i < sizeof(path); ++i) path[i] = static_cast(i); + std::string raw(510, 'A'); + + JsonDocument scratch; + char packet_buffer[2048]; + int packet_len = buildRepresentativePacket( + scratch, "rx", 1.0f, path, 16, 4, raw.c_str(), + packet_buffer, sizeof(packet_buffer)); + ASSERT_GT(packet_len, 0); + JsonDocument packet; + ASSERT_FALSE(deserializeJson(packet, packet_buffer)); + EXPECT_EQ(510U, strlen(packet["raw"].as())); + JsonArray parsed_path = packet["path"].as(); + ASSERT_EQ(16U, parsed_path.size()); + EXPECT_STREQ("00010203", parsed_path[0].as()); + EXPECT_STREQ("3c3d3e3f", parsed_path[15].as()); + + char raw_buffer[1024]; + int raw_len = MQTTPayloadBuilder::buildRawMessage( + "node", "0123456789ABCDEF", kTimestamp, raw.c_str(), + raw_buffer, sizeof(raw_buffer)); + ASSERT_GT(raw_len, 0); + JsonDocument parsed_raw; + ASSERT_FALSE(deserializeJson(parsed_raw, raw_buffer)); + EXPECT_EQ(510U, strlen(parsed_raw["data"].as())); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageRoundTripsSelfAndEntries) { + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[] = { + {"0011223344556677", 9.75f, 42, "DEN,APRS", "active"}, + {"8899AABBCCDDEEFF", -3.5f, 3600, "", "stale"}, + }; + + JsonDocument scratch; + char buffer[1024]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS", "*", + neighbors, 2, buffer, sizeof(buffer), 5, 2, true); + + ASSERT_GT(len, 0); + EXPECT_EQ(static_cast(len), strlen(buffer)); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("2026-07-18T12:34:56.123456+00:00", parsed["timestamp"].as()); + EXPECT_STREQ("DEN Repeater", parsed["origin"].as()); + EXPECT_STREQ("0123456789ABCDEF", parsed["origin_id"].as()); + EXPECT_STREQ("DEN,APRS", parsed["self"]["scopes"].as()); + EXPECT_STREQ("*", parsed["self"]["default_scope"].as()); + EXPECT_EQ(5, parsed["total_neighbors"].as()); + EXPECT_EQ(2, parsed["queried_neighbors"].as()); + EXPECT_TRUE(parsed["truncated"].as()); + + JsonArray arr = parsed["neighbors"].as(); + ASSERT_EQ(2U, arr.size()); + EXPECT_STREQ("0011223344556677", arr[0]["pubkey"].as()); + EXPECT_FLOAT_EQ(9.75f, arr[0]["snr"].as()); + EXPECT_EQ(42U, arr[0]["heard_secs_ago"].as()); + EXPECT_STREQ("DEN,APRS", arr[0]["scopes"].as()); + EXPECT_STREQ("active", arr[0]["status"].as()); + EXPECT_STREQ("8899AABBCCDDEEFF", arr[1]["pubkey"].as()); + EXPECT_STREQ("", arr[1]["scopes"].as()); + EXPECT_STREQ("stale", arr[1]["status"].as()); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageMeasurementsMatchCompletePayload) { + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[] = { + {"0011223344556677", 9.75f, UINT32_MAX, "DEN,APRS", "responded"}, + {"8899AABBCCDDEEFF", -3.5f, UINT32_MAX, "", "timeout"}, + }; + + size_t measured = + MQTTPayloadBuilder::measureNeighborsMessageBase( + "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS", "*", 2) + + MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbors[0]) + + 1 // array comma + + MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbors[1]); + + JsonDocument scratch; + char buffer[1024]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS", "*", + neighbors, 2, buffer, sizeof(buffer), 2, 2, false); + + ASSERT_GT(len, 0); + EXPECT_EQ(measured, static_cast(len)); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageMeasuredPrefixStopsBeforeOverflow) { + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[20]; + static char keys[20][17]; + static char long_scopes[96]; + memset(long_scopes, 'S', sizeof(long_scopes) - 1); + long_scopes[sizeof(long_scopes) - 1] = '\0'; + for (int i = 0; i < 20; i++) { + snprintf(keys[i], sizeof(keys[i]), "%016X", i); + neighbors[i] = { + keys[i], static_cast(i), UINT32_MAX, + long_scopes, "responded"}; + } + + constexpr size_t kBufferSize = 512; + size_t used = MQTTPayloadBuilder::measureNeighborsMessageBase( + "node", "id", kTimestamp, "DEN", "*", 20); + int published = 0; + int queried = 0; + while (queried < 20) { + size_t added = + MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbors[queried]) + + (published > 0 ? 1U : 0U); + queried++; + if (used + added >= kBufferSize) break; + used += added; + published++; + } + + ASSERT_GT(published, 0); + ASSERT_LT(published, 20); + ASSERT_EQ(published + 1, queried); // only the non-fitting candidate was extra + + JsonDocument scratch; + char buffer[kBufferSize]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "node", "id", kTimestamp, "DEN", "*", + neighbors, published, buffer, sizeof(buffer), + 20, queried, true); + + ASSERT_GT(len, 0); + EXPECT_LT(static_cast(len), sizeof(buffer)); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_EQ(static_cast(published), + parsed["neighbors"].as().size()); + EXPECT_EQ(20, parsed["total_neighbors"].as()); + EXPECT_EQ(queried, parsed["queried_neighbors"].as()); + EXPECT_TRUE(parsed["truncated"].as()); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageFallbackMarksTruncated) { + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[] = { + {"0011223344556677", 9.75f, 1, "DEN", "responded"}, + {"8899AABBCCDDEEFF", -3.5f, 2, "APRS", "responded"}, + }; + size_t first_only_size = + MQTTPayloadBuilder::measureNeighborsMessageBase( + "node", "id", kTimestamp, "DEN", "*", 2) + + MQTTPayloadBuilder::measureNeighborsMessageEntry(neighbors[0]); + std::vector buffer(first_only_size + 1); + + JsonDocument scratch; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "node", "id", kTimestamp, "DEN", "*", + neighbors, 2, buffer.data(), buffer.size(), + 2, 2, false); + + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer.data())); + EXPECT_EQ(1U, parsed["neighbors"].as().size()); + EXPECT_TRUE(parsed["truncated"].as()); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageFailsCleanlyOnAllocationFailure) { + MQTTPayloadBuilder::NeighborsMessageEntry neighbor = { + "0011223344556677", 9.75f, 1, "DEN", "responded"}; + RejectAllJsonAllocations allocator; + JsonDocument scratch(&allocator); + char buffer[512]; + + EXPECT_EQ(0, MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "node", "id", kTimestamp, "DEN", "*", + &neighbor, 1, buffer, sizeof(buffer), + 1, 1, false)); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageHandlesEmptyTableAndNullScopes) { + JsonDocument scratch; + char buffer[256]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "node", "id", kTimestamp, nullptr, nullptr, nullptr, 0, + buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("", parsed["self"]["scopes"].as()); + EXPECT_STREQ("", parsed["self"]["default_scope"].as()); + JsonArray arr = parsed["neighbors"].as(); + ASSERT_TRUE(arr.isNull() == false); + EXPECT_EQ(0U, arr.size()); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageDropsTailWhenBufferFills) { + // Twenty entries far exceed a tight buffer; the builder must emit a prefix + // that still parses as complete JSON rather than truncating mid-document. + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[20]; + static char keys[20][17]; + for (int i = 0; i < 20; i++) { + snprintf(keys[i], sizeof(keys[i]), "%016X", i); + neighbors[i].pubkey_hex = keys[i]; + neighbors[i].snr = static_cast(i); + neighbors[i].heard_secs_ago = static_cast(i) * 10U; + neighbors[i].heard_unknown = false; + neighbors[i].scopes = "DEN"; + neighbors[i].status = "active"; + } + + JsonDocument scratch; + char buffer[512]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "node", "id", kTimestamp, "DEN", "*", neighbors, 20, + buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + EXPECT_LT(static_cast(len), sizeof(buffer)); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + JsonArray arr = parsed["neighbors"].as(); + ASSERT_FALSE(arr.isNull()); + EXPECT_GT(arr.size(), 0U); + EXPECT_LT(arr.size(), 20U); + EXPECT_FALSE(parsed["truncated"].is()); + // Kept entries are the head of the input, in order. + EXPECT_STREQ(keys[0], arr[0]["pubkey"].as()); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageRendersUnknownHeardAgeAsNull) { + // A neighbour stamped before the clock was set has no usable age. The key + // stays present and null so a consumer cannot read it as "heard just now". + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[2]; + neighbors[0] = {"0011223344556677", 9.75f, 42, "DEN", "responded"}; + neighbors[1] = {"8899AABBCCDDEEFF", -3.5f, 0, "DEN", "responded"}; + neighbors[1].heard_unknown = true; + + JsonDocument scratch; + char buffer[1024]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "node", "id", kTimestamp, "DEN", "*", neighbors, 2, + buffer, sizeof(buffer), 2, 2, false); + + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + JsonArray arr = parsed["neighbors"].as(); + ASSERT_EQ(2U, arr.size()); + EXPECT_EQ(42U, arr[0]["heard_secs_ago"].as()); + EXPECT_FALSE(arr[0]["heard_secs_ago"].isNull()); + EXPECT_TRUE(arr[1]["heard_secs_ago"].isNull()); + EXPECT_NE(nullptr, strstr(buffer, "\"heard_secs_ago\":null")); + // The rest of the entry still renders. + EXPECT_STREQ("8899AABBCCDDEEFF", arr[1]["pubkey"].as()); + EXPECT_STREQ("responded", arr[1]["status"].as()); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageUnknownHeardAgeFitsMeasuredWidth) { + // Paced discovery measures each entry with a known UINT32_MAX age; a null age + // must never serialize wider than what that reserved. + MQTTPayloadBuilder::NeighborsMessageEntry measured = { + "0011223344556677", 9.75f, UINT32_MAX, "DEN", "responded"}; + MQTTPayloadBuilder::NeighborsMessageEntry unknown = measured; + unknown.heard_unknown = true; + unknown.heard_secs_ago = 0; + + EXPECT_LE(MQTTPayloadBuilder::measureNeighborsMessageEntry(unknown), + MQTTPayloadBuilder::measureNeighborsMessageEntry(measured)); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp b/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp new file mode 100644 index 000000000..67880a824 --- /dev/null +++ b/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp @@ -0,0 +1,623 @@ +#include + +#include +#include +#include +#include + +#include "helpers/MQTTPrefsAtomicStore.h" +#include "helpers/MQTTPrefsRecovery.h" + +namespace AtomicStore = MQTTPrefsAtomicStore; +namespace Recovery = MQTTPrefsRecovery; + +namespace { + +enum class FailurePoint { + None, + Begin, + HeaderWrite, + PayloadWrite, + ImageWrite, + Finish, + Commit, +}; + +class InMemoryStore { +public: + explicit InMemoryStore(FailurePoint failure, bool preexisting_recovery_temp = false) + : _failure(failure), _preexisting_recovery_temp(preexisting_recovery_temp) { + _files["/mqtt_prefs"] = {'o', 'l', 'd', '-', 'p', 'r', 'e', 'f', 's'}; + if (_preexisting_recovery_temp) _files["/mqtt_prefs.tmp"] = {'r', 'e', 'c', 'o', 'v', 'e', 'r'}; + } + + bool begin() { + ++begin_calls; + if (_preexisting_recovery_temp) return false; + _files.erase("/mqtt_prefs.tmp"); + _open = _failure != FailurePoint::Begin; + _owns_temp = _open; + return _open; + } + + size_t write(const uint8_t* bytes, size_t size) { + ++write_calls; + if (!_open) return 0; + const bool should_fail = (write_calls == 1 && _failure == FailurePoint::HeaderWrite) || + (write_calls == 2 && _failure == FailurePoint::PayloadWrite); + const size_t written = should_fail && size > 0 ? size - 1 : size; + _staging.insert(_staging.end(), bytes, bytes + written); + return written; + } + + bool finish() { + ++finish_calls; + _open = false; + if (_failure == FailurePoint::Finish) return false; + _files["/mqtt_prefs.tmp"] = _staging; + _finished = true; + return true; + } + + bool commit() { + ++commit_calls; + if (_failure == FailurePoint::Commit) return false; + _files["/mqtt_prefs"] = _files["/mqtt_prefs.tmp"]; + _files.erase("/mqtt_prefs.tmp"); + _finished = false; + return true; + } + + void abort() { + ++abort_calls; + _open = false; + _staging.clear(); + // Mirrors MQTTPrefsFileStore: after finish(), a failed commit may already + // have moved the old primary to .bak, so the verified temp is recovery + // data rather than disposable staging. + if (_owns_temp && !_finished) _files.erase("/mqtt_prefs.tmp"); + _finished = false; + _owns_temp = false; + } + + const std::vector& source() const { return _files.at("/mqtt_prefs"); } + bool tempExists() const { return _files.count("/mqtt_prefs.tmp") != 0; } + + int begin_calls = 0; + int write_calls = 0; + int finish_calls = 0; + int commit_calls = 0; + int abort_calls = 0; + +private: + FailurePoint _failure; + bool _preexisting_recovery_temp = false; + bool _open = false; + bool _finished = false; + bool _owns_temp = false; + std::vector _staging; + std::map> _files; +}; + +AtomicStore::Result run(InMemoryStore* store) { + const uint8_t header[] = {0xf5, 'M', 'Q', 'P', 1, 0, 0x09, 0x00}; + const uint8_t payload[] = {'n', 'e', 'w', '-', 'p', 'r', 'e', 'f', 's'}; + return AtomicStore::write(*store, header, sizeof(header), payload, sizeof(payload)); +} + +AtomicStore::Result runWithObserverTail(InMemoryStore* store) { + const uint8_t header[] = {0xf5, 'M', 'Q', 'P', 1, 0, 0x0a, 0x00}; + // The final three bytes stand in for the observer tail transferred from a + // legacy /com_prefs file. They must be committed before that source is compacted. + const uint8_t payload[] = {'m', 'i', 'g', 'r', 'a', 't', 'e', 0x91, 0x7e, 0xa5}; + return AtomicStore::write(*store, header, sizeof(header), payload, sizeof(payload)); +} + +class LegacyComPrefs { +public: + LegacyComPrefs() : bytes({'l', 'e', 'g', 'a', 'c', 'y', '-', 'c', 'o', 'm'}) {} + + void compactAfterMqttCommit(const std::vector& mqtt_bytes) { + const std::vector observer_tail = {0x91, 0x7e, 0xa5}; + mqtt_tail_present_before_compaction = mqtt_bytes.size() >= observer_tail.size() && + std::equal(observer_tail.rbegin(), observer_tail.rend(), mqtt_bytes.rbegin()); + bytes = {'c', 'o', 'm', 'p', 'a', 'c', 't'}; + ++compact_calls; + } + + std::vector bytes; + bool mqtt_tail_present_before_compaction = false; + int compact_calls = 0; +}; + +class LegacyNodePrefs { +public: + LegacyNodePrefs() : bytes({'l', 'e', 'g', 'a', 'c', 'y', '-', 'n', 'o', 'd', 'e'}) {} + + void migrateAfterMqttCommit(const std::vector& mqtt_bytes) { + const std::vector observer_tail = {0x91, 0x7e, 0xa5}; + mqtt_tail_present_before_removal = mqtt_bytes.size() >= observer_tail.size() && + std::equal(observer_tail.rbegin(), observer_tail.rend(), mqtt_bytes.rbegin()); + bytes.clear(); // model removal after current-layout /com_prefs is written + ++migration_calls; + } + + std::vector bytes; + bool mqtt_tail_present_before_removal = false; + int migration_calls = 0; +}; + +// Models the final old-name migration separately from the MQTT transaction: +// /com_prefs is absent while /node_prefs is authoritative. A failed temp write +// or rename must leave that source as the only usable preference image. +class InMemoryCommonPrefsStore { +public: + explicit InMemoryCommonPrefsStore(FailurePoint failure) : _failure(failure) { + _files["/node_prefs"] = {'l', 'e', 'g', 'a', 'c', 'y', '-', 'n', 'o', 'd', 'e'}; + } + + bool begin() { + ++begin_calls; + _files.erase("/com_prefs.tmp"); + _staging.clear(); + _open = _failure != FailurePoint::Begin; + return _open; + } + + size_t write(const uint8_t* bytes, size_t size) { + ++write_calls; + if (!_open) return 0; + const bool should_fail = _failure == FailurePoint::ImageWrite && write_calls == 2; + const size_t written = should_fail && size > 0 ? size - 1 : size; + _staging.insert(_staging.end(), bytes, bytes + written); + return written; + } + + bool finish() { + ++finish_calls; + _open = false; + if (_failure == FailurePoint::Finish) return false; + _files["/com_prefs.tmp"] = _staging; + return true; + } + + bool commit() { + ++commit_calls; + if (_failure == FailurePoint::Commit) return false; + _files["/com_prefs"] = _files["/com_prefs.tmp"]; + _files.erase("/com_prefs.tmp"); + return true; + } + + void abort() { + ++abort_calls; + _open = false; + _staging.clear(); + _files.erase("/com_prefs.tmp"); + } + + void removeNodeSource() { _files.erase("/node_prefs"); } + const std::vector& nodeSource() const { return _files.at("/node_prefs"); } + const std::vector& destination() const { return _files.at("/com_prefs"); } + bool destinationExists() const { return _files.count("/com_prefs") != 0; } + bool tempExists() const { return _files.count("/com_prefs.tmp") != 0; } + bool nodeSourceIsPreferred() const { + return _files.count("/node_prefs") != 0 && _files.count("/com_prefs") == 0; + } + bool nodeSourceExists() const { return _files.count("/node_prefs") != 0; } + + int begin_calls = 0; + int write_calls = 0; + int finish_calls = 0; + int commit_calls = 0; + int abort_calls = 0; + +private: + FailurePoint _failure; + bool _open = false; + std::vector _staging; + std::map> _files; +}; + +AtomicStore::ImageResult runCommonPrefsImage(InMemoryCommonPrefsStore* store) { + const uint8_t core[] = {'c', 'o', 'm', '-', 'p', 'r', 'e', 'f', 's'}; + const uint8_t tail[] = {0x19, 0xa4, 0x7e}; + return AtomicStore::writeImage(*store, [&core, &tail](InMemoryCommonPrefsStore& target) { + return target.write(core, sizeof(core)) == sizeof(core) && + target.write(tail, sizeof(tail)) == sizeof(tail); + }); +} + +// Models the exact SPIFFS transaction used by MQTTPrefsFileStore. Files only +// move by rename: SPIFFS rejects a destination that already exists, so the old +// primary must remain available as .bak until the new temp owns the primary. +class SpiffsMqttTransaction { +public: + enum class Boundary { + BeforeBackupRename, + AfterBackupRename, + AfterPrimaryRename, + AfterBackupCleanup, + }; + + SpiffsMqttTransaction() { + _files["/mqtt_prefs"] = oldImage(); + } + + void writeVerifiedTemp() { _files["/mqtt_prefs.tmp"] = newImage(); } + void cutDuringTempWrite() { _files["/mqtt_prefs.tmp"] = {'n'}; } + + void cutAt(Boundary boundary) { + writeVerifiedTemp(); + if (boundary == Boundary::BeforeBackupRename) return; + rename("/mqtt_prefs", "/mqtt_prefs.bak"); + if (boundary == Boundary::AfterBackupRename) return; + rename("/mqtt_prefs.tmp", "/mqtt_prefs"); + if (boundary == Boundary::AfterPrimaryRename) return; + _files.erase("/mqtt_prefs.bak"); + } + + // Inject ordinary operation failures (as distinct from a power cut). A + // failed temp rename leaves both the verified temp and old backup intact. + bool publish(bool fail_backup_rename, bool fail_temp_rename, bool fail_cleanup) { + writeVerifiedTemp(); + if (fail_backup_rename || !rename("/mqtt_prefs", "/mqtt_prefs.bak")) return false; + if (fail_temp_rename || !rename("/mqtt_prefs.tmp", "/mqtt_prefs")) return false; + if (!fail_cleanup) _files.erase("/mqtt_prefs.bak"); + return true; // backup cleanup is intentionally non-fatal after publish + } + + void recover(Recovery::FileState primary = Recovery::FileState::Usable, + Recovery::FileState temp = Recovery::FileState::Usable, + Recovery::FileState backup = Recovery::FileState::Usable) { + const bool had_primary = _files.count("/mqtt_prefs") != 0; + const auto stateFor = [&](const char* path, Recovery::FileState readable) { + return _files.count(path) == 0 ? Recovery::FileState::Missing : readable; + }; + const Recovery::Action action = Recovery::select( + stateFor("/mqtt_prefs", primary), stateFor("/mqtt_prefs.tmp", temp), + stateFor("/mqtt_prefs.bak", backup)); + if (action == Recovery::Action::PromoteTemp) { + rename("/mqtt_prefs.tmp", "/mqtt_prefs"); + // Match production: once a usable temp becomes primary, every backup is + // stale and is cleared so a second save can start this boot. + if (temp == Recovery::FileState::Usable && backup != Recovery::FileState::Missing) { + _files.erase("/mqtt_prefs.bak"); + } + return; + } + if (action == Recovery::Action::PromoteBackup) { + rename("/mqtt_prefs.bak", "/mqtt_prefs"); + if (backup == Recovery::FileState::Usable && temp != Recovery::FileState::Missing) { + _files.erase("/mqtt_prefs.tmp"); + } + return; + } + + // A usable primary is authoritative, so production cleans every stale or + // incomplete transaction artifact. It only preserves artifacts when the + // primary itself is opaque. + if (had_primary && primary == Recovery::FileState::Usable) { + _files.erase("/mqtt_prefs.tmp"); + _files.erase("/mqtt_prefs.bak"); + } + } + + bool has(const char* path) const { return _files.count(path) != 0; } + bool canStartSave() const { return !has("/mqtt_prefs.tmp") && !has("/mqtt_prefs.bak"); } + const std::vector& primary() const { return _files.at("/mqtt_prefs"); } + static std::vector oldImage() { return {'o', 'l', 'd'}; } + static std::vector newImage() { return {'n', 'e', 'w'}; } + +private: + bool rename(const char* from, const char* to) { + if (_files.count(from) == 0 || _files.count(to) != 0) return false; + _files[to] = _files[from]; + _files.erase(from); + return true; + } + + std::map> _files; +}; + +} // namespace + +TEST(MQTTPrefsAtomicStore, CommitPublishesExactHeaderThenPayload) { + InMemoryStore store(FailurePoint::None); + + EXPECT_EQ(AtomicStore::Result::Committed, run(&store)); + EXPECT_EQ((std::vector{0xf5, 'M', 'Q', 'P', 1, 0, 0x09, 0x00, + 'n', 'e', 'w', '-', 'p', 'r', 'e', 'f', 's'}), + store.source()); + EXPECT_FALSE(store.tempExists()); + EXPECT_EQ(1, store.begin_calls); + EXPECT_EQ(2, store.write_calls); + EXPECT_EQ(1, store.finish_calls); + EXPECT_EQ(1, store.commit_calls); + EXPECT_EQ(0, store.abort_calls); +} + +TEST(MQTTPrefsAtomicStore, AnyFailureAbortsAndPreservesExistingSource) { + const std::vector source = {'o', 'l', 'd', '-', 'p', 'r', 'e', 'f', 's'}; + const struct { + FailurePoint point; + AtomicStore::Result expected; + int writes; + int finishes; + int commits; + } cases[] = { + {FailurePoint::Begin, AtomicStore::Result::BeginFailed, 0, 0, 0}, + {FailurePoint::HeaderWrite, AtomicStore::Result::HeaderWriteFailed, 1, 0, 0}, + {FailurePoint::PayloadWrite, AtomicStore::Result::PayloadWriteFailed, 2, 0, 0}, + {FailurePoint::Finish, AtomicStore::Result::FinishFailed, 2, 1, 0}, + {FailurePoint::Commit, AtomicStore::Result::CommitFailed, 2, 1, 1}, + }; + + for (const auto& test_case : cases) { + InMemoryStore store(test_case.point); + EXPECT_EQ(test_case.expected, run(&store)); + EXPECT_EQ(source, store.source()); + EXPECT_EQ(test_case.point == FailurePoint::Commit, store.tempExists()); + EXPECT_EQ(1, store.begin_calls); + EXPECT_EQ(test_case.writes, store.write_calls); + EXPECT_EQ(test_case.finishes, store.finish_calls); + EXPECT_EQ(test_case.commits, store.commit_calls); + EXPECT_EQ(1, store.abort_calls); + } +} + +TEST(MQTTPrefsAtomicStore, BeginFailureDoesNotErasePreexistingRecoveryTemp) { + InMemoryStore store(FailurePoint::Begin, true); + + EXPECT_EQ(AtomicStore::Result::BeginFailed, run(&store)); + EXPECT_TRUE(store.tempExists()); + EXPECT_EQ(1, store.abort_calls); +} + +TEST(MQTTPrefsAtomicStore, LegacyCrossFileUpgradeCommitsTailBeforeCompactingComPrefs) { + InMemoryStore mqtt_store(FailurePoint::None); + LegacyComPrefs com_prefs; + AtomicStore::LegacyUpgradeGate gate(true); + gate.requireMqttRewrite(); + + const AtomicStore::Result result = runWithObserverTail(&mqtt_store); + gate.recordMqttSave(AtomicStore::committed(result)); + ASSERT_TRUE(gate.mayRewriteComPrefs()); + + com_prefs.compactAfterMqttCommit(mqtt_store.source()); + gate.recordComPrefsRewrite(); + + EXPECT_TRUE(com_prefs.mqtt_tail_present_before_compaction); + EXPECT_EQ(1, com_prefs.compact_calls); + EXPECT_EQ((std::vector{'c', 'o', 'm', 'p', 'a', 'c', 't'}), com_prefs.bytes); + EXPECT_FALSE(gate.mayRewriteComPrefs()); +} + +TEST(MQTTPrefsAtomicStore, LegacyCrossFilePowerCutPreservesBothSources) { + const std::vector legacy_mqtt = {'o', 'l', 'd', '-', 'p', 'r', 'e', 'f', 's'}; + const std::vector legacy_com = {'l', 'e', 'g', 'a', 'c', 'y', '-', 'c', 'o', 'm'}; + for (const FailurePoint point : {FailurePoint::Begin, FailurePoint::HeaderWrite, + FailurePoint::PayloadWrite, FailurePoint::Finish, + FailurePoint::Commit}) { + InMemoryStore mqtt_store(point); + LegacyComPrefs com_prefs; + AtomicStore::LegacyUpgradeGate gate(true); + gate.requireMqttRewrite(); + + const AtomicStore::Result result = runWithObserverTail(&mqtt_store); + gate.recordMqttSave(AtomicStore::committed(result)); + if (gate.mayRewriteComPrefs()) { + com_prefs.compactAfterMqttCommit(mqtt_store.source()); + gate.recordComPrefsRewrite(); + } + + EXPECT_FALSE(AtomicStore::committed(result)); + EXPECT_EQ(legacy_mqtt, mqtt_store.source()); + EXPECT_EQ(legacy_com, com_prefs.bytes); + EXPECT_EQ(0, com_prefs.compact_calls); + EXPECT_TRUE(gate.blocksComPrefsRewrite()); + } +} + +TEST(MQTTPrefsAtomicStore, LegacyNodePrefsMigrationWaitsForObserverTailCommit) { + InMemoryStore mqtt_store(FailurePoint::None); + LegacyNodePrefs node_prefs; + AtomicStore::LegacyUpgradeGate gate(true); + gate.requireMqttRewrite(); + + const AtomicStore::Result result = runWithObserverTail(&mqtt_store); + gate.recordMqttSave(AtomicStore::committed(result)); + ASSERT_TRUE(gate.mayRewriteComPrefs()); + + node_prefs.migrateAfterMqttCommit(mqtt_store.source()); + gate.recordComPrefsRewrite(); + + EXPECT_TRUE(node_prefs.mqtt_tail_present_before_removal); + EXPECT_EQ(1, node_prefs.migration_calls); + EXPECT_TRUE(node_prefs.bytes.empty()); +} + +TEST(MQTTPrefsAtomicStore, LegacyNodePrefsPowerCutPreservesSource) { + const std::vector legacy_mqtt = {'o', 'l', 'd', '-', 'p', 'r', 'e', 'f', 's'}; + const std::vector legacy_node = { + 'l', 'e', 'g', 'a', 'c', 'y', '-', 'n', 'o', 'd', 'e'}; + for (const FailurePoint point : {FailurePoint::Begin, FailurePoint::HeaderWrite, + FailurePoint::PayloadWrite, FailurePoint::Finish, + FailurePoint::Commit}) { + InMemoryStore mqtt_store(point); + LegacyNodePrefs node_prefs; + AtomicStore::LegacyUpgradeGate gate(true); + gate.requireMqttRewrite(); + + const AtomicStore::Result result = runWithObserverTail(&mqtt_store); + gate.recordMqttSave(AtomicStore::committed(result)); + if (gate.mayRewriteComPrefs()) { + node_prefs.migrateAfterMqttCommit(mqtt_store.source()); + gate.recordComPrefsRewrite(); + } + + EXPECT_FALSE(AtomicStore::committed(result)); + EXPECT_EQ(legacy_mqtt, mqtt_store.source()); + EXPECT_EQ(legacy_node, node_prefs.bytes); + EXPECT_EQ(0, node_prefs.migration_calls); + EXPECT_TRUE(gate.blocksComPrefsRewrite()); + } +} + +TEST(MQTTPrefsAtomicStore, NodePrefsMigrationPublishesComPrefsBeforeRemovingSource) { + InMemoryCommonPrefsStore store(FailurePoint::None); + + ASSERT_EQ(AtomicStore::ImageResult::Committed, runCommonPrefsImage(&store)); + EXPECT_TRUE(store.nodeSourceExists()); // caller removes it only after commit + EXPECT_EQ((std::vector{'c', 'o', 'm', '-', 'p', 'r', 'e', 'f', 's', 0x19, 0xa4, 0x7e}), + store.destination()); + EXPECT_FALSE(store.tempExists()); + + store.removeNodeSource(); + EXPECT_FALSE(store.nodeSourceExists()); + EXPECT_TRUE(store.destinationExists()); +} + +TEST(MQTTPrefsAtomicStore, NodePrefsMigrationFailurePreservesSourceAndNeverPrefersPartialDestination) { + const std::vector legacy_node = { + 'l', 'e', 'g', 'a', 'c', 'y', '-', 'n', 'o', 'd', 'e'}; + const struct { + FailurePoint point; + AtomicStore::ImageResult expected; + int writes; + int finishes; + int commits; + } cases[] = { + {FailurePoint::Begin, AtomicStore::ImageResult::BeginFailed, 0, 0, 0}, + {FailurePoint::ImageWrite, AtomicStore::ImageResult::WriteFailed, 2, 0, 0}, + {FailurePoint::Finish, AtomicStore::ImageResult::FinishFailed, 2, 1, 0}, + {FailurePoint::Commit, AtomicStore::ImageResult::CommitFailed, 2, 1, 1}, + }; + + for (const auto& test_case : cases) { + InMemoryCommonPrefsStore store(test_case.point); + EXPECT_EQ(test_case.expected, runCommonPrefsImage(&store)); + EXPECT_EQ(legacy_node, store.nodeSource()); + EXPECT_TRUE(store.nodeSourceIsPreferred()); + EXPECT_FALSE(store.destinationExists()); + EXPECT_FALSE(store.tempExists()); + EXPECT_EQ(1, store.begin_calls); + EXPECT_EQ(test_case.writes, store.write_calls); + EXPECT_EQ(test_case.finishes, store.finish_calls); + EXPECT_EQ(test_case.commits, store.commit_calls); + EXPECT_EQ(1, store.abort_calls); + } +} + +TEST(MQTTPrefsAtomicStore, SpiffsPowerCutsAtEveryPublishBoundaryLeaveRecoverableImage) { + const struct { + SpiffsMqttTransaction::Boundary boundary; + std::vector expected_after_reboot; + } cases[] = { + // Temp has not become the committed image yet, so the old primary wins. + {SpiffsMqttTransaction::Boundary::BeforeBackupRename, SpiffsMqttTransaction::oldImage()}, + // Old primary is .bak and verified new temp wins the recovery race. + {SpiffsMqttTransaction::Boundary::AfterBackupRename, SpiffsMqttTransaction::newImage()}, + {SpiffsMqttTransaction::Boundary::AfterPrimaryRename, SpiffsMqttTransaction::newImage()}, + {SpiffsMqttTransaction::Boundary::AfterBackupCleanup, SpiffsMqttTransaction::newImage()}, + }; + + for (const auto& test_case : cases) { + SpiffsMqttTransaction store; + store.cutAt(test_case.boundary); + store.recover(); + ASSERT_TRUE(store.has("/mqtt_prefs")); + EXPECT_EQ(test_case.expected_after_reboot, store.primary()); + EXPECT_FALSE(store.has("/mqtt_prefs.tmp")); + EXPECT_FALSE(store.has("/mqtt_prefs.bak")); + } +} + +TEST(MQTTPrefsAtomicStore, PowerCutDuringTempWriteKeepsPrimaryAndAllowsNextSave) { + SpiffsMqttTransaction store; + store.cutDuringTempWrite(); + + // The partial temp is opaque to the codec, but the existing primary is the + // only committed image. Recovery discards the incomplete transaction rather + // than blocking every later config save behind /mqtt_prefs.tmp. + store.recover(Recovery::FileState::Usable, Recovery::FileState::Preserve); + EXPECT_EQ(SpiffsMqttTransaction::oldImage(), store.primary()); + EXPECT_FALSE(store.has("/mqtt_prefs.tmp")); + EXPECT_TRUE(store.canStartSave()); +} + +TEST(MQTTPrefsAtomicStore, RecoveredUsablePrimaryClearsOpaqueTransactionArtifacts) { + { + SpiffsMqttTransaction store; + store.cutAt(SpiffsMqttTransaction::Boundary::AfterBackupRename); + // A current-format temp wins; the old backup need not be decodable to be + // stale once that usable temp owns the primary name. + store.recover(Recovery::FileState::Usable, Recovery::FileState::Usable, + Recovery::FileState::Preserve); + EXPECT_EQ(SpiffsMqttTransaction::newImage(), store.primary()); + EXPECT_TRUE(store.canStartSave()); + } + { + SpiffsMqttTransaction store; + store.cutAt(SpiffsMqttTransaction::Boundary::AfterBackupRename); + // Conversely, when the usable backup becomes primary, an opaque temp was + // never published and must not leave saves permanently blocked. + store.recover(Recovery::FileState::Usable, Recovery::FileState::Preserve, + Recovery::FileState::Usable); + EXPECT_EQ(SpiffsMqttTransaction::oldImage(), store.primary()); + EXPECT_TRUE(store.canStartSave()); + } +} + +TEST(MQTTPrefsAtomicStore, SpiffsRenameAndCleanupFailuresRemainRecoverable) { + { + SpiffsMqttTransaction store; + EXPECT_FALSE(store.publish(true, false, false)); + store.recover(); + EXPECT_EQ(SpiffsMqttTransaction::oldImage(), store.primary()); + EXPECT_FALSE(store.has("/mqtt_prefs.tmp")); + EXPECT_FALSE(store.has("/mqtt_prefs.bak")); + } + { + SpiffsMqttTransaction store; + EXPECT_FALSE(store.publish(false, true, false)); + EXPECT_FALSE(store.has("/mqtt_prefs")); + EXPECT_TRUE(store.has("/mqtt_prefs.tmp")); + EXPECT_TRUE(store.has("/mqtt_prefs.bak")); + store.recover(); + EXPECT_EQ(SpiffsMqttTransaction::newImage(), store.primary()); + EXPECT_FALSE(store.has("/mqtt_prefs.tmp")); + EXPECT_FALSE(store.has("/mqtt_prefs.bak")); + } + { + SpiffsMqttTransaction store; + EXPECT_TRUE(store.publish(false, false, true)); + EXPECT_EQ(SpiffsMqttTransaction::newImage(), store.primary()); + EXPECT_TRUE(store.has("/mqtt_prefs.bak")); + store.recover(); + EXPECT_FALSE(store.has("/mqtt_prefs.bak")); + } +} + +TEST(MQTTPrefsAtomicStore, RecoveryNeverOverwritesOpaqueNewerLayout) { + // An unreadable primary owns its name, even if an older usable backup and a + // verified temp exist. This is the downgrade-preservation invariant. + EXPECT_EQ(Recovery::Action::KeepPrimary, + Recovery::select(Recovery::FileState::Preserve, Recovery::FileState::Usable, + Recovery::FileState::Usable)); + // If there is no primary, a usable backup wins over an opaque temp. Once + // promoted, production treats the backup as authoritative and clears temp. + EXPECT_EQ(Recovery::Action::PromoteBackup, + Recovery::select(Recovery::FileState::Missing, Recovery::FileState::Preserve, + Recovery::FileState::Usable)); + // With no other image, an opaque backup is renamed into the empty primary + // name so CommonCLI will hold it rather than silently replace it with defaults. + EXPECT_EQ(Recovery::Action::PromoteBackup, + Recovery::select(Recovery::FileState::Missing, Recovery::FileState::Missing, + Recovery::FileState::Preserve)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_prefs_codec/FIXTURES.md b/test/test_mqtt_prefs_codec/FIXTURES.md new file mode 100644 index 000000000..0068e1a58 --- /dev/null +++ b/test/test_mqtt_prefs_codec/FIXTURES.md @@ -0,0 +1,24 @@ +# MQTT preferences fixture provenance + +The codec tests construct synthetic, non-secret byte vectors at hard-coded +offsets. They intentionally do not serialize the production structs: a layout +change must disagree with the frozen fixture bytes and fail the field checks. + +| Bytes | Historical layout | Source history | +|---:|---|---| +| 472 | Pre-slot, before and after `wifi_power_save` | Initial `/mqtt_prefs` layout; `34c8bea7` inserted WiFi power without changing the padded total size | +| 1032 | Initial three-slot layout | `b43e9618` | +| 1464 | Three slots with token/topic tails | `95874f0c` | +| 2452 | Six slots with token/topic tails | `1b5884bd` | +| 2836 | Six slots with audience tail | `1263e71d` | +| 2840 | Six slots with RX flag | `47b632aa` | +| 2904 | Six slots with NTP server | `7416d632` | +| 2736 / 2860 | Version-1 payload before observer tail / complete payload | `58b9cb66` introduced the eight-byte versioned header; the shorter form exercises its append-compatible prefix contract | + +The 3024-byte raw observer-tail form is not accepted as deployed fleet data: +repository history indicates it existed briefly before versioning but was not +shipped. Tests require it to be preserved rather than guessed and rewritten. + +Headerless formats have no checksum. Plausibility checks reject obvious random +or malformed content, but cannot authenticate a byte sequence that happens to +look like a valid historical struct. diff --git a/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp b/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp new file mode 100644 index 000000000..7bf49746d --- /dev/null +++ b/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp @@ -0,0 +1,585 @@ +#include + +#include +#include + +#define WITH_MQTT_BRIDGE 1 +#include "helpers/MQTTPrefsCodec.h" +#include "helpers/MQTTPacketFilter.h" + +namespace Codec = MQTTPrefsCodec; + +namespace { + +MQTTPrefs defaults() { + MQTTPrefs prefs = {}; + prefs.mqtt_status_enabled = 1; + prefs.mqtt_packets_enabled = 1; + prefs.mqtt_tx_enabled = 2; + prefs.mqtt_rx_enabled = 1; + prefs.mqtt_status_interval = 300000; + prefs.wifi_power_save = 1; + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { + strncpy(prefs.mqtt_slot_preset[i], "none", sizeof(prefs.mqtt_slot_preset[i]) - 1); + prefs.mqtt_slot_packet_filter[i] = MQTTPacketFilter::kAllPacketTypes; + } + strncpy(prefs.snmp_community, "public", sizeof(prefs.snmp_community) - 1); + prefs.radio_watchdog_minutes = 5; + prefs.alert_wifi_minutes = 30; + prefs.alert_mqtt_minutes = 240; + prefs.alert_min_interval_min = 60; + return prefs; +} + +void writeText(std::vector* bytes, size_t offset, const char* value) { + const size_t length = strlen(value); + ASSERT_LE(offset + length, bytes->size()); + memcpy(bytes->data() + offset, value, length); +} + +void writeLe16(std::vector* bytes, size_t offset, uint16_t value) { + ASSERT_LE(offset + 2, bytes->size()); + (*bytes)[offset] = static_cast(value & 0xff); + (*bytes)[offset + 1] = static_cast(value >> 8); +} + +void writeHeader(std::vector* bytes, uint16_t version, uint16_t payload_len) { + ASSERT_GE(bytes->size(), sizeof(MQTTPrefsHeader)); + (*bytes)[0] = MQTT_PREFS_MAGIC[0]; + (*bytes)[1] = MQTT_PREFS_MAGIC[1]; + (*bytes)[2] = MQTT_PREFS_MAGIC[2]; + (*bytes)[3] = MQTT_PREFS_MAGIC[3]; + writeLe16(bytes, 4, version); + writeLe16(bytes, 6, payload_len); +} + +Codec::DecodePlan classify(const std::vector& bytes) { + const size_t prefix_size = bytes.size() < sizeof(MQTTPrefsHeader) + ? bytes.size() : sizeof(MQTTPrefsHeader); + return Codec::classify(bytes.data(), prefix_size, bytes.size()); +} + +void fillHighEntropy(std::vector* bytes) { + uint32_t state = 0x89abcdef; + for (size_t i = 0; i < bytes->size(); ++i) { + state = state * 1664525u + 1013904223u; + // Keep every byte non-NUL so this is a useful corruption fixture rather + // than a sparse/default-like legacy file. + (*bytes)[i] = static_cast((state >> 24) | 0x80); + } +} + +} // namespace + +TEST(MQTTPrefsCodec, MigratesPostWifiPowerPreSlotFixture) { + // Frozen post-WiFi-power pre-slot offsets: status=44, ssid=48, power=144, + // timezone=145, server=178, port=242. Do not derive this fixture from structs. + std::vector bytes(472, 0); + writeText(&bytes, 0, "legacy-node"); + writeText(&bytes, 32, "YYZ"); + bytes[40] = 1; + bytes[41] = 1; + bytes[43] = 2; + bytes[144] = 2; + writeText(&bytes, 145, "UTC"); + writeText(&bytes, 178, "broker.example"); + writeLe16(&bytes, 242, 1883); + writeText(&bytes, 244, "alice"); + writeText(&bytes, 276, "secret"); + bytes[340] = 1; + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::LegacyPreSlot, plan.source); + ASSERT_TRUE(plan.rewrite_legacy); + ASSERT_FALSE(Codec::looksLikePreWifiPower(bytes.data(), bytes.size())); + ASSERT_TRUE(Codec::isPlausibleLegacy(plan.source, bytes.data(), bytes.size())); + + OldMQTTPrefs old_prefs = {}; + memcpy(&old_prefs, bytes.data(), sizeof(old_prefs)); + MQTTPrefs prefs = defaults(); + Codec::migratePreSlot(old_prefs, &prefs); + + EXPECT_STREQ("legacy-node", prefs.mqtt_origin); + EXPECT_STREQ("analyzer-us", prefs.mqtt_slot_preset[0]); + EXPECT_STREQ("custom", prefs.mqtt_slot_preset[2]); + EXPECT_STREQ("broker.example", prefs.mqtt_slot_host[2]); + EXPECT_EQ(1883, prefs.mqtt_slot_port[2]); + EXPECT_STREQ("secret", prefs.mqtt_slot_password[2]); +} + +TEST(MQTTPrefsCodec, MigratesPreWifiPowerFixtureWithConservativeHeuristic) { + // In the pre-WiFi-power variant timezone starts at 144 and server at 177; + // port still aligns at 242. A non-empty timezone makes the layout unambiguous. + std::vector bytes(472, 0); + writeText(&bytes, 0, "pre-power"); + writeText(&bytes, 144, "PST8PDT"); + writeText(&bytes, 177, "old-broker"); + writeLe16(&bytes, 242, 8883); + + ASSERT_TRUE(Codec::looksLikePreWifiPower(bytes.data(), bytes.size())); + ASSERT_TRUE(Codec::isPlausibleLegacy(Codec::Source::LegacyPreSlot, + bytes.data(), bytes.size())); + PreWifiPowerOldMQTTPrefs old_prefs = {}; + memcpy(&old_prefs, bytes.data(), sizeof(old_prefs)); + MQTTPrefs prefs = defaults(); + Codec::migratePreWifiPower(old_prefs, &prefs); + + EXPECT_STREQ("PST8PDT", prefs.timezone_string); + EXPECT_STREQ("old-broker", prefs.mqtt_slot_host[2]); + EXPECT_EQ(8883, prefs.mqtt_slot_port[2]); + EXPECT_EQ(1, prefs.wifi_power_save); // retained current default; old layout had none +} + +TEST(MQTTPrefsCodec, DetectsPreWifiPowerFixtureWithOnlyUtcOffsetConfigured) { + std::vector bytes(472, 0); + bytes[176] = static_cast(-8); + + EXPECT_TRUE(Codec::looksLikePreWifiPower(bytes.data(), bytes.size())); +} + +TEST(MQTTPrefsCodec, MigratesThreeSlotBaseAndExtendedFixtures) { + // Frozen 3-slot offsets: presets=178, hosts=250, ports=442, users=448, + // passwords=544, owner=736, tokens=1030, topics=1174. The base file has + // two tail-padding bytes, so its frozen size is 1032. + std::vector base(1032, 0); + writeText(&base, 0, "three-base"); + writeText(&base, 178, "meshmapper"); + writeText(&base, 250, "three.example"); + writeLe16(&base, 442, 1884); + const Codec::DecodePlan base_plan = classify(base); + ASSERT_EQ(Codec::Source::LegacyThreeSlotBase, base_plan.source); + ASSERT_TRUE(Codec::isPlausibleLegacy(base_plan.source, base.data(), base.size())); + ThreeSlotBaseMQTTPrefs old_base = {}; + memcpy(&old_base, base.data(), sizeof(old_base)); + MQTTPrefs base_prefs = defaults(); + Codec::migrateThreeSlot(old_base, &base_prefs); + EXPECT_STREQ("three-base", base_prefs.mqtt_origin); + EXPECT_STREQ("meshmapper", base_prefs.mqtt_slot_preset[0]); + EXPECT_EQ(1884, base_prefs.mqtt_slot_port[0]); + EXPECT_STREQ("none", base_prefs.mqtt_slot_preset[3]); + + std::vector extended(1464, 0); + writeText(&extended, 0, "three-extended"); + writeText(&extended, 178 + 24, "custom"); + writeText(&extended, 250 + 64, "slot-two.example"); + writeText(&extended, 1030 + 48, "token-two"); + writeText(&extended, 1174 + 96, "custom/{type}"); + const Codec::DecodePlan extended_plan = classify(extended); + ASSERT_EQ(Codec::Source::LegacyThreeSlot, extended_plan.source); + ASSERT_TRUE(Codec::isPlausibleLegacy(extended_plan.source, extended.data(), extended.size())); + ThreeSlotMQTTPrefs old_extended = {}; + memcpy(&old_extended, extended.data(), sizeof(old_extended)); + MQTTPrefs extended_prefs = defaults(); + Codec::migrateThreeSlot(old_extended, &extended_prefs); + EXPECT_STREQ("custom", extended_prefs.mqtt_slot_preset[1]); + EXPECT_STREQ("token-two", extended_prefs.mqtt_slot_token[1]); + EXPECT_STREQ("custom/{type}", extended_prefs.mqtt_slot_topic[1]); +} + +TEST(MQTTPrefsCodec, MigratesAllLegacySixSlotPrefixesWithoutClobberingDefaults) { + // Frozen 6-slot offsets: presets=178, hosts=322, ports=706, tokens=1588, + // topics=1876, audience=2452, rx=2836, ntp=2837. + for (const size_t size : {size_t(2452), size_t(2836), size_t(2840), size_t(2904)}) { + std::vector bytes(size, 0); + writeText(&bytes, 0, "six-slot"); + writeText(&bytes, 178 + 5 * 24, "custom"); + writeText(&bytes, 322 + 5 * 64, "six.example"); + writeText(&bytes, 1588 + 5 * 48, "token-six"); + writeText(&bytes, 1876 + 5 * 96, "six/{type}"); + if (size >= 2836) writeText(&bytes, 2452 + 5 * 64, "audience-six"); + if (size >= 2840) bytes[2836] = 0; + if (size >= 2904) writeText(&bytes, 2837, "time.example"); + + const Codec::DecodePlan plan = classify(bytes); + const Codec::Source expected_source = size == 2452 ? Codec::Source::LegacySixSlotBase + : size == 2836 ? Codec::Source::LegacySixSlotAudience + : size == 2840 ? Codec::Source::LegacySixSlotAudienceRx + : Codec::Source::LegacySixSlot; + EXPECT_EQ(expected_source, plan.source) << size; + ASSERT_TRUE(plan.rewrite_legacy); + ASSERT_TRUE(Codec::isPlausibleLegacy(plan.source, bytes.data(), bytes.size())) << size; + Legacy6SlotMQTTPrefs old_prefs = {}; + memcpy(&old_prefs, bytes.data(), bytes.size()); + MQTTPrefs prefs = defaults(); + Codec::migrateLegacySixSlot(old_prefs, plan.source, &prefs); + + EXPECT_STREQ("custom", prefs.mqtt_slot_preset[5]); + EXPECT_STREQ("token-six", prefs.mqtt_slot_token[5]); + if (size < 2836) { + EXPECT_EQ('\0', prefs.mqtt_slot_audience[5][0]); + } else { + EXPECT_STREQ("audience-six", prefs.mqtt_slot_audience[5]); + } + if (size < 2840) { + EXPECT_EQ(1, prefs.mqtt_rx_enabled) << size; + } else { + EXPECT_EQ(0, prefs.mqtt_rx_enabled); + } + if (size < 2904) { + EXPECT_EQ('\0', prefs.mqtt_ntp_server[0]); + } else { + EXPECT_STREQ("time.example", prefs.mqtt_ntp_server); + } + for (int slot = 0; slot < MQTT_PREFS_SLOT_COUNT; ++slot) { + EXPECT_EQ(MQTTPacketFilter::kAllPacketTypes, + prefs.mqtt_slot_packet_filter[slot]) << size << ":" << slot; + } + } +} + +TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) { + MQTTPrefs source = defaults(); + strncpy(source.mqtt_origin, "current-node", sizeof(source.mqtt_origin) - 1); + strncpy(source.mqtt_slot_password[2], "preserve-me", sizeof(source.mqtt_slot_password[2]) - 1); + strncpy(source.alert_region, "PNW", sizeof(source.alert_region) - 1); + source.mqtt_neighbors_enabled = 1; + source.mqtt_neighbors_interval = MQTT_NEIGHBORS_MAX_INTERVAL_MS; + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { + source.mqtt_slot_packet_filter[i] = static_cast(1u << i); + } + std::vector bytes(Codec::kEncodedSize); + ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size())); + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + ASSERT_FALSE(plan.preserve_file); + ASSERT_TRUE(plan.observer_fields_present); + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source))); +} + +// /mqtt_prefs also carries the WiFi credentials, so a payload older firmware +// rejects strands a downgraded node with no network and no way to save. The +// filter tail is only written once it actually holds something. +TEST(MQTTPrefsCodec, DefaultFiltersKeepTheDowngradeReadablePayloadLength) { + MQTTPrefs source = defaults(); + strncpy(source.wifi_ssid, "home-net", sizeof(source.wifi_ssid) - 1); + strncpy(source.mqtt_slot_preset[0], "analyzer-us", sizeof(source.mqtt_slot_preset[0]) - 1); + + ASSERT_EQ(Codec::kV1PreFilterPayloadSize, Codec::payloadLenFor(source)); + std::vector bytes(Codec::kEncodedSize, 0xEE); + const size_t written = Codec::encode(source, bytes.data(), bytes.size()); + ASSERT_EQ(sizeof(MQTTPrefsHeader) + Codec::kV1PreFilterPayloadSize, written); + EXPECT_LT(written, Codec::kEncodedSize); + + MQTTPrefsHeader header; + memcpy(&header, bytes.data(), sizeof(header)); + EXPECT_EQ(Codec::kV1PreFilterPayloadSize, header.payload_len); + // Nothing past the boundary was touched, so the file really is the short one. + for (size_t i = written; i < bytes.size(); ++i) { + EXPECT_EQ(0xEE, bytes[i]) << i; + } + + bytes.resize(written); + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + ASSERT_EQ(Codec::kV1PreFilterPayloadSize, plan.payload_len); + ASSERT_FALSE(plan.preserve_file); + + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source))); +} + +TEST(MQTTPrefsCodec, AnyNonDefaultFilterOptsIntoTheLongerPayload) { + MQTTPrefs source = defaults(); + strncpy(source.wifi_ssid, "home-net", sizeof(source.wifi_ssid) - 1); + + // "none" is as much a real setting as a subset, and must survive a reload. + for (int slot = 0; slot < MQTT_PREFS_SLOT_COUNT; ++slot) { + for (uint16_t mask : {static_cast(0), + static_cast(1u << 4), + static_cast(MQTTPacketFilter::kAllPacketTypes & ~1u)}) { + source.mqtt_slot_packet_filter[slot] = mask; + ASSERT_EQ(Codec::kV1BaselinePayloadSize, Codec::payloadLenFor(source)) << slot; + + std::vector bytes(Codec::kEncodedSize); + ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size())); + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len); + + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + EXPECT_EQ(mask, loaded.mqtt_slot_packet_filter[slot]) << slot; + EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source))); + } + source.mqtt_slot_packet_filter[slot] = MQTTPacketFilter::kAllPacketTypes; + } + // Clearing the last non-default filter returns the node to the short payload. + EXPECT_EQ(Codec::kV1PreFilterPayloadSize, Codec::payloadLenFor(source)); +} + +TEST(MQTTPrefsCodec, EncodeRefusesAnOutputBufferShorterThanItsChosenPayload) { + MQTTPrefs shortest = defaults(); + const size_t short_size = sizeof(MQTTPrefsHeader) + Codec::kV1PreFilterPayloadSize; + std::vector bytes(Codec::kEncodedSize); + + EXPECT_EQ(0u, Codec::encode(shortest, bytes.data(), short_size - 1)); + EXPECT_EQ(short_size, Codec::encode(shortest, bytes.data(), short_size)); + EXPECT_EQ(0u, Codec::encode(shortest, nullptr, bytes.size())); + + // A buffer that fits the short payload is not enough for the long one. + MQTTPrefs longest = defaults(); + longest.mqtt_slot_packet_filter[0] = 0; + EXPECT_EQ(0u, Codec::encode(longest, bytes.data(), short_size)); + EXPECT_EQ(Codec::kEncodedSize, Codec::encode(longest, bytes.data(), bytes.size())); +} + +TEST(MQTTPrefsCodec, PreFilterV1PayloadDefaultsEverySlotToAllTypes) { + MQTTPrefs source = defaults(); + strncpy(source.mqtt_origin, "pre-filter-node", sizeof(source.mqtt_origin) - 1); + source.mqtt_neighbors_enabled = 1; + source.mqtt_neighbors_interval = MQTT_NEIGHBORS_MAX_INTERVAL_MS; + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { + source.mqtt_slot_packet_filter[i] = 0; + } + + std::vector bytes(sizeof(MQTTPrefsHeader) + Codec::kV1PreFilterPayloadSize, 0); + writeHeader(&bytes, MQTT_PREFS_VERSION, + static_cast(Codec::kV1PreFilterPayloadSize)); + memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, Codec::kV1PreFilterPayloadSize); + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + ASSERT_EQ(Codec::kV1PreFilterPayloadSize, plan.payload_len); + ASSERT_TRUE(plan.observer_fields_present); + ASSERT_FALSE(plan.preserve_file); + + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + EXPECT_STREQ("pre-filter-node", loaded.mqtt_origin); + EXPECT_EQ(1u, loaded.mqtt_neighbors_enabled); + EXPECT_EQ(MQTT_NEIGHBORS_MAX_INTERVAL_MS, loaded.mqtt_neighbors_interval); + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { + EXPECT_EQ(MQTTPacketFilter::kAllPacketTypes, + loaded.mqtt_slot_packet_filter[i]) << i; + } +} + +TEST(MQTTPrefsCodec, CompatibleShortV1PayloadPreservesDefaultsBeyondObserverBoundary) { + MQTTPrefs source = defaults(); + strncpy(source.mqtt_origin, "short-v1-node", sizeof(source.mqtt_origin) - 1); + strncpy(source.mqtt_ntp_server, "ntp.short.example", sizeof(source.mqtt_ntp_server) - 1); + source.snmp_enabled = 1; + strncpy(source.snmp_community, "do-not-copy", sizeof(source.snmp_community) - 1); + source.radio_watchdog_minutes = 99; + + std::vector bytes(sizeof(MQTTPrefsHeader) + Codec::kV1PreObserverPayloadSize, 0); + writeHeader(&bytes, MQTT_PREFS_VERSION, + static_cast(Codec::kV1PreObserverPayloadSize)); + memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, Codec::kV1PreObserverPayloadSize); + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + ASSERT_EQ(Codec::kV1PreObserverPayloadSize, plan.payload_len); + ASSERT_FALSE(plan.preserve_file); + ASSERT_FALSE(plan.observer_fields_present); + + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + EXPECT_STREQ("short-v1-node", loaded.mqtt_origin); + EXPECT_STREQ("ntp.short.example", loaded.mqtt_ntp_server); + EXPECT_EQ(0, loaded.snmp_enabled); + EXPECT_STREQ("public", loaded.snmp_community); + EXPECT_EQ(5, loaded.radio_watchdog_minutes); +} + +TEST(MQTTPrefsCodec, PreNeighborsV1PayloadLoadsObserverFieldsAndDefaultsNeighborsTail) { + // A /mqtt_prefs written by observer/webconfig firmware before the neighbors + // tail existed: full observer fields, 2860-byte v1 payload. It must still load + // as Current (observer fields present) with the neighbors tail defaulted. + MQTTPrefs source = defaults(); + strncpy(source.mqtt_origin, "pre-neighbors-node", sizeof(source.mqtt_origin) - 1); + strncpy(source.alert_region, "PNW", sizeof(source.alert_region) - 1); + source.snmp_enabled = 1; + source.alert_enabled = 1; + source.mqtt_neighbors_enabled = 0; // old struct's byte 2857 was zero padding + source.mqtt_neighbors_interval = 0x11223344; // must NOT survive a 2860-byte read + + std::vector bytes(sizeof(MQTTPrefsHeader) + Codec::kV1PreNeighborsPayloadSize, 0); + writeHeader(&bytes, MQTT_PREFS_VERSION, + static_cast(Codec::kV1PreNeighborsPayloadSize)); + memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, Codec::kV1PreNeighborsPayloadSize); + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + ASSERT_EQ(Codec::kV1PreNeighborsPayloadSize, plan.payload_len); + ASSERT_FALSE(plan.preserve_file); + ASSERT_TRUE(plan.observer_fields_present); + + MQTTPrefs loaded = defaults(); + loaded.mqtt_neighbors_enabled = 1; // pretend stale + loaded.mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; // caller's defaulted tail + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + + EXPECT_STREQ("pre-neighbors-node", loaded.mqtt_origin); + EXPECT_STREQ("PNW", loaded.alert_region); + EXPECT_EQ(1, loaded.snmp_enabled); + EXPECT_EQ(1, loaded.alert_enabled); + // Enable flag sits at offset 2857 (inside the 2860 read) -> takes the file's 0. + // Interval begins at 2860 (beyond the read) -> keeps the caller's default. + EXPECT_EQ(0u, loaded.mqtt_neighbors_enabled); + EXPECT_EQ(MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS, loaded.mqtt_neighbors_interval); + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { + EXPECT_EQ(MQTTPacketFilter::kAllPacketTypes, + loaded.mqtt_slot_packet_filter[i]) << i; + } +} + +TEST(MQTTPrefsCodec, CorruptOrShortVersionedInputsArePreserved) { + Codec::DecodePlan plan = Codec::classify(nullptr, 0, 0); + EXPECT_EQ(Codec::Source::Corrupt, plan.source); + EXPECT_TRUE(plan.preserve_file); + + std::vector partial_magic = {MQTT_PREFS_MAGIC[0], MQTT_PREFS_MAGIC[1], MQTT_PREFS_MAGIC[2]}; + plan = classify(partial_magic); + EXPECT_EQ(Codec::Source::Corrupt, plan.source); + EXPECT_TRUE(plan.preserve_file); + + std::vector short_payload(sizeof(MQTTPrefsHeader) + 4, 0); + writeHeader(&short_payload, MQTT_PREFS_VERSION, + static_cast(Codec::kV1BaselinePayloadSize - 1)); + plan = classify(short_payload); + EXPECT_EQ(Codec::Source::Corrupt, plan.source); + EXPECT_TRUE(plan.preserve_file); + + std::vector declared_short_with_full_body( + sizeof(MQTTPrefsHeader) + Codec::kV1BaselinePayloadSize, 0); + writeHeader(&declared_short_with_full_body, MQTT_PREFS_VERSION, + static_cast(Codec::kV1PreObserverPayloadSize)); + plan = classify(declared_short_with_full_body); + EXPECT_EQ(Codec::Source::Corrupt, plan.source); + EXPECT_TRUE(plan.preserve_file); + + std::vector declared_full_with_short_body( + sizeof(MQTTPrefsHeader) + Codec::kV1PreObserverPayloadSize, 0); + writeHeader(&declared_full_with_short_body, MQTT_PREFS_VERSION, + static_cast(Codec::kV1BaselinePayloadSize)); + plan = classify(declared_full_with_short_body); + EXPECT_EQ(Codec::Source::Corrupt, plan.source); + EXPECT_TRUE(plan.preserve_file); + + std::vector truncated(sizeof(MQTTPrefsHeader) + Codec::kV1BaselinePayloadSize - 1, 0); + writeHeader(&truncated, MQTT_PREFS_VERSION, + static_cast(Codec::kV1BaselinePayloadSize)); + plan = classify(truncated); + EXPECT_EQ(Codec::Source::Corrupt, plan.source); + EXPECT_TRUE(plan.preserve_file); + + std::vector trailing(sizeof(MQTTPrefsHeader) + Codec::kV1BaselinePayloadSize + 1, 0); + writeHeader(&trailing, MQTT_PREFS_VERSION, + static_cast(Codec::kV1BaselinePayloadSize)); + plan = classify(trailing); + EXPECT_EQ(Codec::Source::Corrupt, plan.source); + EXPECT_TRUE(plan.preserve_file); +} + +TEST(MQTTPrefsCodec, LegacyPlausibilityRejectsHighEntropyBytesAtEveryWhitelistedSize) { + // A headerless raw struct has no checksum, so this only reduces false + // migrations; it cannot prove that a plausible-looking file is authentic. + for (const size_t size : {size_t(472), size_t(1032), size_t(1464), size_t(2452), + size_t(2836), size_t(2840), size_t(2904)}) { + std::vector bytes(size, 0); + fillHighEntropy(&bytes); + const Codec::DecodePlan plan = classify(bytes); + ASSERT_TRUE(plan.rewrite_legacy) << size; + EXPECT_FALSE(Codec::isPlausibleLegacy(plan.source, bytes.data(), bytes.size())) << size; + } +} + +TEST(MQTTPrefsCodec, UnsupportedHeaderlessSizesArePreserved) { + // 3024 was produced briefly before versioning, but repository history says + // that raw observer-tail form was not shipped. Preserve it rather than guess. + for (const size_t size : {size_t(471), size_t(473), size_t(1465), size_t(2905), size_t(3024)}) { + std::vector bytes(size, 0); + const Codec::DecodePlan plan = classify(bytes); + EXPECT_EQ(Codec::Source::Corrupt, plan.source) << size; + EXPECT_TRUE(plan.preserve_file) << size; + } +} + +// A different version tag means the layout may have changed shape, so it is +// still refused outright. That refusal is what makes reading longer same-version +// payloads safe, so the two belong in one test. +TEST(MQTTPrefsCodec, ADifferentVersionTagIsStillRefusedAndHeld) { + std::vector newer(sizeof(MQTTPrefsHeader), 0); + writeHeader(&newer, MQTT_PREFS_VERSION + 1, 0); + const Codec::DecodePlan plan = classify(newer); + EXPECT_EQ(Codec::Source::UnsupportedVersion, plan.source); + EXPECT_TRUE(plan.preserve_file); +} + +// The downgrade contract: a v1 payload longer than this build's baseline was +// appended to by a later build, so the baseline prefix is present verbatim. +// Read it and drop the tail — never refuse the file, which would cost the +// operator WiFi and every broker slot to save settings they don't understand. +TEST(MQTTPrefsCodec, LongerSameVersionPayloadLoadsTheBaselineAndIgnoresTheTail) { + MQTTPrefs source = defaults(); + strncpy(source.mqtt_origin, "future-node", sizeof(source.mqtt_origin) - 1); + strncpy(source.wifi_ssid, "field-ssid", sizeof(source.wifi_ssid) - 1); + strncpy(source.wifi_password, "field-secret", sizeof(source.wifi_password) - 1); + strncpy(source.mqtt_iata, "SEA", sizeof(source.mqtt_iata) - 1); + strncpy(source.mqtt_slot_preset[0], "meshrank", sizeof(source.mqtt_slot_preset[0]) - 1); + source.mqtt_neighbors_enabled = 1; + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { + source.mqtt_slot_packet_filter[i] = static_cast(1u << i); + } + + // Baseline image plus a 40-byte tail of fields this build has never heard of. + const size_t kTail = 40; + const size_t payload_len = Codec::kV1BaselinePayloadSize + kTail; + std::vector bytes(sizeof(MQTTPrefsHeader) + payload_len, 0xA5); + writeHeader(&bytes, MQTT_PREFS_VERSION, static_cast(payload_len)); + memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, sizeof(source)); + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + EXPECT_FALSE(plan.preserve_file) << "refusing the file would strand the node"; + EXPECT_TRUE(plan.observer_fields_present); + EXPECT_FALSE(plan.rewrite_legacy); + ASSERT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len); + + // Reading plan.payload_len bytes recovers this build's whole struct exactly, + // and cannot run past it into the unknown tail. + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source))); + EXPECT_STREQ("future-node", loaded.mqtt_origin); + EXPECT_STREQ("field-ssid", loaded.wifi_ssid); + EXPECT_STREQ("field-secret", loaded.wifi_password); + EXPECT_STREQ("meshrank", loaded.mqtt_slot_preset[0]); + EXPECT_EQ(1u, loaded.mqtt_neighbors_enabled); + EXPECT_EQ(1u, loaded.mqtt_slot_packet_filter[0]); +} + +// The rule has to hold for any appended size, including a single byte and a +// tail far larger than the baseline. +TEST(MQTTPrefsCodec, EveryLongerSameVersionLengthReadsTheBaseline) { + for (const size_t tail : {size_t(1), size_t(2), size_t(12), size_t(64), + size_t(512), size_t(4096)}) { + const size_t payload_len = Codec::kV1BaselinePayloadSize + tail; + std::vector bytes(sizeof(MQTTPrefsHeader) + payload_len, 0); + writeHeader(&bytes, MQTT_PREFS_VERSION, static_cast(payload_len)); + const Codec::DecodePlan plan = classify(bytes); + EXPECT_EQ(Codec::Source::Current, plan.source) << tail; + EXPECT_FALSE(plan.preserve_file) << tail; + EXPECT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len) << tail; + } +} + +// A length that merely *declares* a longer payload without the bytes to back it +// is still corrupt — the header/file-size agreement check must run first. +TEST(MQTTPrefsCodec, LongerDeclaredLengthWithoutTheBytesIsStillCorrupt) { + std::vector bytes(sizeof(MQTTPrefsHeader) + Codec::kV1BaselinePayloadSize, 0); + writeHeader(&bytes, MQTT_PREFS_VERSION, + static_cast(Codec::kV1BaselinePayloadSize + 16)); + const Codec::DecodePlan plan = classify(bytes); + EXPECT_EQ(Codec::Source::Corrupt, plan.source); + EXPECT_TRUE(plan.preserve_file); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_presets/test_mqtt_presets.cpp b/test/test_mqtt_presets/test_mqtt_presets.cpp new file mode 100644 index 000000000..64eb79d5c --- /dev/null +++ b/test/test_mqtt_presets/test_mqtt_presets.cpp @@ -0,0 +1,169 @@ +// Host tests for the MQTT observer preset table and lookup helpers +// (src/helpers/MQTTPresets.h). Pure logic — no ESP/radio dependencies. +// +// The preset table and lookup functions are compiled only for observer builds, +// so opt into that feature flag before including the header (the definitions are +// pure C++/data with no ESP dependencies). +#define WITH_MQTT_BRIDGE 1 +#define PROGMEM // host build: the CA-cert strings in MQTTPresets.h are PROGMEM-qualified +#include +#include +#include +#include +#include "helpers/MQTTPresets.h" + +// ---- findMQTTPreset ------------------------------------------------------- + +TEST(MQTTPresets, FindKnownPreset) { + const MQTTPresetDef* p = findMQTTPreset("analyzer-us"); + ASSERT_NE(nullptr, p); + EXPECT_STREQ("analyzer-us", p->name); + EXPECT_EQ(MQTT_AUTH_JWT, p->auth_type); + EXPECT_EQ(MQTT_TOPIC_MESHCORE, p->topic_style); +} + +TEST(MQTTPresets, FindReturnsTablePointer) { + // The returned pointer must be into the table, not a copy. + const MQTTPresetDef* p = findMQTTPreset("meshrank"); + ASSERT_NE(nullptr, p); + bool in_table = false; + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + if (p == &MQTT_PRESETS[i]) { in_table = true; break; } + } + EXPECT_TRUE(in_table); +} + +TEST(MQTTPresets, UnknownAndEmptyReturnNull) { + EXPECT_EQ(nullptr, findMQTTPreset("does-not-exist")); + EXPECT_EQ(nullptr, findMQTTPreset("")); + EXPECT_EQ(nullptr, findMQTTPreset(nullptr)); +} + +TEST(MQTTPresets, NoneAndCustomAreNotTablePresets) { + // "none"/"custom" are virtual presets handled by the CLI, not table entries. + EXPECT_EQ(nullptr, findMQTTPreset(MQTT_PRESET_NONE)); + EXPECT_EQ(nullptr, findMQTTPreset(MQTT_PRESET_CUSTOM)); + EXPECT_STREQ("none", MQTT_PRESET_NONE); + EXPECT_STREQ("custom", MQTT_PRESET_CUSTOM); +} + +TEST(MQTTPresets, LookupIsCaseSensitive) { + EXPECT_EQ(nullptr, findMQTTPreset("Analyzer-US")); +} + +// ---- table integrity ------------------------------------------------------ + +TEST(MQTTPresets, EveryNameIsUniqueAndNonEmpty) { + std::set names; + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + ASSERT_NE(nullptr, MQTT_PRESETS[i].name) << "preset " << i << " has null name"; + EXPECT_NE('\0', MQTT_PRESETS[i].name[0]) << "preset " << i << " has empty name"; + auto res = names.insert(MQTT_PRESETS[i].name); + EXPECT_TRUE(res.second) << "duplicate preset name: " << MQTT_PRESETS[i].name; + } + EXPECT_EQ((size_t)MQTT_PRESET_COUNT, names.size()); +} + +TEST(MQTTPresets, EveryPresetHasAServerUrl) { + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + ASSERT_NE(nullptr, MQTT_PRESETS[i].server_url) << MQTT_PRESETS[i].name; + EXPECT_NE('\0', MQTT_PRESETS[i].server_url[0]) << MQTT_PRESETS[i].name; + } +} + +TEST(MQTTPresets, JwtPresetsCarryAnAudience) { + // JWT auth needs an audience (the field doubles as the broker host here). + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + if (MQTT_PRESETS[i].auth_type == MQTT_AUTH_JWT) { + EXPECT_NE(nullptr, MQTT_PRESETS[i].jwt_audience) + << MQTT_PRESETS[i].name << " is JWT but has no audience"; + } + } +} + +TEST(MQTTPresets, NamesFitTheSlotPresetBuffer) { + // Stored preset name goes into mqtt_slot_preset[MAX][24]; keep < 24 chars. + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + EXPECT_LT(strlen(MQTT_PRESETS[i].name), (size_t)24) + << MQTT_PRESETS[i].name << " too long for slot-preset buffer"; + } +} + +// ---- mqttPresetNeedsSlotCredentials --------------------------------------- + +TEST(MQTTPresets, EmbeddedUserpassDoesNotNeedSlotCredentials) { + // tennmesh ships an embedded username+password. + const MQTTPresetDef* p = findMQTTPreset("tennmesh"); + ASSERT_NE(nullptr, p); + EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type); + EXPECT_FALSE(mqttPresetNeedsSlotCredentials(p)); +} + +TEST(MQTTPresets, UserpassWithoutEmbeddedCredsNeedsSlotCredentials) { + // inwmesh is USERPASS with null user/pass -> must come from mqttN.username/password. + const MQTTPresetDef* p = findMQTTPreset("inwmesh"); + ASSERT_NE(nullptr, p); + EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type); + EXPECT_TRUE(mqttPresetNeedsSlotCredentials(p)); + EXPECT_TRUE(mqttPresetNeedsSlotUsername(p)); + EXPECT_TRUE(mqttPresetNeedsSlotPassword(p)); + EXPECT_FALSE(mqttPresetUsesDevicePubkeyUsername(p)); +} + +TEST(MQTTPresets, NonUserpassNeverNeedsSlotCredentials) { + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + if (MQTT_PRESETS[i].auth_type != MQTT_AUTH_USERPASS) { + EXPECT_FALSE(mqttPresetNeedsSlotCredentials(&MQTT_PRESETS[i])) + << MQTT_PRESETS[i].name; + } + } + EXPECT_FALSE(mqttPresetNeedsSlotCredentials(nullptr)); +} + +TEST(MQTTPresets, MeshrankIsTokenStyleNoAuth) { + const MQTTPresetDef* p = findMQTTPreset("meshrank"); + ASSERT_NE(nullptr, p); + EXPECT_EQ(MQTT_TOPIC_MESHRANK, p->topic_style); + EXPECT_EQ(MQTT_AUTH_NONE, p->auth_type); +} + +TEST(MQTTPresets, MeshChaun14UsesPubkeyUsernameAndNeedsPassword) { + const MQTTPresetDef* p = findMQTTPreset("mesh-chaun14"); + ASSERT_NE(nullptr, p); + EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type); + EXPECT_EQ(MQTT_TOPIC_MESHCORE, p->topic_style); + EXPECT_STREQ("mqtt://mqtt.mesh.chaun14.fr:1884", p->server_url); + EXPECT_EQ(nullptr, p->ca_cert); + EXPECT_EQ(60, p->keepalive); + EXPECT_TRUE(mqttPresetUsesDevicePubkeyUsername(p)); + EXPECT_STREQ(MQTT_USERPASS_USERNAME_PUBKEY, p->userpass_username); + EXPECT_FALSE(mqttPresetNeedsSlotUsername(p)); + EXPECT_TRUE(mqttPresetNeedsSlotPassword(p)); + EXPECT_TRUE(mqttPresetNeedsSlotCredentials(p)); +} + +TEST(MQTTPresets, WcmeshIsJwtWithIsrgRootX1) { + const MQTTPresetDef* p = findMQTTPreset("wcmesh"); + ASSERT_NE(nullptr, p); + EXPECT_EQ(MQTT_AUTH_JWT, p->auth_type); + EXPECT_EQ(MQTT_TOPIC_MESHCORE, p->topic_style); + EXPECT_STREQ("wss://mqtt.wcmesh.com:443", p->server_url); + EXPECT_STREQ("mqtt.wcmesh.com", p->jwt_audience); + EXPECT_EQ(ISRG_ROOT_X1, p->ca_cert); + EXPECT_NE(GTS_ROOT_R4, p->ca_cert); + EXPECT_FALSE(mqttPresetNeedsSlotCredentials(p)); + EXPECT_FALSE(mqttPresetUsesDevicePubkeyUsername(p)); +} + +// ---- slot count constants ------------------------------------------------- + +TEST(MQTTPresets, SlotCountsAreSane) { + EXPECT_GT(RUNTIME_MQTT_SLOTS, 0); + EXPECT_LE(RUNTIME_MQTT_SLOTS, MAX_MQTT_SLOTS); + EXPECT_EQ(6, MAX_MQTT_SLOTS); // persisted layout — must not drift without migration +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_reply_format/test_mqtt_reply_format.cpp b/test/test_mqtt_reply_format/test_mqtt_reply_format.cpp new file mode 100644 index 000000000..4b3726afa --- /dev/null +++ b/test/test_mqtt_reply_format/test_mqtt_reply_format.cpp @@ -0,0 +1,132 @@ +// Host tests for replyAppendf (src/helpers/MQTTReplyFormat.h), the bounded +// clamping printf-append used by MQTTBridge's status/stats/diag CLI formatters. +// Proves the A1 out-of-bounds-write bound holds regardless of input size, rather +// than "by input-size accident" (see the 2026-07-19 MQTT observer review). +#include +#include +#include "helpers/MQTTReplyFormat.h" + +// Lay a canary past the logical buffer so any write at buf[bufsize..] is caught. +// `logical` bytes are the buffer handed to replyAppendf; the trailing GUARD bytes +// must stay 0xAA. +static const size_t GUARD = 8; +struct Canaried { + static const size_t CAP = 256; + char mem[CAP + GUARD]; + size_t logical; + explicit Canaried(size_t n) : logical(n) { + memset(mem, 0xAA, sizeof(mem)); + mem[0] = '\0'; + } + char* buf() { return mem; } + bool guardIntact() const { + for (size_t i = logical; i < logical + GUARD; i++) { + if ((unsigned char)mem[i] != 0xAA) return false; + } + return true; + } +}; + +TEST(ReplyAppendf, BasicAppendWithinBounds) { + char buf[64]; + int pos = 0; + replyAppendf(buf, sizeof(buf), &pos, "> msgs: %s", "on"); + EXPECT_STREQ("> msgs: on", buf); + EXPECT_EQ(pos, 10); +} + +TEST(ReplyAppendf, SequenceAccumulates) { + char buf[64]; + int pos = 0; + replyAppendf(buf, sizeof(buf), &pos, "> msgs: %s", "off"); + replyAppendf(buf, sizeof(buf), &pos, ", %d: %s (%s)", 1, "denmesh", "ok"); + replyAppendf(buf, sizeof(buf), &pos, ", q:%d", 3); + EXPECT_STREQ("> msgs: off, 1: denmesh (ok), q:3", buf); + EXPECT_EQ(pos, (int)strlen(buf)); +} + +TEST(ReplyAppendf, TruncationClampsPosAndTerminates) { + char buf[16]; + int pos = 0; + replyAppendf(buf, sizeof(buf), &pos, "%s", "0123456789ABCDEF_TOO_LONG"); + // Written up to 15 chars + NUL; pos pinned at bufsize-1. + EXPECT_EQ(pos, 15); + EXPECT_EQ(buf[15], '\0'); + EXPECT_STREQ("0123456789ABCDE", buf); +} + +TEST(ReplyAppendf, AppendAfterFullIsNoOp) { + char buf[8]; + int pos = 0; + replyAppendf(buf, sizeof(buf), &pos, "%s", "AAAAAAAAAAAA"); // overflows + EXPECT_EQ(pos, 7); + char snapshot[8]; + memcpy(snapshot, buf, sizeof(buf)); + // Further appends must not write anything and must keep pos clamped. + replyAppendf(buf, sizeof(buf), &pos, ", more"); + replyAppendf(buf, sizeof(buf), &pos, ", q:%d", 9); + EXPECT_EQ(pos, 7); + EXPECT_EQ(0, memcmp(snapshot, buf, sizeof(buf))); +} + +// The A1 reproduction: the old `pos += snprintf(...)` idiom would let pos exceed +// bufsize after a truncated append, so the *next* append wrote at buf+pos with a +// wrapped size_t length. replyAppendf must never touch the guard bytes. +TEST(ReplyAppendf, NoWritePastBufferAcrossOverflowingChain) { + Canaried c(24); + int pos = 0; + // Mimic formatSlotDiagReply's chain, sized to blow well past 24 bytes. + replyAppendf(c.buf(), c.logical, &pos, "> mqtt%d: %s", 6, "no client"); + replyAppendf(c.buf(), c.logical, &pos, ", dc:%lu", 4294967295UL); + replyAppendf(c.buf(), c.logical, &pos, ", first_disc:%lus", 4294967295UL); + replyAppendf(c.buf(), c.logical, &pos, ", %s (0x%04X)", "cert verify failed", 0x800Bu); + replyAppendf(c.buf(), c.logical, &pos, ", mbedtls:-0x%04X", 0x8010u); + replyAppendf(c.buf(), c.logical, &pos, ", sock:%d", -2147483647); + replyAppendf(c.buf(), c.logical, &pos, ", %luh ago", 1193046UL); + EXPECT_TRUE(c.guardIntact()); + EXPECT_LE(pos, (int)c.logical - 1); + EXPECT_EQ(c.buf()[c.logical - 1], '\0'); // still NUL-terminated +} + +TEST(ReplyAppendf, ExactFitBoundary) { + char buf[11]; // room for exactly "0123456789" + NUL + int pos = 0; + replyAppendf(buf, sizeof(buf), &pos, "%s", "0123456789"); + EXPECT_EQ(pos, 10); + EXPECT_STREQ("0123456789", buf); + EXPECT_EQ(buf[10], '\0'); +} + +TEST(ReplyAppendf, NullAndZeroSizeAreNoOps) { + int pos = 0; + replyAppendf(nullptr, 16, &pos, "x"); // null buf + EXPECT_EQ(pos, 0); + char buf[8] = {'k', 0}; + replyAppendf(buf, 0, &pos, "x"); // zero size + EXPECT_STREQ("k", buf); + replyAppendf(buf, sizeof(buf), nullptr, "x"); // null pos + EXPECT_STREQ("k", buf); +} + +TEST(ReplyAppendf, BufsizeOneJustTerminates) { + char buf[1]; + buf[0] = 'Z'; + int pos = 0; + replyAppendf(buf, sizeof(buf), &pos, "anything"); + EXPECT_EQ(pos, 0); + EXPECT_EQ(buf[0], '\0'); +} + +TEST(ReplyAppendf, NegativeIncomingPosTreatedAsZero) { + char buf[16]; + memset(buf, 'x', sizeof(buf)); + int pos = -5; + replyAppendf(buf, sizeof(buf), &pos, "hi"); + EXPECT_EQ(pos, 2); + EXPECT_STREQ("hi", buf); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_runtime_buffer_lifecycle/test_mqtt_runtime_buffer_lifecycle.cpp b/test/test_mqtt_runtime_buffer_lifecycle/test_mqtt_runtime_buffer_lifecycle.cpp new file mode 100644 index 000000000..959a501d4 --- /dev/null +++ b/test/test_mqtt_runtime_buffer_lifecycle/test_mqtt_runtime_buffer_lifecycle.cpp @@ -0,0 +1,70 @@ +#include + +#include +#include + +#include "helpers/MQTTRuntimeBufferLifecycle.h" + +namespace RuntimeBuffers = MQTTRuntimeBufferLifecycle; + +TEST(MQTTRuntimeBufferLifecycle, PartialAllocationKeepsOtherBuffersAndRetriesOnlyMissing) { + std::vector allocation_sizes; + int allocation_attempt = 0; + const auto allocate = [&allocation_sizes, &allocation_attempt](size_t size) -> void* { + allocation_sizes.push_back(size); + allocation_attempt++; + if (allocation_attempt == 2) { + return nullptr; + } + return std::malloc(size); + }; + + void* raw = nullptr; + void* publish = nullptr; + void* status = nullptr; + + raw = RuntimeBuffers::allocateIfMissing(raw, 256, allocate); + publish = RuntimeBuffers::allocateIfMissing(publish, 2048, allocate); + status = RuntimeBuffers::allocateIfMissing(status, 768, allocate); + + ASSERT_NE(nullptr, raw); + EXPECT_EQ(nullptr, publish); + ASSERT_NE(nullptr, status); + ASSERT_EQ(3U, allocation_sizes.size()); + void* const initial_raw = raw; + void* const initial_status = status; + + raw = RuntimeBuffers::allocateIfMissing(raw, 256, allocate); + publish = RuntimeBuffers::allocateIfMissing(publish, 2048, allocate); + status = RuntimeBuffers::allocateIfMissing(status, 768, allocate); + + EXPECT_EQ(4U, allocation_sizes.size()); + EXPECT_EQ(2048U, allocation_sizes.back()); + EXPECT_EQ(initial_raw, raw); + EXPECT_EQ(initial_status, status); + EXPECT_NE(nullptr, publish); + + int releases = 0; + const auto release = [&releases](void* allocation) { + releases++; + std::free(allocation); + }; + raw = RuntimeBuffers::release(raw, release); + publish = RuntimeBuffers::release(publish, release); + status = RuntimeBuffers::release(status, release); + + EXPECT_EQ(nullptr, raw); + EXPECT_EQ(nullptr, publish); + EXPECT_EQ(nullptr, status); + EXPECT_EQ(3, releases); + + raw = RuntimeBuffers::release(raw, release); + publish = RuntimeBuffers::release(publish, release); + status = RuntimeBuffers::release(status, release); + EXPECT_EQ(3, releases); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_topic_router/test_mqtt_topic_router.cpp b/test/test_mqtt_topic_router/test_mqtt_topic_router.cpp new file mode 100644 index 000000000..2a453663a --- /dev/null +++ b/test/test_mqtt_topic_router/test_mqtt_topic_router.cpp @@ -0,0 +1,200 @@ +// Host contract tests for the complete MQTT publication-topic routing policy. +#define WITH_MQTT_BRIDGE 1 +#define PROGMEM + +#include +#include +#include + +#include "helpers/MQTTPresets.h" +#include "helpers/MQTTTopicRouter.h" + +namespace { + +constexpr const char* IATA = "DEN"; +constexpr const char* DEVICE = "0123456789ABCDEF"; +constexpr const char* TOKEN = "account-token"; + +struct TypeCase { + int type; + const char* name; +}; + +const TypeCase kTypes[] = { + {MQTT_PUBLICATION_STATUS, "status"}, + {MQTT_PUBLICATION_PACKETS, "packets"}, + {MQTT_PUBLICATION_RAW, "raw"}, + {MQTT_PUBLICATION_NEIGHBORS, "neighbors"}, +}; + +TEST(MQTTTopicRouter, EveryMeshCorePresetSupportsEveryPublicationType) { + for (int preset_index = 0; preset_index < MQTT_PRESET_COUNT; ++preset_index) { + const MQTTPresetDef& preset = MQTT_PRESETS[preset_index]; + if (preset.topic_style != MQTT_TOPIC_MESHCORE) continue; + + for (const TypeCase& type : kTypes) { + char topic[128]; + ASSERT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, type.type, nullptr, + IATA, DEVICE, TOKEN, topic, sizeof(topic))) + << preset.name << " / " << type.name; + EXPECT_EQ(std::string("meshcore/DEN/0123456789ABCDEF/") + type.name, topic) + << preset.name; + } + } +} + +// Guards the raw exclusion: a merge from observer-firmware must not re-enable it. +TEST(MQTTTopicRouter, MeshRankTakesEveryTypeExceptRaw) { + const MQTTPresetDef* preset = findMQTTPreset("meshrank"); + ASSERT_NE(nullptr, preset); + ASSERT_EQ(MQTT_TOPIC_MESHRANK, preset->topic_style); + + for (const TypeCase& type : kTypes) { + char topic[128]; + if (type.type == MQTT_PUBLICATION_RAW) { + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, type.type, nullptr, + IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + continue; + } + ASSERT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, type.type, nullptr, + IATA, DEVICE, TOKEN, topic, sizeof(topic))) + << type.name; + EXPECT_EQ(std::string("meshrank/uplink/account-token/0123456789ABCDEF/") + type.name, + topic) + << type.name; + } +} + +TEST(MQTTTopicRouter, MeshCoreRequiresUsableIataAndDevice) { + char topic[64]; + const char* invalid_iatas[] = {nullptr, "", "XX", "XXXX", "X/X", "XXX"}; + for (const char* iata : invalid_iatas) { + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, iata, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + } + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, nullptr, TOKEN, topic, sizeof(topic))); + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, "", TOKEN, topic, sizeof(topic))); +} + +TEST(MQTTTopicRouter, MeshRankRequiresTokenAndDeviceButNotIata) { + char topic[128]; + const char* missing_tokens[] = {nullptr, ""}; + for (const char* token : missing_tokens) { + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS, + nullptr, nullptr, DEVICE, token, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + } + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS, + nullptr, nullptr, nullptr, TOKEN, topic, sizeof(topic))); + EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS, + nullptr, nullptr, DEVICE, TOKEN, topic, sizeof(topic))); +} + +TEST(MQTTTopicRouter, CustomTemplateExpandsEveryType) { + for (const TypeCase& type : kTypes) { + char topic[128]; + ASSERT_TRUE(mqttBuildPublicationTopic( + MQTT_ROUTE_CUSTOM, type.type, "custom/{iata}/{token}/{device}/{type}", + IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_EQ(std::string("custom/DEN/account-token/0123456789ABCDEF/") + type.name, topic); + } +} + +TEST(MQTTTopicRouter, CustomLiteralDoesNotRequireIataTokenOrDevice) { + char topic[32]; + ASSERT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_RAW, + "private/raw", nullptr, nullptr, nullptr, + topic, sizeof(topic))); + EXPECT_STREQ("private/raw", topic); +} + +TEST(MQTTTopicRouter, EmptyCustomTemplateFailsRatherThanFallingBackImplicitly) { + char topic[64]; + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_STATUS, + "", IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + + // MQTTBridge selects the MeshCore style explicitly for a custom slot whose + // template is empty; make that default contract visible here. + EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("meshcore/DEN/0123456789ABCDEF/status", topic); +} + +TEST(MQTTTopicRouter, FormattedTopicsRequireRoomForTerminator) { + const char* expected = "meshcore/DEN/0123456789ABCDEF/status"; + const size_t exact_size = strlen(expected) + 1; + char exact[64]; + ASSERT_LE(exact_size, sizeof(exact)); + EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, DEVICE, TOKEN, exact, exact_size)); + EXPECT_STREQ(expected, exact); + + char short_buf[64]; + memset(short_buf, 0x7f, sizeof(short_buf)); + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, DEVICE, TOKEN, + short_buf, exact_size - 1)); + EXPECT_EQ('\0', short_buf[exact_size - 2]); +} + +TEST(MQTTTopicRouter, CustomTopicHonorsExactBoundary) { + const char* expected = "custom/DEN/raw"; + char exact[15]; + static_assert(sizeof(exact) == 15, "fixture includes the terminator"); + EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_RAW, + "custom/{iata}/{type}", IATA, DEVICE, TOKEN, + exact, sizeof(exact))); + EXPECT_STREQ(expected, exact); + + char short_buf[14]; + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_RAW, + "custom/{iata}/{type}", IATA, DEVICE, TOKEN, + short_buf, sizeof(short_buf))); + EXPECT_LT(strlen(short_buf), sizeof(short_buf)); +} + +TEST(MQTTTopicRouter, RejectsInvalidStyleTypeSlotAndOutput) { + char topic[64] = "dirty"; + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, 99, nullptr, + IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + EXPECT_FALSE(mqttBuildPublicationTopic(static_cast(99), + MQTT_PUBLICATION_STATUS, nullptr, + IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, DEVICE, TOKEN, nullptr, 64)); + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, DEVICE, TOKEN, topic, 0)); + + EXPECT_FALSE(mqttTopicSlotIndexValid(-1, RUNTIME_MQTT_SLOTS)); + EXPECT_TRUE(mqttTopicSlotIndexValid(0, RUNTIME_MQTT_SLOTS)); + EXPECT_TRUE(mqttTopicSlotIndexValid(RUNTIME_MQTT_SLOTS - 1, RUNTIME_MQTT_SLOTS)); + EXPECT_FALSE(mqttTopicSlotIndexValid(RUNTIME_MQTT_SLOTS, RUNTIME_MQTT_SLOTS)); + EXPECT_FALSE(mqttTopicSlotIndexValid(0, 0)); +} + +TEST(MQTTTopicRouter, PublicationTypeEnumValuesAreFrozen) { + // The bridge passes MQTTBridge::MQTTMessageType to mqttBuildPublicationTopic + // as an int; a compile-time static_assert in the bridge ties the two enums + // together. Freeze the router side here so its values can't drift on their own. + EXPECT_EQ(0, MQTT_PUBLICATION_STATUS); + EXPECT_EQ(1, MQTT_PUBLICATION_PACKETS); + EXPECT_EQ(2, MQTT_PUBLICATION_RAW); + EXPECT_EQ(3, MQTT_PUBLICATION_NEIGHBORS); + EXPECT_STREQ("status", mqttPublicationTypeName(MQTT_PUBLICATION_STATUS)); + EXPECT_STREQ("packets", mqttPublicationTypeName(MQTT_PUBLICATION_PACKETS)); + EXPECT_STREQ("raw", mqttPublicationTypeName(MQTT_PUBLICATION_RAW)); + EXPECT_STREQ("neighbors", mqttPublicationTypeName(MQTT_PUBLICATION_NEIGHBORS)); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_observer_validation/test_observer_validation.cpp b/test/test_observer_validation/test_observer_validation.cpp new file mode 100644 index 000000000..3045caed6 --- /dev/null +++ b/test/test_observer_validation/test_observer_validation.cpp @@ -0,0 +1,135 @@ +// Host tests for the observer input validators shared by the CLI setters +// (src/helpers/MQTTObserverValidation.h): IATA, owner key, NTP hostname, and +// the buffer-fit check behind the #17 length validation. +#include +#include +#include "helpers/MQTTObserverValidation.h" + +// ---- IATA: exactly 3 alphanumerics --------------------------------------- + +TEST(IataValid, AcceptsThreeLetters) { + EXPECT_TRUE(mqttIataValid("DEN")); + EXPECT_TRUE(mqttIataValid("den")); // case handled (setter uppercases after) + EXPECT_TRUE(mqttIataValid("LAX")); +} + +TEST(IataValid, AcceptsThreeAlphanumerics) { + EXPECT_TRUE(mqttIataValid("D3N")); + EXPECT_TRUE(mqttIataValid("2M0")); +} + +TEST(IataValid, RejectsWrongLength) { + EXPECT_FALSE(mqttIataValid("")); + EXPECT_FALSE(mqttIataValid("D")); + EXPECT_FALSE(mqttIataValid("DE")); + EXPECT_FALSE(mqttIataValid("DENV")); + EXPECT_FALSE(mqttIataValid("DENVER")); +} + +TEST(IataValid, RejectsNonAlphanumeric) { + EXPECT_FALSE(mqttIataValid("D-N")); // topic separator-ish + EXPECT_FALSE(mqttIataValid("D N")); // space + EXPECT_FALSE(mqttIataValid("D/N")); // MQTT topic separator + EXPECT_FALSE(mqttIataValid("D+N")); // MQTT wildcard + EXPECT_FALSE(mqttIataValid("D#N")); // MQTT wildcard +} + +TEST(IataValid, RejectsNull) { + EXPECT_FALSE(mqttIataValid(nullptr)); +} + +// ---- owner key: exactly 64 hex ------------------------------------------- + +static std::string hexKey(int len, char fill = 'a') { return std::string(len, fill); } + +TEST(OwnerKeyValid, Accepts64Hex) { + EXPECT_TRUE(mqttOwnerKeyValid(hexKey(64, 'a').c_str())); + EXPECT_TRUE(mqttOwnerKeyValid(hexKey(64, 'F').c_str())); + EXPECT_TRUE(mqttOwnerKeyValid( + "0123456789abcdefABCDEF0123456789abcdefABCDEF0123456789abcdef0123")); +} + +TEST(OwnerKeyValid, RejectsWrongLength) { + EXPECT_FALSE(mqttOwnerKeyValid("")); + EXPECT_FALSE(mqttOwnerKeyValid(hexKey(63).c_str())); + EXPECT_FALSE(mqttOwnerKeyValid(hexKey(65).c_str())); +} + +TEST(OwnerKeyValid, RejectsNonHex) { + std::string k = hexKey(64); + k[10] = 'g'; // not a hex digit + EXPECT_FALSE(mqttOwnerKeyValid(k.c_str())); + k[10] = 'z'; + EXPECT_FALSE(mqttOwnerKeyValid(k.c_str())); + k[10] = ' '; + EXPECT_FALSE(mqttOwnerKeyValid(k.c_str())); +} + +TEST(OwnerKeyValid, RejectsNull) { + EXPECT_FALSE(mqttOwnerKeyValid(nullptr)); +} + +// ---- NTP hostname --------------------------------------------------------- + +TEST(NtpHostnameValid, AcceptsTypicalHosts) { + EXPECT_TRUE(mqttNtpHostnameValid("pool.ntp.org")); + EXPECT_TRUE(mqttNtpHostnameValid("time.google.com")); + EXPECT_TRUE(mqttNtpHostnameValid("1.2.3.4")); + EXPECT_TRUE(mqttNtpHostnameValid("a")); +} + +TEST(NtpHostnameValid, LengthBoundaryIs63) { + EXPECT_TRUE(mqttNtpHostnameValid(std::string(63, 'a').c_str())); + EXPECT_FALSE(mqttNtpHostnameValid(std::string(64, 'a').c_str())); +} + +TEST(NtpHostnameValid, RejectsEmptyAndNull) { + EXPECT_FALSE(mqttNtpHostnameValid("")); + EXPECT_FALSE(mqttNtpHostnameValid(nullptr)); +} + +TEST(NtpHostnameValid, RejectsLeadingOrTrailingDot) { + EXPECT_FALSE(mqttNtpHostnameValid(".pool.ntp.org")); + EXPECT_FALSE(mqttNtpHostnameValid("pool.ntp.org.")); +} + +TEST(NtpHostnameValid, RejectsInvalidChars) { + EXPECT_FALSE(mqttNtpHostnameValid("a_b")); // underscore + EXPECT_FALSE(mqttNtpHostnameValid("a b")); // space + EXPECT_FALSE(mqttNtpHostnameValid("http://x")); // scheme / slashes +} + +// ---- buffer-fit (the #17 length check) ----------------------------------- + +TEST(ValueFits, FitsWhenShorterThanBuffer) { + EXPECT_TRUE(mqttValueFits("abc", 4)); // 3 < 4 (room for NUL) + EXPECT_TRUE(mqttValueFits("", 1)); // empty fits any 1+ buffer +} + +TEST(ValueFits, RejectsWhenExactlyBufferSizeOrLonger) { + EXPECT_FALSE(mqttValueFits("abcd", 4)); // 4 == 4, no room for NUL + EXPECT_FALSE(mqttValueFits("abcde", 4)); + EXPECT_FALSE(mqttValueFits("x", 1)); +} + +TEST(ValueFits, RealBufferBoundaries) { + // Mirrors the actual MQTTPrefs field sizes the setters pass sizeof() for. + EXPECT_TRUE(mqttValueFits(std::string(63, 'p').c_str(), 64)); // wifi_password[64] + EXPECT_FALSE(mqttValueFits(std::string(64, 'p').c_str(), 64)); + EXPECT_TRUE(mqttValueFits(std::string(31, 's').c_str(), 32)); // wifi_ssid[32] + EXPECT_FALSE(mqttValueFits(std::string(32, 's').c_str(), 32)); + EXPECT_TRUE(mqttValueFits(std::string(47, 't').c_str(), 48)); // slot token[48] + EXPECT_FALSE(mqttValueFits(std::string(48, 't').c_str(), 48)); + EXPECT_TRUE(mqttValueFits(std::string(95, 'x').c_str(), 96)); // slot topic[96] + EXPECT_FALSE(mqttValueFits(std::string(96, 'x').c_str(), 96)); +} + +TEST(ValueFits, RejectsNullOrZeroBuffer) { + EXPECT_FALSE(mqttValueFits(nullptr, 32)); + EXPECT_FALSE(mqttValueFits("abc", 0)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_topic_template/test_topic_template.cpp b/test/test_topic_template/test_topic_template.cpp new file mode 100644 index 000000000..5ebb553dc --- /dev/null +++ b/test/test_topic_template/test_topic_template.cpp @@ -0,0 +1,113 @@ +// Host tests for the MQTT custom-topic placeholder expansion +// (src/helpers/MQTTTopicTemplate.h), the pure core of +// MQTTBridge::substituteTopicTemplate. +#include +#include +#include "helpers/MQTTTopicTemplate.h" + +static const char* IATA = "DEN"; +static const char* DEV = "abcdef0123456789"; +static const char* TOK = "tok123"; + +TEST(TopicTemplate, SubstitutesAllPlaceholders) { + char buf[128]; + ASSERT_TRUE(mqttSubstituteTopic("meshcore/{iata}/{device}/{type}", IATA, DEV, TOK, "status", + buf, sizeof(buf))); + EXPECT_STREQ("meshcore/DEN/abcdef0123456789/status", buf); +} + +TEST(TopicTemplate, TokenPlaceholder) { + char buf[128]; + ASSERT_TRUE(mqttSubstituteTopic("meshrank/uplink/{token}/{device}/packets", + IATA, DEV, TOK, "packets", buf, sizeof(buf))); + EXPECT_STREQ("meshrank/uplink/tok123/abcdef0123456789/packets", buf); +} + +TEST(TopicTemplate, RepeatedPlaceholder) { + char buf[64]; + ASSERT_TRUE(mqttSubstituteTopic("{iata}-{iata}", IATA, DEV, TOK, "raw", buf, sizeof(buf))); + EXPECT_STREQ("DEN-DEN", buf); +} + +TEST(TopicTemplate, LiteralWithNoPlaceholders) { + char buf[64]; + ASSERT_TRUE(mqttSubstituteTopic("plain/topic/path", IATA, DEV, TOK, "status", buf, sizeof(buf))); + EXPECT_STREQ("plain/topic/path", buf); +} + +TEST(TopicTemplate, UnknownBracesCopiedVerbatim) { + char buf[64]; + ASSERT_TRUE(mqttSubstituteTopic("a/{bogus}/{iata}", IATA, DEV, TOK, "status", buf, sizeof(buf))); + EXPECT_STREQ("a/{bogus}/DEN", buf); +} + +TEST(TopicTemplate, TypeStringVaries) { + char buf[64]; + mqttSubstituteTopic("{type}", IATA, DEV, TOK, "status", buf, sizeof(buf)); + EXPECT_STREQ("status", buf); + mqttSubstituteTopic("{type}", IATA, DEV, TOK, "packets", buf, sizeof(buf)); + EXPECT_STREQ("packets", buf); + mqttSubstituteTopic("{type}", IATA, DEV, TOK, "raw", buf, sizeof(buf)); + EXPECT_STREQ("raw", buf); +} + +TEST(TopicTemplate, NullValuesSubstituteEmpty) { + char buf[64]; + ASSERT_TRUE(mqttSubstituteTopic("x/{token}/y", IATA, DEV, nullptr, "status", buf, sizeof(buf))); + EXPECT_STREQ("x//y", buf); +} + +TEST(TopicTemplate, OverflowReturnsFalseNoWrite) { + // Substituting {device} (16 chars) into a template won't fit an 8-byte buffer. + char buf[8]; + EXPECT_FALSE(mqttSubstituteTopic("{device}", IATA, DEV, TOK, "status", buf, sizeof(buf))); +} + +TEST(TopicTemplate, LiteralOverflowReturnsFalseAndNulTerminates) { + char buf[5]; + // Literal longer than the buffer: fills up to buf_size-1 and NUL-terminates. + EXPECT_FALSE(mqttSubstituteTopic("abcdefghij", IATA, DEV, TOK, "status", buf, sizeof(buf))); + EXPECT_EQ('\0', buf[4]); + EXPECT_EQ((size_t)4, strlen(buf)); +} + +TEST(TopicTemplate, LiteralSuffixOverflowAfterSubstitutionReturnsFalse) { + char buf[8]; + EXPECT_FALSE(mqttSubstituteTopic("{iata}/tail", IATA, DEV, TOK, "status", buf, sizeof(buf))); + EXPECT_STREQ("DEN/tai", buf); +} + +TEST(TopicTemplate, ExactFitLiteralSucceeds) { + char buf[5]; + EXPECT_TRUE(mqttSubstituteTopic("abcd", IATA, DEV, TOK, "status", buf, sizeof(buf))); + EXPECT_STREQ("abcd", buf); +} + +TEST(TopicTemplate, AlwaysNulTerminatedAndBounded) { + // Fuzz-ish: many buffer sizes never overrun and always NUL-terminate. + const char* tmpl = "meshcore/{iata}/{device}/{token}/{type}/tail"; + for (size_t sz = 1; sz <= 80; sz++) { + char buf[96]; + memset(buf, 0x7f, sizeof(buf)); + mqttSubstituteTopic(tmpl, IATA, DEV, TOK, "packets", buf, sz); + EXPECT_LT(strlen(buf), sz) << "size " << sz; // fits with room for NUL + EXPECT_EQ('\0', buf[strlen(buf)]); + } +} + +TEST(TopicTemplate, ZeroBufferOrNullFails) { + char buf[8]; + EXPECT_FALSE(mqttSubstituteTopic("x", IATA, DEV, TOK, "status", buf, 0)); + EXPECT_FALSE(mqttSubstituteTopic("x", IATA, DEV, TOK, "status", nullptr, 8)); +} + +TEST(TopicTemplate, EmptyTemplateReturnsFalse) { + char buf[8]; + EXPECT_FALSE(mqttSubstituteTopic("", IATA, DEV, TOK, "status", buf, sizeof(buf))); + EXPECT_STREQ("", buf); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_utf8_helpers/test_utf8_helpers.cpp b/test/test_utf8_helpers/test_utf8_helpers.cpp new file mode 100644 index 000000000..ee9535220 --- /dev/null +++ b/test/test_utf8_helpers/test_utf8_helpers.cpp @@ -0,0 +1,38 @@ +#include + +#include + +TEST(UTF8Helpers, KeepsCompleteNameWithinLimit) { + const char* name = "Example RPT 🔋🇵🇱"; + + EXPECT_EQ(24u, mesh::validUtf8PrefixLength(name, 24)); +} + +TEST(UTF8Helpers, StopsBeforeCodePointCrossingLimit) { + const char* name = "Example RPT 🔋🇵🇱"; + + EXPECT_EQ(20u, mesh::validUtf8PrefixLength(name, 23)); +} + +TEST(UTF8Helpers, RejectsMalformedAndTruncatedSequences) { + const char overlong[] = {'A', static_cast(0xC0), static_cast(0xAF), 0}; + const char surrogate[] = {'A', static_cast(0xED), static_cast(0xA0), static_cast(0x80), 0}; + const char out_of_range[] = {'A', static_cast(0xF4), static_cast(0x90), static_cast(0x80), static_cast(0x80), 0}; + const char truncated[] = {'A', static_cast(0xF0), static_cast(0x9F), 0}; + + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(overlong, sizeof(overlong))); + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(surrogate, sizeof(surrogate))); + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(out_of_range, sizeof(out_of_range))); + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(truncated, sizeof(truncated))); +} + +TEST(UTF8Helpers, RejectsUnexpectedContinuationByte) { + const char invalid[] = {'A', static_cast(0x80), 'B', 0}; + + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(invalid, sizeof(invalid))); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_webconfig_batch/test_webconfig_batch.cpp b/test/test_webconfig_batch/test_webconfig_batch.cpp new file mode 100644 index 000000000..813412c57 --- /dev/null +++ b/test/test_webconfig_batch/test_webconfig_batch.cpp @@ -0,0 +1,308 @@ +// Host contract tests for the pure WebConfig config-batch / reboot / stop state +// machine. This spec mirrors src/helpers/esp32/WebConfigServer.cpp; it is not +// yet wired into the bridge (see WebConfigBatch.h scope note). +#include + +#include +#include + +#include "helpers/WebConfigBatch.h" + +namespace Batch = WebConfigBatch; +using State = WebConfigBatch::State; + +// -------------------------------------------------------------------------- +// POST accept classification +// -------------------------------------------------------------------------- +TEST(WebConfigBatch, IdleAcceptsANewBatchWithChangesOrRebootOnly) { + // A normal save (changes present) is accepted. + EXPECT_EQ(Batch::PostOutcome::Accept, + Batch::classifyPost(State::Idle, /*reqid_matches=*/false, /*count=*/3, false)); + // A reboot-only request (no changes) is still accepted. + EXPECT_EQ(Batch::PostOutcome::Accept, + Batch::classifyPost(State::Idle, false, 0, /*reboot_after=*/true)); +} + +TEST(WebConfigBatch, IdleWithNothingToDoIsNoChanges) { + EXPECT_EQ(Batch::PostOutcome::NoChanges, + Batch::classifyPost(State::Idle, false, 0, false)); +} + +TEST(WebConfigBatch, SameReqidReplaysWithoutReapplyingWhilePendingOrDone) { + // The idempotent-replay path fires for BOTH in-flight and finished batches + // when the reqid matches; commands are never re-applied. + EXPECT_EQ(Batch::PostOutcome::Replay, + Batch::classifyPost(State::Pending, /*reqid_matches=*/true, 3, false)); + EXPECT_EQ(Batch::PostOutcome::Replay, + Batch::classifyPost(State::Done, /*reqid_matches=*/true, 3, false)); + // Replay wins even over a would-be no-changes request. + EXPECT_EQ(Batch::PostOutcome::Replay, + Batch::classifyPost(State::Pending, true, 0, false)); + // The replay body reports the batch's own state. + EXPECT_STREQ("pending", Batch::replayStateName(State::Pending)); + EXPECT_STREQ("done", Batch::replayStateName(State::Done)); +} + +TEST(WebConfigBatch, DifferentReqidIsBusyOnlyWhilePending) { + // A second client (different reqid) while a batch is still draining => busy. + EXPECT_EQ(Batch::PostOutcome::Busy, + Batch::classifyPost(State::Pending, /*reqid_matches=*/false, 2, false)); + // But once the previous batch is DONE, a different reqid is NOT busy: the new + // batch overwrites the finished slot (asymmetry with the Pending case). + EXPECT_EQ(Batch::PostOutcome::Accept, + Batch::classifyPost(State::Done, false, 2, false)); + EXPECT_EQ(Batch::PostOutcome::NoChanges, + Batch::classifyPost(State::Done, false, 0, false)); +} + +// -------------------------------------------------------------------------- +// Drain pacing / all_ok / finish +// -------------------------------------------------------------------------- +TEST(WebConfigBatch, DrainNeverWaitsBeforeTheFirstOrAfterTheLastCommand) { + // batch_next == 0: the first command runs immediately (fires onConfigBatchStart). + EXPECT_FALSE(Batch::drainMustWait(0, 5, 1000, 1000)); + // batch_next >= batch_count: nothing left to pace. + EXPECT_FALSE(Batch::drainMustWait(5, 5, 1000, 1000)); + // A single-command (or reboot-only) batch never paces. + EXPECT_FALSE(Batch::drainMustWait(0, 1, 1000, 1000)); + EXPECT_FALSE(Batch::drainMustWait(0, 0, 1000, 1000)); +} + +TEST(WebConfigBatch, DrainPacesTwentyFiveMillisBetweenCommandsWithInclusiveRelease) { + const uint32_t last = 1000; + // 24 ms after the previous command: still waiting. + EXPECT_TRUE(Batch::drainMustWait(2, 5, last + 24, last)); + // Exactly 25 ms: the gate releases (production uses `< 25`). + EXPECT_FALSE(Batch::drainMustWait(2, 5, last + 25, last)); + EXPECT_FALSE(Batch::drainMustWait(2, 5, last + 26, last)); +} + +TEST(WebConfigBatch, DrainPacingSurvivesMillisRollover) { + const uint32_t last = std::numeric_limits::max() - 10; + EXPECT_TRUE(Batch::drainMustWait(2, 5, last + 24, last)); // 24 ms elapsed, wrapped + EXPECT_FALSE(Batch::drainMustWait(2, 5, last + 25, last)); // 25 ms elapsed, wrapped +} + +TEST(WebConfigBatch, AllOkIsAStickyAndAcrossCommandReplies) { + bool all_ok = true; + all_ok = Batch::nextAllOk(all_ok, true); + EXPECT_TRUE(all_ok); + all_ok = Batch::nextAllOk(all_ok, false); // one command failed + EXPECT_FALSE(all_ok); + all_ok = Batch::nextAllOk(all_ok, true); // stays false forever after + EXPECT_FALSE(all_ok); +} + +TEST(WebConfigBatch, DrainFinishesWhenTheIndexReachesTheCount) { + EXPECT_FALSE(Batch::drainFinished(4, 5)); + EXPECT_TRUE(Batch::drainFinished(5, 5)); + EXPECT_TRUE(Batch::drainFinished(0, 0)); // reboot-only / empty batch +} + +TEST(WebConfigBatch, FinishArmsThirtySecondFallbackOnlyForAFullyOkRebootBatch) { + const uint32_t now = 100000; + EXPECT_EQ(now + Batch::kRebootFallbackMs, + Batch::finishRebootAt(/*reboot=*/true, /*all_ok=*/true, now)); + // A partially-failed batch never reboots. + EXPECT_EQ(0u, Batch::finishRebootAt(true, false, now)); + // No reboot requested. + EXPECT_EQ(0u, Batch::finishRebootAt(false, true, now)); +} + +// -------------------------------------------------------------------------- +// Result read +// -------------------------------------------------------------------------- +TEST(WebConfigBatch, ResultReadClassifiesIdlePendingDoneAndUnknownReqid) { + // Idle: any valid reqid gets "idle" (no reqid check while idle). + EXPECT_EQ(Batch::ResultOutcome::Idle, Batch::classifyResult(State::Idle, false)); + EXPECT_EQ(Batch::ResultOutcome::Idle, Batch::classifyResult(State::Idle, true)); + // Matching reqid reflects the batch state. + EXPECT_EQ(Batch::ResultOutcome::Pending, Batch::classifyResult(State::Pending, true)); + EXPECT_EQ(Batch::ResultOutcome::Done, Batch::classifyResult(State::Done, true)); + // A live/finished batch with a mismatched reqid is unknown (404). + EXPECT_EQ(Batch::ResultOutcome::Unknown, Batch::classifyResult(State::Pending, false)); + EXPECT_EQ(Batch::ResultOutcome::Unknown, Batch::classifyResult(State::Done, false)); +} + +TEST(WebConfigBatch, DoneBodyAdvertisesRebootOnlyWhenFullyOk) { + EXPECT_TRUE(Batch::doneReportsReboot(true, true)); + EXPECT_FALSE(Batch::doneReportsReboot(true, false)); + EXPECT_FALSE(Batch::doneReportsReboot(false, true)); +} + +TEST(WebConfigBatch, FirstDoneReadArmsTheThreeSecondRebootExactlyOnce) { + const uint32_t now = 500000; + // First read of a fully-OK reboot batch arms. + EXPECT_TRUE(Batch::shouldArmConfirmReboot(State::Done, true, true, /*already_armed=*/false)); + EXPECT_EQ(now + Batch::kRebootConfirmMs, Batch::confirmRebootAt(now)); + // Already armed => never re-arms (polling can't push the deadline out). + EXPECT_FALSE(Batch::shouldArmConfirmReboot(State::Done, true, true, /*already_armed=*/true)); + // Not applicable while pending, without reboot, or after a partial failure. + EXPECT_FALSE(Batch::shouldArmConfirmReboot(State::Pending, true, true, false)); + EXPECT_FALSE(Batch::shouldArmConfirmReboot(State::Done, false, true, false)); + EXPECT_FALSE(Batch::shouldArmConfirmReboot(State::Done, true, false, false)); +} + +// -------------------------------------------------------------------------- +// Reboot fire / pending +// -------------------------------------------------------------------------- +TEST(WebConfigBatch, RebootFiresAtOrAfterTheDeadlineAndNeverWhenUnscheduled) { + EXPECT_FALSE(Batch::rebootDue(0, 1234567)); // 0 == unscheduled + const uint32_t at = 100000; + EXPECT_FALSE(Batch::rebootDue(at, at - 1)); + EXPECT_TRUE(Batch::rebootDue(at, at)); // inclusive boundary + EXPECT_TRUE(Batch::rebootDue(at, at + 1)); +} + +TEST(WebConfigBatch, RebootDueSurvivesMillisRollover) { + const uint32_t at = std::numeric_limits::max() - 5; // near the top + EXPECT_FALSE(Batch::rebootDue(at, at - 1)); + EXPECT_TRUE(Batch::rebootDue(at, at)); + EXPECT_TRUE(Batch::rebootDue(at, at + 10)); // now has wrapped past zero +} + +TEST(WebConfigBatch, OnlyAConfigSaveRebootInDoneStateReportsPending) { + EXPECT_TRUE(Batch::isConfigRebootPending(/*reboot_at=*/123, /*batch_reboot=*/true, State::Done)); + // Manual /api/reboot: reboot_at set but batch_reboot false => not "pending". + EXPECT_FALSE(Batch::isConfigRebootPending(123, false, State::Done)); + // Not yet done, or nothing scheduled. + EXPECT_FALSE(Batch::isConfigRebootPending(123, true, State::Pending)); + EXPECT_FALSE(Batch::isConfigRebootPending(0, true, State::Done)); +} + +// -------------------------------------------------------------------------- +// Stop gating +// -------------------------------------------------------------------------- +TEST(WebConfigBatch, StopFinalizesOnlyWhenNoHandlersAreInFlight) { + EXPECT_EQ(Batch::StopAction::Finalize, + Batch::stopStep(/*refs=*/0, /*warned=*/false, /*warn_at=*/50000, /*now=*/60000)); +} + +TEST(WebConfigBatch, StopWarnsOnceAfterTheDeadlineThenKeepsWaiting) { + const uint32_t warn_at = 50000; + // Before the warn deadline with handlers in flight: just wait. + EXPECT_EQ(Batch::StopAction::Wait, Batch::stopStep(2, false, warn_at, warn_at - 1)); + // At the deadline, not yet warned: warn once. + EXPECT_EQ(Batch::StopAction::Warn, Batch::stopStep(2, false, warn_at, warn_at)); + // Already warned: keep waiting (never warns again, never forces teardown). + EXPECT_EQ(Batch::StopAction::Wait, Batch::stopStep(2, true, warn_at, warn_at + 100000)); + // An unscheduled warn timer never warns. + EXPECT_EQ(Batch::StopAction::Wait, Batch::stopStep(2, false, 0, 999999)); +} + +// -------------------------------------------------------------------------- +// CLI sequences (/api/cli), which share the deferred-command slot +// -------------------------------------------------------------------------- +TEST(WebConfigBatch, CliReadPagesResultsAndNeverOverrunsWhatHasDrained) { + const int page = Batch::kCliResultPage; + // Nothing drained past the cursor yet. + EXPECT_EQ(0, Batch::cliPageCount(/*from=*/0, /*produced=*/0, page)); + EXPECT_EQ(0, Batch::cliPageCount(/*from=*/3, /*produced=*/3, page)); + // Partial progress: hand back exactly what exists. + EXPECT_EQ(3, Batch::cliPageCount(0, 3, page)); + EXPECT_EQ(2, Batch::cliPageCount(5, 7, page)); + // More available than fits in one read: cap at the page size. + EXPECT_EQ(page, Batch::cliPageCount(0, page + 5, page)); + // A cursor beyond what has drained (stale or crafted) yields nothing rather + // than a negative count that would index backwards through the batch. + EXPECT_EQ(0, Batch::cliPageCount(/*from=*/9, /*produced=*/4, page)); +} + +TEST(WebConfigBatch, CliReadIsDoneOnlyOnceEveryResultHasBeenHandedOver) { + // Still executing: never final, however much has been read. + EXPECT_FALSE(Batch::cliReadIsFinal(State::Pending, /*from=*/0, /*page=*/8, /*total=*/8)); + // Execution finished but the client has only seen the first page. Reporting + // "done" here would make a client that stops polling lose the rest. + EXPECT_FALSE(Batch::cliReadIsFinal(State::Done, /*from=*/0, /*page=*/8, /*total=*/20)); + EXPECT_FALSE(Batch::cliReadIsFinal(State::Done, /*from=*/8, /*page=*/8, /*total=*/20)); + // The read that hands over the last result is the final one. + EXPECT_TRUE(Batch::cliReadIsFinal(State::Done, /*from=*/16, /*page=*/4, /*total=*/20)); + // Re-reading past the end stays final (polls after the last page). + EXPECT_TRUE(Batch::cliReadIsFinal(State::Done, /*from=*/20, /*page=*/0, /*total=*/20)); +} + +// Every string below is a literal lifted from CommonCLI.cpp / +// CommonCLI_Observer.cpp. Testing only for an "Err" prefix passed five of these +// off as success, which both coloured them green and let a queued reboot go +// ahead after them. +TEST(WebConfigBatch, CliFailureRepliesAreRecognisedInEveryShapeCommonCLIEmits) { + const char* failures[] = { + "Err - bad params", // MyMesh setperm + "ERR: bad pubkey", // neighbor.remove + "Error: IATA code must be exactly 3 letters",// observer setters + "(ERR: clock cannot go backwards)", // clock sync, parenthesised + "Unknown command", // top-level fallthrough + "unknown config: mqtt.nope", // set fallthrough + "??: mqtt.nope", // get fallthrough + "Can't find GPS", // gps + "File system erase: Err", // failure reported at the end + }; + for (const char* f : failures) { + EXPECT_TRUE(Batch::cliReplyIsFailure(f)) << f; + } + + const char* successes[] = { + "OK", + "OK - slot 1 preset: meshrank", + "> 22", // getter value + "> msgs: on, 1: analyzer-us (ok)", // getter, contains "ok" + "File system erase: OK", // same shape, succeeded + "Free: 142832, Min: 126808", // memory + "v1.16.0 (Build: 6 Jun 2026)", // ver + }; + for (const char* s : successes) { + EXPECT_FALSE(Batch::cliReplyIsFailure(s)) << s; + } + // An empty reply is normalised to "OK" before it ever reaches the client. + EXPECT_FALSE(Batch::cliReplyIsFailure("")); + EXPECT_FALSE(Batch::cliReplyIsFailure(NULL)); +} + +TEST(WebConfigBatch, OnlyWritesGateTheDeferredReboot) { + // Writes gate it: these are what can leave a config not worth rebooting into. + EXPECT_TRUE(Batch::cliReplyGatesReboot("set tx 22")); + EXPECT_TRUE(Batch::cliReplyGatesReboot("set mqtt1.preset meshrank")); + EXPECT_TRUE(Batch::cliReplyGatesReboot("password hunter2")); + // Diagnostics do not. `memory` answering "Free: ..." must not be read as a + // failure and strand the operator's reboot, and a getter's "> value" must not + // be read as a success either — neither is asked. + EXPECT_FALSE(Batch::cliReplyGatesReboot("memory")); + EXPECT_FALSE(Batch::cliReplyGatesReboot("get tx")); + EXPECT_FALSE(Batch::cliReplyGatesReboot("reboot")); + EXPECT_FALSE(Batch::cliReplyGatesReboot("advert")); + EXPECT_FALSE(Batch::cliReplyGatesReboot(NULL)); + // "settle" must not be mistaken for a `set`; the space is part of the token. + EXPECT_FALSE(Batch::cliReplyGatesReboot("settle")); + + // A write counts only on the "OK" prefix every setter keeps. + EXPECT_TRUE(Batch::cliWriteSucceeded("OK")); + EXPECT_TRUE(Batch::cliWriteSucceeded("OK - reboot to apply")); + EXPECT_FALSE(Batch::cliWriteSucceeded("unknown config: nope")); + EXPECT_FALSE(Batch::cliWriteSucceeded("Error: expected a number")); + EXPECT_FALSE(Batch::cliWriteSucceeded("")); +} + +TEST(WebConfigBatch, CliRebootIsWithheldWhenAnyCommandInTheSequenceFailed) { + EXPECT_TRUE(Batch::cliRebootAllowed(/*has_reboot=*/true, /*all_ok=*/true)); + // Same rule a config save follows: do not reboot into a half-applied config + // over a link the operator may not get back. + EXPECT_FALSE(Batch::cliRebootAllowed(true, false)); + // No `reboot` in the sequence: nothing to allow either way. + EXPECT_FALSE(Batch::cliRebootAllowed(false, true)); + EXPECT_FALSE(Batch::cliRebootAllowed(false, false)); +} + +// -------------------------------------------------------------------------- +// Wrap-around guard shared with the production _reboot_at assignments +// -------------------------------------------------------------------------- +TEST(WebConfigBatch, ScheduleAtNeverReturnsTheUnscheduledSentinel) { + EXPECT_EQ(1000u + 3000u, Batch::scheduleAt(1000, 3000)); + // A deadline that lands exactly on 0 is bumped to 1 so it still means "set". + const uint32_t just_below_wrap = std::numeric_limits::max(); // +1 wraps to 0 + EXPECT_EQ(1u, Batch::scheduleAt(just_below_wrap, 1)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_webconfig_keys/test_webconfig_keys.cpp b/test/test_webconfig_keys/test_webconfig_keys.cpp new file mode 100644 index 000000000..3903453b1 --- /dev/null +++ b/test/test_webconfig_keys/test_webconfig_keys.cpp @@ -0,0 +1,181 @@ +// Host tests for the WebConfig key allowlist / secret / slot-prefix helpers +// (src/helpers/WebConfigKeys.h). These parse attacker-supplied POST keys, so +// coverage of the length-guard and boundary cases matters for safety. +#include +#include "helpers/WebConfigKeys.h" + +// ---- allowlist ------------------------------------------------------------ + +TEST(WebConfigKeys, AllowsKnownScalarKeys) { + EXPECT_TRUE(wcIsAllowedSetKey("name")); + EXPECT_TRUE(wcIsAllowedSetKey("radio")); + EXPECT_TRUE(wcIsAllowedSetKey("repeat")); + EXPECT_TRUE(wcIsAllowedSetKey("wifi.ssid")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt.iata")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt.neighbors")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt.neighbors.interval")); + EXPECT_TRUE(wcIsAllowedSetKey("snmp.community")); + EXPECT_TRUE(wcIsAllowedSetKey("timezone.offset")); +} + +TEST(WebConfigKeys, AllowsPerSlotKeys) { + EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.preset")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.server")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.token")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt2.filter")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.audience")); // MAX_MQTT_SLOTS == 6 +} + +TEST(WebConfigKeys, RejectsDangerousOrUnknownKeys) { + EXPECT_FALSE(wcIsAllowedSetKey("erase")); + EXPECT_FALSE(wcIsAllowedSetKey("password")); + EXPECT_FALSE(wcIsAllowedSetKey("reboot")); + EXPECT_FALSE(wcIsAllowedSetKey("bogus")); + EXPECT_FALSE(wcIsAllowedSetKey("mqtt1.bogus")); // unknown slot field + EXPECT_FALSE(wcIsAllowedSetKey("")); +} + +TEST(WebConfigKeys, AdminPasswordIsNotAnAllowlistedSetKey) { + EXPECT_TRUE(wcIsAdminPasswordKey("password")); + EXPECT_FALSE(wcIsAdminPasswordKey("admin.password")); + EXPECT_FALSE(wcIsAdminPasswordKey("")); + EXPECT_FALSE(wcIsAllowedSetKey("password")); // never reachable as `set password` +} + +TEST(WebConfigKeys, AdminPasswordFitsNodePrefsAndRejectsLineBreaks) { + EXPECT_FALSE(wcIsValidAdminPassword(NULL)); + EXPECT_FALSE(wcIsValidAdminPassword("")); + EXPECT_TRUE(wcIsValidAdminPassword("new-password")); + EXPECT_TRUE(wcIsValidAdminPassword("123456789012345")); + EXPECT_FALSE(wcIsValidAdminPassword("1234567890123456")); + EXPECT_FALSE(wcIsValidAdminPassword("line\nbreak")); + EXPECT_FALSE(wcIsValidAdminPassword("line\rbreak")); +} + +TEST(WebConfigKeys, SlotIndexBoundsMatchMaxSlots) { + EXPECT_FALSE(wcIsAllowedSetKey("mqtt0.preset")); // slot 0 invalid + EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.preset")); // last valid slot + EXPECT_FALSE(wcIsAllowedSetKey("mqtt7.preset")); // beyond MAX_MQTT_SLOTS + EXPECT_FALSE(wcIsAllowedSetKey("mqtt9.preset")); +} + +TEST(WebConfigKeys, IsCaseSensitive) { + EXPECT_FALSE(wcIsAllowedSetKey("Name")); + EXPECT_FALSE(wcIsAllowedSetKey("MQTT1.preset")); +} + +// ---- short-key OOB guard -------------------------------------------------- +// The slot-prefix probe indexes key[4..6]; these short strings must be rejected +// without ever reading past the terminator. + +TEST(WebConfigKeys, ShortKeysRejectedSafely) { + EXPECT_FALSE(wcIsSlotKeyPrefix("")); + EXPECT_FALSE(wcIsSlotKeyPrefix("m")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mq")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqt")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt")); // 4 chars — no digit/dot + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt1")); // 5 chars — no dot + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt1.")); // 6 chars — no field char + EXPECT_TRUE(wcIsSlotKeyPrefix("mqtt1.x")); // 7 chars — minimum valid + // Same guard via the public allowlist/secret entry points: + EXPECT_FALSE(wcIsAllowedSetKey("mqtt")); + EXPECT_FALSE(wcIsSecretKey("m")); + EXPECT_FALSE(wcIsSecretKey("mqtt")); +} + +TEST(WebConfigKeys, SlotPrefixDigitRange) { + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt0.x")); + EXPECT_TRUE(wcIsSlotKeyPrefix("mqtt6.x")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt7.x")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqttA.x")); // non-digit +} + +// ---- secret classification ------------------------------------------------ + +TEST(WebConfigKeys, SecretKeysDetected) { + EXPECT_TRUE(wcIsSecretKey("wifi.pwd")); + EXPECT_TRUE(wcIsSecretKey("mqtt1.password")); + EXPECT_TRUE(wcIsSecretKey("mqtt3.token")); + EXPECT_TRUE(wcIsSecretKey("mqtt6.password")); +} + +TEST(WebConfigKeys, NonSecretKeysNotFlagged) { + EXPECT_FALSE(wcIsSecretKey("wifi.ssid")); + EXPECT_FALSE(wcIsSecretKey("mqtt1.username")); // username is not masked + EXPECT_FALSE(wcIsSecretKey("mqtt1.server")); + EXPECT_FALSE(wcIsSecretKey("mqtt1.filter")); + EXPECT_FALSE(wcIsSecretKey("mqtt.origin")); + EXPECT_FALSE(wcIsSecretKey("name")); + EXPECT_FALSE(wcIsSecretKey("")); +} + +TEST(WebConfigKeys, EverySecretKeyIsAlsoAllowed) { + // A secret key must be one the portal can actually set, or the masking is moot. + const char* secrets[] = {"wifi.pwd", "mqtt1.password", "mqtt1.token", + "mqtt6.password", "mqtt6.token"}; + for (const char* k : secrets) { + EXPECT_TRUE(wcIsSecretKey(k)) << k; + EXPECT_TRUE(wcIsAllowedSetKey(k)) << k; + } +} + +// ---- CLI secret reads ---------------------------------------------------- +// The web CLI runs commands with sender_timestamp 0, which is how CommonCLI +// recognises the serial console and answers secret getters in plaintext. These +// are the reads that must be masked back down for an HTTP caller. + +TEST(WebConfigKeys, MasksEverySecretReadTheCliCanReach) { + const char* masked[] = { + "get prv.key", // this node's identity — the worst one to leak + "get wifi.pwd", // grants the operator's LAN, not just the node + "get guest.password", + "get alert.psk", + "get bridge.secret", + "get mqtt1.password", "get mqtt1.token", + "get mqtt6.password", "get mqtt6.token", + "get wifi.pwd", // extra space after the verb + }; + for (const char* c : masked) EXPECT_TRUE(wcIsSecretReadCommand(c)) << c; +} + +TEST(WebConfigKeys, DoesNotMaskReadsThatCarryNoSecret) { + const char* plain[] = { + "get wifi.ssid", "get mqtt1.username", "get mqtt1.server", "get tx", + "get public.key", // public half, safe to read + "get mqtt.owner", // an owner's public key, not a credential + }; + for (const char* c : plain) EXPECT_FALSE(wcIsSecretReadCommand(c)) << c; +} + +TEST(WebConfigKeys, OnlyMasksReads) { + // Writing a secret has always been the portal's job and reveals nothing; + // only the read is restricted. Nor may a prefix be mistaken for a `get`. + EXPECT_FALSE(wcIsSecretReadCommand("set wifi.pwd hunter2")); + EXPECT_FALSE(wcIsSecretReadCommand("set prv.key aabb")); + EXPECT_FALSE(wcIsSecretReadCommand("password hunter2")); + EXPECT_FALSE(wcIsSecretReadCommand("getwifi.pwd")); + EXPECT_FALSE(wcIsSecretReadCommand("get")); + EXPECT_FALSE(wcIsSecretReadCommand("")); +} + +// ---- request correlation ------------------------------------------------- + +TEST(WebConfigKeys, AcceptsExactHexRequestIds) { + EXPECT_TRUE(wcIsValidReqId("0123456789abcdef")); + EXPECT_TRUE(wcIsValidReqId("ABCDEF0123456789")); +} + +TEST(WebConfigKeys, RejectsMissingMalformedOrWrongLengthRequestIds) { + EXPECT_FALSE(wcIsValidReqId(NULL)); + EXPECT_FALSE(wcIsValidReqId("")); + EXPECT_FALSE(wcIsValidReqId("0123456789abcde")); + EXPECT_FALSE(wcIsValidReqId("0123456789abcdef0")); + EXPECT_FALSE(wcIsValidReqId("0123456789abcdeg")); + EXPECT_FALSE(wcIsValidReqId("01234567-9abcdef")); + EXPECT_FALSE(wcIsValidReqId("01234567 9abcdef")); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/ebyte_eora_s3/platformio.ini b/variants/ebyte_eora_s3/platformio.ini index 15fe761ba..1ab3d3fb1 100644 --- a/variants/ebyte_eora_s3/platformio.ini +++ b/variants/ebyte_eora_s3/platformio.ini @@ -102,6 +102,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Ebyte_EoRa-S3.build_src_filter} diff --git a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h index ab7ecc245..0b0f701c2 100644 --- a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h +++ b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h @@ -47,7 +47,7 @@ public: uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/gat562_30s_mesh_kit/platformio.ini b/variants/gat562_30s_mesh_kit/platformio.ini index 2baac2561..89276a49a 100644 --- a/variants/gat562_30s_mesh_kit/platformio.ini +++ b/variants/gat562_30s_mesh_kit/platformio.ini @@ -75,6 +75,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${GAT562_30S_Mesh_Kit.build_src_filter} diff --git a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h index 33c3d05fc..66c770994 100644 --- a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h +++ b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h @@ -47,7 +47,7 @@ public: uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h index aa1772e4c..39b55911a 100644 --- a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h +++ b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h @@ -47,7 +47,7 @@ public: uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/gat562_mesh_tracker_pro/platformio.ini b/variants/gat562_mesh_tracker_pro/platformio.ini index af153b8fc..142cfe4b8 100644 --- a/variants/gat562_mesh_tracker_pro/platformio.ini +++ b/variants/gat562_mesh_tracker_pro/platformio.ini @@ -71,6 +71,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${GAT562_Mesh_Tracker_Pro.build_src_filter} diff --git a/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h b/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h index da792b78b..805ca67f2 100644 --- a/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h +++ b/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h @@ -38,7 +38,7 @@ public: uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/heltec_ct62/platformio.ini b/variants/heltec_ct62/platformio.ini index 0179d9658..401a30d23 100644 --- a/variants/heltec_ct62/platformio.ini +++ b/variants/heltec_ct62/platformio.ini @@ -101,6 +101,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Heltec_ct62.build_src_filter} diff --git a/variants/heltec_e213/HeltecE213Board.cpp b/variants/heltec_e213/HeltecE213Board.cpp index af1153182..88737c4d5 100644 --- a/variants/heltec_e213/HeltecE213Board.cpp +++ b/variants/heltec_e213/HeltecE213Board.cpp @@ -20,33 +20,6 @@ void HeltecE213Board::begin() { } } - void HeltecE213Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void HeltecE213Board::powerOff() { - enterDeepSleep(0); - } - uint16_t HeltecE213Board::getBattMilliVolts() { analogReadResolution(10); digitalWrite(PIN_ADC_CTRL, HIGH); diff --git a/variants/heltec_e213/HeltecE213Board.h b/variants/heltec_e213/HeltecE213Board.h index 2192c1410..fadc038f9 100644 --- a/variants/heltec_e213/HeltecE213Board.h +++ b/variants/heltec_e213/HeltecE213Board.h @@ -3,7 +3,6 @@ #include #include #include -#include class HeltecE213Board : public ESP32Board { @@ -13,8 +12,6 @@ public: HeltecE213Board() : periph_power(PIN_VEXT_EN,PIN_VEXT_EN_ACTIVE) { } void begin(); - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; }; diff --git a/variants/heltec_e213/platformio.ini b/variants/heltec_e213/platformio.ini index 123edd91c..021938190 100644 --- a/variants/heltec_e213/platformio.ini +++ b/variants/heltec_e213/platformio.ini @@ -73,6 +73,7 @@ build_flags = -D DISPLAY_CLASS=E213Display -D AUTO_OFF_MILLIS=0 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${Heltec_E213_base.build_src_filter} + + diff --git a/variants/heltec_e290/HeltecE290Board.cpp b/variants/heltec_e290/HeltecE290Board.cpp index 3994a2061..96ec59c9b 100644 --- a/variants/heltec_e290/HeltecE290Board.cpp +++ b/variants/heltec_e290/HeltecE290Board.cpp @@ -20,33 +20,6 @@ void HeltecE290Board::begin() { } } - void HeltecE290Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void HeltecE290Board::powerOff() { - enterDeepSleep(0); - } - uint16_t HeltecE290Board::getBattMilliVolts() { analogReadResolution(10); digitalWrite(PIN_ADC_CTRL, HIGH); diff --git a/variants/heltec_e290/HeltecE290Board.h b/variants/heltec_e290/HeltecE290Board.h index 645ec3481..f287227c6 100644 --- a/variants/heltec_e290/HeltecE290Board.h +++ b/variants/heltec_e290/HeltecE290Board.h @@ -3,7 +3,6 @@ #include #include #include -#include class HeltecE290Board : public ESP32Board { @@ -13,8 +12,6 @@ public: HeltecE290Board() : periph_power(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE) { } void begin(); - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/heltec_mesh_solar/platformio.ini b/variants/heltec_mesh_solar/platformio.ini index 92d641a35..17f8ba217 100644 --- a/variants/heltec_mesh_solar/platformio.ini +++ b/variants/heltec_mesh_solar/platformio.ini @@ -1,7 +1,6 @@ [Heltec_mesh_solar] extends = nrf52_base board = heltec_mesh_solar -platform_packages = framework-arduinoadafruitnrf52 board_build.ldscript = boards/nrf52840_s140_v6.ld build_flags = ${nrf52_base.build_flags} -I src/helpers/nrf52 @@ -14,6 +13,11 @@ build_flags = ${nrf52_base.build_flags} -D LORA_TX_POWER=22 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 + -D HAS_EXTERNAL_WATCHDOG + -D EXTERNAL_WATCHDOG_DONE_PIN=9 + -D EXTERNAL_WATCHDOG_WAKE_PIN=10 + -D EXTERNAL_WATCHDOG_FEED_INTERVAL_MS=480000 ; 8 minute feed interval, safely inside the hardware watchdog timeout + build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/heltec_mesh_solar> @@ -83,6 +87,7 @@ build_flags = ${Heltec_mesh_solar.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp index 4c49146bc..2061596ce 100644 --- a/variants/heltec_mesh_solar/target.cpp +++ b/variants/heltec_mesh_solar/target.cpp @@ -13,6 +13,7 @@ VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); SolarSensorManager sensors = SolarSensorManager(nmea); +SolarExternalWatchdog external_watchdog; #ifdef DISPLAY_CLASS DISPLAY_CLASS display; @@ -106,3 +107,34 @@ bool SolarSensorManager::setSettingValue(const char* name, const char* value) { } return false; // not supported } + +bool SolarExternalWatchdog::begin() { + last_feed_watchdog = 0; + pinMode(EXTERNAL_WATCHDOG_WAKE_PIN, INPUT); + pinMode(EXTERNAL_WATCHDOG_DONE_PIN, OUTPUT); + delay(1); + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, LOW); + delay(1); + feed(); + return true; +} +void SolarExternalWatchdog::loop() { + if (millis() - last_feed_watchdog >= EXTERNAL_WATCHDOG_FEED_INTERVAL_MS) { + feed(); + } +} + +unsigned long SolarExternalWatchdog::getIntervalMs() const { + unsigned long elapsed_ms = millis() - last_feed_watchdog; + if (elapsed_ms >= EXTERNAL_WATCHDOG_FEED_INTERVAL_MS) { + return 0; + } + return EXTERNAL_WATCHDOG_FEED_INTERVAL_MS - elapsed_ms; +} + +void SolarExternalWatchdog::feed() { + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, HIGH); + delay(1); + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, LOW); + last_feed_watchdog = millis(); +} diff --git a/variants/heltec_mesh_solar/target.h b/variants/heltec_mesh_solar/target.h index 1956f50eb..8e13a9601 100644 --- a/variants/heltec_mesh_solar/target.h +++ b/variants/heltec_mesh_solar/target.h @@ -8,6 +8,7 @@ #include #include #include +#include #ifdef DISPLAY_CLASS #include #endif @@ -30,10 +31,20 @@ public: bool setSettingValue(const char* name, const char* value) override; }; +class SolarExternalWatchdog : public ExternalWatchdogManager { +public: + SolarExternalWatchdog() {} + bool begin() override; + void loop() override; + unsigned long getIntervalMs() const override; + void feed() override; +}; + extern MeshSolarBoard board; extern WRAPPER_CLASS radio_driver; extern AutoDiscoverRTCClock rtc_clock; extern SolarSensorManager sensors; +extern SolarExternalWatchdog external_watchdog; #ifdef DISPLAY_CLASS extern DISPLAY_CLASS display; diff --git a/variants/heltec_mesh_solar/variant.h b/variants/heltec_mesh_solar/variant.h index 14956619f..3c1b378df 100644 --- a/variants/heltec_mesh_solar/variant.h +++ b/variants/heltec_mesh_solar/variant.h @@ -34,8 +34,8 @@ #define PIN_SERIAL1_RX (37) #define PIN_SERIAL1_TX (39) -#define PIN_SERIAL2_RX (9) -#define PIN_SERIAL2_TX (10) +#define PIN_SERIAL2_RX (-1) +#define PIN_SERIAL2_TX (-1) //////////////////////////////////////////////////////////////////////////////// // I2C pin definition diff --git a/variants/heltec_rc32/HeltecRC32Board.cpp b/variants/heltec_rc32/HeltecRC32Board.cpp new file mode 100644 index 000000000..59920d238 --- /dev/null +++ b/variants/heltec_rc32/HeltecRC32Board.cpp @@ -0,0 +1,68 @@ +#include "HeltecRC32Board.h" + +void HeltecRC32Board::begin() { + ESP32Board::begin(); + + pinMode(PIN_ADC_CTRL, OUTPUT); + digitalWrite(PIN_ADC_CTRL, !ADC_CTRL_ENABLED); + +#ifdef SENSOR_RST_PIN + pinMode(SENSOR_RST_PIN, OUTPUT); + digitalWrite(SENSOR_RST_PIN, HIGH); +#endif + +#ifdef LED_POWER + pinMode(LED_POWER, OUTPUT); + digitalWrite(LED_POWER, LOW); +#endif + + periph_power.begin(); + periph_power.claim(); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1L << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; + } + + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} + +void HeltecRC32Board::powerOff() { + enterDeepSleep(0); +} + +void HeltecRC32Board::onBeforeTransmit() { + digitalWrite(P_LORA_TX_LED, HIGH); +} + +void HeltecRC32Board::onAfterTransmit() { + digitalWrite(P_LORA_TX_LED, LOW); +} + +uint16_t HeltecRC32Board::getBattMilliVolts() { + analogReadResolution(12); + analogSetAttenuation(ADC_2_5db); + digitalWrite(PIN_ADC_CTRL, ADC_CTRL_ENABLED); + delay(10); + uint32_t raw = 0; + for (int i = 0; i < 8; i++) { + raw += analogReadMilliVolts(PIN_VBAT_READ); + } + raw = raw / 8; + digitalWrite(PIN_ADC_CTRL, !ADC_CTRL_ENABLED); + + return (adc_mult * raw); +} + +bool HeltecRC32Board::setAdcMultiplier(float multiplier) { + adc_mult = multiplier == 0.0f ? ADC_MULTIPLIER : multiplier; + return true; +} + +const char* HeltecRC32Board::getManufacturerName() const { + return "Heltec RC32"; +} diff --git a/variants/heltec_rc32/HeltecRC32Board.h b/variants/heltec_rc32/HeltecRC32Board.h new file mode 100644 index 000000000..5b2093c8f --- /dev/null +++ b/variants/heltec_rc32/HeltecRC32Board.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +#ifndef ADC_MULTIPLIER + #define ADC_MULTIPLIER 4.9f +#endif + +class HeltecRC32Board : public ESP32Board { +protected: + float adc_mult = ADC_MULTIPLIER; + +public: + RefCountedDigitalPin periph_power; + + HeltecRC32Board() : periph_power(SENSOR_POWER_CTRL_PIN, SENSOR_POWER_ON){} + + void begin(); + void onBeforeTransmit() override; + void onAfterTransmit() override; + void powerOff() override; + uint16_t getBattMilliVolts() override; + bool setAdcMultiplier(float multiplier) override; + float getAdcMultiplier() const override { return adc_mult; } + const char* getManufacturerName() const override; +}; diff --git a/variants/heltec_rc32/HeltecRC32RotaryInput.cpp b/variants/heltec_rc32/HeltecRC32RotaryInput.cpp new file mode 100644 index 000000000..e8b282ff5 --- /dev/null +++ b/variants/heltec_rc32/HeltecRC32RotaryInput.cpp @@ -0,0 +1,122 @@ +#include "HeltecRC32RotaryInput.h" + +#include + +namespace { +constexpr uint8_t TCA6408_ADDR = 0x20; +constexpr uint8_t TCA6408_INPUT_REG = 0x00; +constexpr uint8_t TCA6408_POLARITY_REG = 0x02; +constexpr uint8_t TCA6408_CONFIG_REG = 0x03; +constexpr uint8_t TCA6408_ROTARY_A_MASK = 0x01; +constexpr uint8_t TCA6408_ROTARY_B_MASK = 0x02; +constexpr uint8_t TCA6408_ROTARY_MASK = TCA6408_ROTARY_A_MASK | TCA6408_ROTARY_B_MASK; +constexpr uint32_t TCA6408_DEBOUNCE_MS = 5; +} + +bool HeltecRC32RotaryInput::begin() { + initialized = true; + ready = false; + input_state = TCA6408_ROTARY_MASK; + active_low_phase = false; + + if (periph_power && !power_claimed) { + periph_power->claim(); + power_claimed = true; + delay(12); + } + + if (!writeRegister(TCA6408_POLARITY_REG, 0x00) || !writeRegister(TCA6408_CONFIG_REG, 0xFF)) { + return false; + } + + uint8_t state = 0; + if (!readInput(state)) { + return false; + } + + input_state = state & TCA6408_ROTARY_MASK; + ready = true; + return true; +} + +RotaryInputEvent HeltecRC32RotaryInput::poll() { + if (!initialized) { + begin(); + return RotaryInputEvent::None; + } + + if (!ready) { + return RotaryInputEvent::None; + } + + uint8_t new_state = 0; + if (!readInput(new_state)) { + return RotaryInputEvent::None; + } + + new_state &= TCA6408_ROTARY_MASK; + RotaryInputEvent event = handleTransition(new_state); + input_state = new_state; + return event; +} + +bool HeltecRC32RotaryInput::writeRegister(uint8_t reg, uint8_t value) { + Wire.beginTransmission(TCA6408_ADDR); + Wire.write(reg); + Wire.write(value); + return Wire.endTransmission() == 0; +} + +bool HeltecRC32RotaryInput::readInput(uint8_t& value) { + Wire.beginTransmission(TCA6408_ADDR); + Wire.write(TCA6408_INPUT_REG); + if (Wire.endTransmission(false) != 0) { + return false; + } + if (Wire.requestFrom(TCA6408_ADDR, static_cast(1)) != 1) { + return false; + } + + value = Wire.read(); + return true; +} + +RotaryInputEvent HeltecRC32RotaryInput::handleTransition(uint8_t newState) { + uint8_t changed = (input_state ^ newState) & TCA6408_ROTARY_MASK; + RotaryInputEvent event = RotaryInputEvent::None; + bool a_low = (newState & TCA6408_ROTARY_A_MASK) == 0; + bool b_low = (newState & TCA6408_ROTARY_B_MASK) == 0; + + if (!a_low && !b_low) { + active_low_phase = false; + } + + if (!active_low_phase && (changed & TCA6408_ROTARY_A_MASK) && a_low && !b_low) { + event = RotaryInputEvent::Prev; + active_low_phase = true; + } else if (!active_low_phase && (changed & TCA6408_ROTARY_B_MASK) && b_low && !a_low) { + event = RotaryInputEvent::Next; + active_low_phase = true; + } + + if (event == RotaryInputEvent::None && !active_low_phase && (changed & TCA6408_ROTARY_A_MASK)) { + bool a_rising = (newState & TCA6408_ROTARY_A_MASK) != 0; + if (a_rising && b_low) { + event = RotaryInputEvent::Prev; + } + } + + if (event == RotaryInputEvent::None && !active_low_phase && (changed & TCA6408_ROTARY_B_MASK)) { + bool b_rising = (newState & TCA6408_ROTARY_B_MASK) != 0; + if (b_rising && a_low) { + event = RotaryInputEvent::Next; + } + } + + if (event == RotaryInputEvent::None || (millis() - last_event_ms) < TCA6408_DEBOUNCE_MS) { + return RotaryInputEvent::None; + } + + last_event_ms = millis(); + return event; +} diff --git a/variants/heltec_rc32/HeltecRC32RotaryInput.h b/variants/heltec_rc32/HeltecRC32RotaryInput.h new file mode 100644 index 000000000..36557dca3 --- /dev/null +++ b/variants/heltec_rc32/HeltecRC32RotaryInput.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +class HeltecRC32RotaryInput : public RotaryInput { +public: + explicit HeltecRC32RotaryInput(RefCountedDigitalPin* periphPower = nullptr) : periph_power(periphPower) { } + + bool begin() override; + RotaryInputEvent poll() override; + bool isReady() const override { return ready; } + +private: + bool writeRegister(uint8_t reg, uint8_t value); + bool readInput(uint8_t& value); + RotaryInputEvent handleTransition(uint8_t newState); + + uint8_t input_state = 0x03; + uint32_t last_event_ms = 0; + bool ready = false; + bool initialized = false; + bool power_claimed = false; + bool active_low_phase = false; + RefCountedDigitalPin* periph_power = nullptr; +}; diff --git a/variants/heltec_rc32/pins_arduino.h b/variants/heltec_rc32/pins_arduino.h new file mode 100644 index 000000000..8ea3a0c15 --- /dev/null +++ b/variants/heltec_rc32/pins_arduino.h @@ -0,0 +1,60 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 21; +static const uint8_t SCL = 18; + +static const uint8_t SS = 10; +static const uint8_t MOSI = 12; +static const uint8_t MISO = 13; +static const uint8_t SCK = 11; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +static const uint8_t RST_LoRa = 9; +static const uint8_t BUSY_LoRa = 1; +static const uint8_t DIO1_LoRa = 14; + +#endif diff --git a/variants/heltec_rc32/platformio.ini b/variants/heltec_rc32/platformio.ini new file mode 100644 index 000000000..354004f07 --- /dev/null +++ b/variants/heltec_rc32/platformio.ini @@ -0,0 +1,353 @@ +[Heltec_RC32] +extends = esp32_base +board = heltec-rc32 +board_build.partitions = default_16MB.csv +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/heltec_rc32 + -I src/helpers/ui + -D HELTEC_RC32 + -D USE_SX1262 + -D ESP32_CPU_FREQ=160 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D P_LORA_DIO_1=14 + -D P_LORA_NSS=10 + -D P_LORA_RESET=9 + -D P_LORA_BUSY=1 + -D P_LORA_SCLK=11 + -D P_LORA_MISO=13 + -D P_LORA_MOSI=12 + -D LORA_TX_POWER=22 + -D PIN_USER_BTN=0 + -D PIN_BOARD_SDA=21 + -D PIN_BOARD_SCL=18 + -D SENSOR_POWER_CTRL_PIN=46 + -D SENSOR_POWER_ON=HIGH + -D SENSOR_RST_PIN=2 + -D P_LORA_TX_LED=47 + -D PIN_BUZZER=48 + -D PIN_TFT_SCL=17 + -D PIN_TFT_SDA=38 + -D PIN_TFT_CS=39 + -D PIN_TFT_DC=16 + -D PIN_TFT_RST=4 + -D PIN_TFT_EN=6 + -D PIN_TFT_EN_ACTIVE=LOW + -D PIN_TFT_BL=5 + -D PIN_TFT_BL_ACTIVE=HIGH + -D SPI_FREQUENCY=8000000 + -D PIN_GPS_TX=44 + -D PIN_GPS_RX=43 + -D PIN_GPS_EN=45 + -D PIN_GPS_EN_ACTIVE=HIGH + -D PIN_GPS_RESET=40 + -D PIN_GPS_RESET_ACTIVE=LOW + -D PIN_GPS_PPS=41 + -D GPS_BAUD_RATE=9600 + -D ENV_INCLUDE_GPS=1 + -D PIN_ADC_CTRL=15 + -D PIN_VBAT_READ=7 + -D ADC_CTRL_ENABLED=HIGH + -D ADC_MULTIPLIER=4.9 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D SX126X_REGISTER_PATCH=1 ; Patch register 0x8B5 for improved RX +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/heltec_rc32> + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + +[Heltec_RC32_with_display] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D HELTEC_RC32_WITH_DISPLAY + -D DISPLAY_CLASS=NV3001BDisplay +build_src_filter = ${Heltec_RC32.build_src_filter} + + + + +lib_deps = + ${Heltec_RC32.lib_deps} + +[env:heltec_rc32_without_display_repeater] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D ADVERT_NAME='"Heltec RC32 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<../examples/simple_repeater> +lib_deps = + ${Heltec_RC32.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_rc32_without_display_repeater_bridge_espnow] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 +build_src_filter = ${Heltec_RC32.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_RC32.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_without_display_room_server] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D ADVERT_NAME='"Heltec RC32 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${Heltec_RC32.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${Heltec_RC32.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_without_display_companion_radio_usb] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE +build_src_filter = ${Heltec_RC32.build_src_filter} + + + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_without_display_companion_radio_ble] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${Heltec_RC32.build_src_filter} + + + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_without_display_companion_radio_wifi] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${Heltec_RC32.build_src_filter} + + + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_without_display_sensor] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D ADVERT_NAME='"Heltec RC32 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ENV_PIN_SDA=21 + -D ENV_PIN_SCL=18 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<../examples/simple_sensor> +lib_deps = + ${Heltec_RC32.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_repeater] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -D ADVERT_NAME='"Heltec RC32 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + +<../examples/simple_repeater> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_rc32_repeater_bridge_espnow] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_room_server] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -D ADVERT_NAME='"Heltec RC32 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_terminal_chat] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${Heltec_RC32.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_rc32_companion_radio_usb] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -I examples/companion_radio/ui-new + -D UI_HAS_ROTARY_INPUT + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_companion_radio_ble] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -I examples/companion_radio/ui-new + -D UI_HAS_ROTARY_INPUT + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_companion_radio_wifi] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -I examples/companion_radio/ui-new + -D UI_HAS_ROTARY_INPUT + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_sensor] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -D ADVERT_NAME='"Heltec RC32 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ENV_PIN_SDA=21 + -D ENV_PIN_SCL=18 +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + +<../examples/simple_sensor> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_kiss_modem] +extends = Heltec_RC32 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/heltec_rc32/target.cpp b/variants/heltec_rc32/target.cpp new file mode 100644 index 000000000..cc7e4deb7 --- /dev/null +++ b/variants/heltec_rc32/target.cpp @@ -0,0 +1,52 @@ +#include +#include "target.h" +#if defined(UI_HAS_ROTARY_INPUT) +#include "HeltecRC32RotaryInput.h" +#endif + +HeltecRC32Board board; + +#if defined(P_LORA_SCLK) + static SPIClass spi(FSPI); + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#else + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#endif + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + #include + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock, PIN_GPS_RESET, PIN_GPS_EN); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#if defined(UI_HAS_ROTARY_INPUT) + static HeltecRC32RotaryInput rotaryInputImpl(&board.periph_power); + RotaryInput& rotary_input = rotaryInputImpl; +#endif +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + +#if defined(P_LORA_SCLK) + return radio.std_init(&spi); +#else + return radio.std_init(); +#endif +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} diff --git a/variants/heltec_rc32/target.h b/variants/heltec_rc32/target.h new file mode 100644 index 000000000..04cb6d94a --- /dev/null +++ b/variants/heltec_rc32/target.h @@ -0,0 +1,37 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include + +#ifdef DISPLAY_CLASS +#include +#if defined(UI_HAS_ROTARY_INPUT) +#include +#endif +#ifdef HELTEC_RC32_WITH_DISPLAY +#include +#else +#include +#endif +#endif + +extern HeltecRC32Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#if defined(UI_HAS_ROTARY_INPUT) + extern RotaryInput& rotary_input; +#endif +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/heltec_rc32/variant.h b/variants/heltec_rc32/variant.h new file mode 100644 index 000000000..5f7b8d819 --- /dev/null +++ b/variants/heltec_rc32/variant.h @@ -0,0 +1,29 @@ +#ifndef _VARIANT_HELTEC_RC32_ +#define _VARIANT_HELTEC_RC32_ + +#define BUTTON_PIN 0 + +#define I2C_SCL 18 +#define I2C_SDA 21 +#define SENSOR_INT_PIN 42 +#define PERIPHERAL_WARMUP_MS 100 + +#define LORA_SCK 11 +#define LORA_MISO 13 +#define LORA_MOSI 12 +#define LORA_CS 10 +#define LORA_DIO0 RADIOLIB_NC +#define LORA_DIO1 14 +#define LORA_RESET 9 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY 1 +#define SX126X_RESET LORA_RESET + +#define BATTERY_PIN 7 +#define ADC_CHANNEL ADC_CHANNEL_6 +#define ADC_CTRL 15 +#define ADC_ATTENUATION ADC_ATTEN_DB_2_5 + +#endif diff --git a/variants/heltec_t096/T096Board.cpp b/variants/heltec_t096/T096Board.cpp index 5cff3c37f..78af529d6 100644 --- a/variants/heltec_t096/T096Board.cpp +++ b/variants/heltec_t096/T096Board.cpp @@ -112,13 +112,9 @@ void T096Board::variant_shutdown() { } void T096Board::powerOff() { -#if ENV_INCLUDE_GPS == 1 - pinMode(PIN_GPS_EN, OUTPUT); - digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE); -#endif - loRaFEMControl.setSleepModeEnable(); - variant_shutdown(); - sd_power_system_off(); + loRaFEMControl.setSleepModeEnable(); + nrf_gpio_cfg_default(PIN_GPS_EN); // 363uA down to 39uA + NRF52Board::powerOff(); } const char* T096Board::getManufacturerName() const { @@ -141,4 +137,4 @@ bool T096Board::canControlLoRaFemLna() const { bool T096Board::isLoRaFemLnaEnabled() const { return loRaFEMControl.isLNAEnabled(); -} \ No newline at end of file +} diff --git a/variants/heltec_t096/platformio.ini b/variants/heltec_t096/platformio.ini index e820bf58d..02e883287 100644 --- a/variants/heltec_t096/platformio.ini +++ b/variants/heltec_t096/platformio.ini @@ -51,7 +51,7 @@ build_src_filter = ${nrf52_base.build_src_filter} lib_deps = ${nrf52_base.lib_deps} ${sensor_base.lib_deps} - adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + bodmer/TFT_eSPI @ ^2.4.31 debug_tool = jlink upload_protocol = nrfutil @@ -152,6 +152,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/heltec_t1/T1Board.cpp b/variants/heltec_t1/T1Board.cpp index 490f86787..1ef5b766b 100644 --- a/variants/heltec_t1/T1Board.cpp +++ b/variants/heltec_t1/T1Board.cpp @@ -81,34 +81,6 @@ uint16_t T1Board::getBattMilliVolts() { } void T1Board::variant_shutdown() { - nrf_gpio_cfg_default(PIN_TFT_CS); - nrf_gpio_cfg_default(PIN_TFT_DC); - nrf_gpio_cfg_default(PIN_TFT_SDA); - nrf_gpio_cfg_default(PIN_TFT_SCL); - nrf_gpio_cfg_default(PIN_TFT_RST); - nrf_gpio_cfg_default(PIN_TFT_LEDA_CTL); - nrf_gpio_cfg_default(PIN_TFT_VDD_CTL); - - nrf_gpio_cfg_default(PIN_WIRE_SDA); - nrf_gpio_cfg_default(PIN_WIRE_SCL); - - nrf_gpio_cfg_default(LORA_CS); - nrf_gpio_cfg_default(SX126X_DIO1); - nrf_gpio_cfg_default(SX126X_BUSY); - nrf_gpio_cfg_default(SX126X_RESET); - nrf_gpio_cfg_default(PIN_SPI_MISO); - nrf_gpio_cfg_default(PIN_SPI_MOSI); - nrf_gpio_cfg_default(PIN_SPI_SCK); - - nrf_gpio_cfg_default(PIN_SPI1_MOSI); - nrf_gpio_cfg_default(PIN_SPI1_SCK); - - nrf_gpio_cfg_default(PIN_GPS_RESET); - nrf_gpio_cfg_default(PIN_GPS_EN); - nrf_gpio_cfg_default(PIN_GPS_PPS); - nrf_gpio_cfg_default(PIN_GPS_RX); - nrf_gpio_cfg_default(PIN_GPS_TX); - nrf_gpio_cfg_default(PIN_BUZZER_VOLTAGE_MULTIPLIER_1); nrf_gpio_cfg_default(PIN_BUZZER_VOLTAGE_MULTIPLIER_2); @@ -127,7 +99,7 @@ void T1Board::variant_shutdown() { void T1Board::powerOff() { variant_shutdown(); - sd_power_system_off(); + NRF52Board::powerOff(); } const char* T1Board::getManufacturerName() const { diff --git a/variants/heltec_t1/platformio.ini b/variants/heltec_t1/platformio.ini index d5c3caf18..28ed67edb 100644 --- a/variants/heltec_t1/platformio.ini +++ b/variants/heltec_t1/platformio.ini @@ -23,7 +23,7 @@ build_src_filter = ${nrf52_base.build_src_filter} lib_deps = ${nrf52_base.lib_deps} ${sensor_base.lib_deps} - adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + bodmer/TFT_eSPI @ ^2.4.31 debug_tool = jlink upload_protocol = nrfutil @@ -90,6 +90,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Heltec_t1.build_src_filter} diff --git a/variants/heltec_t114/T114Board.cpp b/variants/heltec_t114/T114Board.cpp index c03d39afb..51db72fc8 100644 --- a/variants/heltec_t114/T114Board.cpp +++ b/variants/heltec_t114/T114Board.cpp @@ -33,7 +33,7 @@ void T114Board::initiateShutdown(uint8_t reason) { #endif // NRF52_POWER_MANAGEMENT void T114Board::begin() { - NRF52Board::begin(); + NRF52BoardDCDC::begin(); pinMode(PIN_VBAT_READ, INPUT); diff --git a/variants/heltec_t114/T114Board.h b/variants/heltec_t114/T114Board.h index f27dc291d..bd76d3de4 100644 --- a/variants/heltec_t114/T114Board.h +++ b/variants/heltec_t114/T114Board.h @@ -50,10 +50,7 @@ public: #ifdef LED_PIN digitalWrite(LED_PIN, HIGH); #endif -#if ENV_INCLUDE_GPS == 1 - pinMode(GPS_EN, OUTPUT); - digitalWrite(GPS_EN, LOW); -#endif - sd_power_system_off(); + + NRF52Board::powerOff(); } }; diff --git a/variants/heltec_t114/platformio.ini b/variants/heltec_t114/platformio.ini index 135babb1a..48fb4b0f5 100644 --- a/variants/heltec_t114/platformio.ini +++ b/variants/heltec_t114/platformio.ini @@ -109,6 +109,7 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Heltec_t114.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> @@ -126,8 +127,10 @@ build_flags = -D DISPLAY_CLASS=NullDisplayDriver -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE build_src_filter = ${Heltec_t114.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = @@ -236,6 +239,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/heltec_t114/target.cpp b/variants/heltec_t114/target.cpp index cb8f75db5..90a902f80 100644 --- a/variants/heltec_t114/target.cpp +++ b/variants/heltec_t114/target.cpp @@ -22,7 +22,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include -MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/heltec_t190/HeltecT190Board.cpp b/variants/heltec_t190/HeltecT190Board.cpp index 4f35be400..0a16b52b8 100644 --- a/variants/heltec_t190/HeltecT190Board.cpp +++ b/variants/heltec_t190/HeltecT190Board.cpp @@ -20,33 +20,6 @@ void HeltecT190Board::begin() { } } - void HeltecT190Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void HeltecT190Board::powerOff() { - enterDeepSleep(0); - } - uint16_t HeltecT190Board::getBattMilliVolts() { analogReadResolution(10); digitalWrite(PIN_ADC_CTRL, HIGH); diff --git a/variants/heltec_t190/HeltecT190Board.h b/variants/heltec_t190/HeltecT190Board.h index bc38c1e09..557c070ec 100644 --- a/variants/heltec_t190/HeltecT190Board.h +++ b/variants/heltec_t190/HeltecT190Board.h @@ -3,7 +3,6 @@ #include #include #include -#include class HeltecT190Board : public ESP32Board { @@ -13,8 +12,6 @@ public: HeltecT190Board() : periph_power(PIN_VEXT_EN,PIN_VEXT_EN_ACTIVE) { } void begin(); - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/heltec_t190/platformio.ini b/variants/heltec_t190/platformio.ini index 147e3df43..f24291e76 100644 --- a/variants/heltec_t190/platformio.ini +++ b/variants/heltec_t190/platformio.ini @@ -74,6 +74,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${Heltec_T190_base.build_src_filter} + +<../examples/companion_radio/*.cpp> @@ -112,8 +113,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D MESH_PACKET_LOGGING=1 @@ -128,7 +127,7 @@ lib_deps = ${Heltec_T190_base.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -206,8 +205,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D MESH_PACKET_LOGGING=1 @@ -222,7 +219,7 @@ lib_deps = ${Heltec_T190_base.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.cpp b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp new file mode 100644 index 000000000..04213bda8 --- /dev/null +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp @@ -0,0 +1,103 @@ +#include "HeltecTowerV2Board.h" + +#include +#include + +extern void variant_shutdown(); + +#ifdef NRF52_POWER_MANAGEMENT +const PowerMgtConfig power_config = { + .lpcomp_ain_channel = PWRMGT_LPCOMP_AIN, + .lpcomp_refsel = PWRMGT_LPCOMP_REFSEL, + .voltage_bootlock = PWRMGT_VOLTAGE_BOOTLOCK +}; + +void HeltecTowerV2Board::initiateShutdown(uint8_t reason) { + pinMode(PIN_GPS_EN, OUTPUT); + digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE); + pinMode(PIN_GPS_STANDBY, OUTPUT); + digitalWrite(PIN_GPS_STANDBY, LOW); + pinMode(PIN_GPS_RESET, OUTPUT); + digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); + loRaFEMControl.setSleepModeEnable(); + + bool enable_lpcomp = (reason == SHUTDOWN_REASON_LOW_VOLTAGE || + reason == SHUTDOWN_REASON_BOOT_PROTECT); + pinMode(PIN_BAT_CTL, OUTPUT); + digitalWrite(PIN_BAT_CTL, enable_lpcomp ? HIGH : LOW); + + if (enable_lpcomp) { + configureVoltageWake(power_config.lpcomp_ain_channel, power_config.lpcomp_refsel); + } + + variant_shutdown(); + enterSystemOff(reason); +} +#endif + +void HeltecTowerV2Board::begin() { + NRF52Board::begin(); + + pinMode(P_LORA_TX_LED, OUTPUT); + digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); + + pinMode(PIN_BAT_CTL, OUTPUT); + digitalWrite(PIN_BAT_CTL, LOW); + +#ifdef NRF52_POWER_MANAGEMENT + checkBootVoltage(&power_config); +#endif + + Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); + Wire.begin(); + + pinMode(PIN_GPS_EN, OUTPUT); + digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE); + pinMode(PIN_GPS_RESET, OUTPUT); + digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); + pinMode(PIN_GPS_STANDBY, OUTPUT); + digitalWrite(PIN_GPS_STANDBY, HIGH); + loRaFEMControl.init(); +} + +void HeltecTowerV2Board::onBeforeTransmit() { + digitalWrite(P_LORA_TX_LED, LED_STATE_ON); + loRaFEMControl.setTxModeEnable(); +} + +void HeltecTowerV2Board::onAfterTransmit() { + digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); + loRaFEMControl.setRxModeEnable(); +} + +uint16_t HeltecTowerV2Board::getBattMilliVolts() { + analogReadResolution(12); + analogReference(VBAT_AR_INTERNAL); + pinMode(PIN_VBAT_READ, INPUT); + pinMode(PIN_BAT_CTL, OUTPUT); + digitalWrite(PIN_BAT_CTL, HIGH); + + delay(10); + int adcvalue = analogRead(PIN_VBAT_READ); + digitalWrite(PIN_BAT_CTL, LOW); + + return (uint16_t)((float)adcvalue * MV_LSB * ADC_MULTIPLIER); +} + +const char* HeltecTowerV2Board::getManufacturerName() const { + return "Heltec Tower V2"; +} + +void HeltecTowerV2Board::powerOff() { + pinMode(PIN_GPS_EN, OUTPUT); + digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE); + pinMode(PIN_GPS_STANDBY, OUTPUT); + digitalWrite(PIN_GPS_STANDBY, LOW); + pinMode(PIN_GPS_RESET, OUTPUT); + digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); + loRaFEMControl.setSleepModeEnable(); + pinMode(PIN_BAT_CTL, OUTPUT); + digitalWrite(PIN_BAT_CTL, LOW); + variant_shutdown(); + sd_power_system_off(); +} diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.h b/variants/heltec_tower_v2/HeltecTowerV2Board.h new file mode 100644 index 000000000..6912acdec --- /dev/null +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include "LoRaFEMControl.h" + +class HeltecTowerV2Board : public NRF52BoardDCDC { +protected: +#ifdef NRF52_POWER_MANAGEMENT + void initiateShutdown(uint8_t reason) override; +#endif + +public: + LoRaFEMControl loRaFEMControl; + + HeltecTowerV2Board() : NRF52Board("TOWER_V2_OTA") {} + void begin(); + void onBeforeTransmit() override; + void onAfterTransmit() override; + uint16_t getBattMilliVolts() override; + const char* getManufacturerName() const override; + void powerOff() override; +}; diff --git a/variants/heltec_tower_v2/LoRaFEMControl.cpp b/variants/heltec_tower_v2/LoRaFEMControl.cpp new file mode 100644 index 000000000..d87d1849e --- /dev/null +++ b/variants/heltec_tower_v2/LoRaFEMControl.cpp @@ -0,0 +1,40 @@ +#include "LoRaFEMControl.h" + +#include +#include "variant.h" + +static void enableFEMPower() { + bool wasOff = digitalRead(LORA_KCT8103L_EN) != HIGH; + digitalWrite(LORA_KCT8103L_EN, HIGH); + if (wasOff) { + delay(5); + } +} + +void LoRaFEMControl::init() { + pinMode(LORA_KCT8103L_EN, OUTPUT); + digitalWrite(LORA_KCT8103L_EN, HIGH); + delay(1); + pinMode(LORA_KCT8103L_TX_RX, OUTPUT); + digitalWrite(LORA_KCT8103L_TX_RX, LOW); +} + +void LoRaFEMControl::setSleepModeEnable() { + pinMode(LORA_KCT8103L_EN, OUTPUT); + digitalWrite(LORA_KCT8103L_EN, LOW); +} + +void LoRaFEMControl::setTxModeEnable() { + enableFEMPower(); + digitalWrite(LORA_KCT8103L_TX_RX, HIGH); +} + +void LoRaFEMControl::setRxModeEnable() { + enableFEMPower(); + digitalWrite(LORA_KCT8103L_TX_RX, LOW); +} + +void LoRaFEMControl::setRxModeEnableWhenMCUSleep() { + enableFEMPower(); + digitalWrite(LORA_KCT8103L_TX_RX, LOW); +} diff --git a/variants/heltec_tower_v2/LoRaFEMControl.h b/variants/heltec_tower_v2/LoRaFEMControl.h new file mode 100644 index 000000000..e6c0ad2f1 --- /dev/null +++ b/variants/heltec_tower_v2/LoRaFEMControl.h @@ -0,0 +1,13 @@ +#pragma once + +class LoRaFEMControl { +public: + LoRaFEMControl() {} + virtual ~LoRaFEMControl() {} + + void init(); + void setSleepModeEnable(); + void setTxModeEnable(); + void setRxModeEnable(); + void setRxModeEnableWhenMCUSleep(); +}; diff --git a/variants/heltec_tower_v2/platformio.ini b/variants/heltec_tower_v2/platformio.ini new file mode 100644 index 000000000..207e0a508 --- /dev/null +++ b/variants/heltec_tower_v2/platformio.ini @@ -0,0 +1,107 @@ +[Heltec_tower_v2] +extends = nrf52_base +board = heltec_tower_v2 +board_build.ldscript = boards/nrf52840_s140_v6.ld +build_flags = ${nrf52_base.build_flags} + -D ENV_INCLUDE_GPS=1 + -I lib/nrf52/s140_nrf52_6.1.1_API/include + -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52 + -I variants/heltec_tower_v2 + -D HELTEC_TOWER_V2 + -D NRF52_POWER_MANAGEMENT + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=12 + -D MAX_LORA_TX_POWER=22 ; Max SX1262 output -> ~29dBm at antenna + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 +build_src_filter = ${nrf52_base.build_src_filter} + + + + + +<../variants/heltec_tower_v2> +lib_deps = + ${nrf52_base.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 +debug_tool = jlink +upload_protocol = nrfutil + +[env:Heltec_tower_v2_repeater] +extends = Heltec_tower_v2 +build_src_filter = ${Heltec_tower_v2.build_src_filter} + +<../examples/simple_repeater> +build_flags = + ${Heltec_tower_v2.build_flags} + -D ADVERT_NAME='"Heltec_Tower_V2 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + +[env:Heltec_tower_v2_room_server] +extends = Heltec_tower_v2 +build_src_filter = ${Heltec_tower_v2.build_src_filter} + +<../examples/simple_room_server> +build_flags = + ${Heltec_tower_v2.build_flags} + -D ADVERT_NAME='"Heltec_Tower_V2 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + +[env:Heltec_tower_v2_companion_radio_ble] +extends = Heltec_tower_v2 +board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld +board_upload.maximum_size = 712704 +build_flags = + ${Heltec_tower_v2.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 +; -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_tower_v2.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_tower_v2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:Heltec_tower_v2_companion_radio_usb] +extends = Heltec_tower_v2 +board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld +board_upload.maximum_size = 712704 +build_flags = + ${Heltec_tower_v2.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE +; -D BLE_PIN_CODE=123456 +; -D BLE_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_tower_v2.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_tower_v2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:Heltec_tower_v2_kiss_modem] +extends = Heltec_tower_v2 +build_src_filter = ${Heltec_tower_v2.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/heltec_tower_v2/target.cpp b/variants/heltec_tower_v2/target.cpp new file mode 100644 index 000000000..ec8e7a17d --- /dev/null +++ b/variants/heltec_tower_v2/target.cpp @@ -0,0 +1,64 @@ +#include "target.h" + +#include +#include +#include + +HeltecTowerV2Board board; + +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); + +WRAPPER_CLASS radio_driver(radio, board); + +VolatileRTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); +EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +TowerV2ExternalWatchdog external_watchdog; + +#ifdef DISPLAY_CLASS +DISPLAY_CLASS display; +MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + rtc_clock.begin(Wire); + return radio.std_init(&SPI); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} + +bool TowerV2ExternalWatchdog::begin() { + last_feed_watchdog = 0; + pinMode(EXTERNAL_WATCHDOG_WAKE_PIN, INPUT); + pinMode(EXTERNAL_WATCHDOG_DONE_PIN, OUTPUT); + delay(1); + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, LOW); + delay(1); + feed(); + return true; +} + +void TowerV2ExternalWatchdog::loop() { + if (millis() - last_feed_watchdog >= EXTERNAL_WATCHDOG_FEED_INTERVAL_MS) { + feed(); + } +} + +unsigned long TowerV2ExternalWatchdog::getIntervalMs() const { + unsigned long elapsed_ms = millis() - last_feed_watchdog; + if (elapsed_ms >= EXTERNAL_WATCHDOG_FEED_INTERVAL_MS) { + return 0; + } + return EXTERNAL_WATCHDOG_FEED_INTERVAL_MS - elapsed_ms; +} + +void TowerV2ExternalWatchdog::feed() { + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, HIGH); + delay(1); + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, LOW); + last_feed_watchdog = millis(); +} diff --git a/variants/heltec_tower_v2/target.h b/variants/heltec_tower_v2/target.h new file mode 100644 index 000000000..0b9de40ca --- /dev/null +++ b/variants/heltec_tower_v2/target.h @@ -0,0 +1,39 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef DISPLAY_CLASS +#include +#include "helpers/ui/NullDisplayDriver.h" +#endif + +class TowerV2ExternalWatchdog : public ExternalWatchdogManager { +public: + TowerV2ExternalWatchdog() {} + bool begin() override; + void loop() override; + unsigned long getIntervalMs() const override; + void feed() override; +}; + +extern HeltecTowerV2Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; +extern TowerV2ExternalWatchdog external_watchdog; + +#ifdef DISPLAY_CLASS +extern DISPLAY_CLASS display; +extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/heltec_tower_v2/variant.cpp b/variants/heltec_tower_v2/variant.cpp new file mode 100644 index 000000000..cfb88c4a3 --- /dev/null +++ b/variants/heltec_tower_v2/variant.cpp @@ -0,0 +1,42 @@ +#include "variant.h" + +#include "Arduino.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[] = { + 0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, + 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47 +}; + +void initVariant() +{ + +} + +void variant_shutdown() +{ + nrf_gpio_cfg_default(PIN_GPS_EN); + nrf_gpio_cfg_default(PIN_GPS_PPS); + nrf_gpio_cfg_default(PIN_GPS_RESET); + nrf_gpio_cfg_default(PIN_GPS_STANDBY); + nrf_gpio_cfg_default(GPS_RX_PIN); + nrf_gpio_cfg_default(GPS_TX_PIN); + pinMode(LORA_KCT8103L_EN, OUTPUT); + digitalWrite(LORA_KCT8103L_EN, LOW); + nrf_gpio_cfg_default(LORA_KCT8103L_TX_RX); + nrf_gpio_cfg_default(RF_PA_DETECT_PIN); + nrf_gpio_cfg_default(SX126X_CS); + nrf_gpio_cfg_default(SX126X_DIO1); + nrf_gpio_cfg_default(SX126X_BUSY); + nrf_gpio_cfg_default(SX126X_RESET); + nrf_gpio_cfg_default(PIN_SPI_MISO); + nrf_gpio_cfg_default(PIN_SPI_MOSI); + nrf_gpio_cfg_default(PIN_SPI_SCK); + nrf_gpio_cfg_default(PIN_LED); + detachInterrupt(PIN_GPS_PPS); + detachInterrupt(PIN_BUTTON1); +} diff --git a/variants/heltec_tower_v2/variant.h b/variants/heltec_tower_v2/variant.h new file mode 100644 index 000000000..14116c6f3 --- /dev/null +++ b/variants/heltec_tower_v2/variant.h @@ -0,0 +1,108 @@ +#pragma once + +#include "WVariant.h" + +#define USE_LFXO +#define VARIANT_MCK (64000000ul) + +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (1) +#define NUM_ANALOG_OUTPUTS (0) + +#define WIRE_INTERFACES_COUNT (1) +#define PIN_WIRE_SDA (0 + 30) +#define PIN_WIRE_SCL (0 + 5) +#define PIN_BOARD_SDA PIN_WIRE_SDA +#define PIN_BOARD_SCL PIN_WIRE_SCL + +#define SPI_INTERFACES_COUNT (1) +#define PIN_SPI_MISO (0 + 23) +#define PIN_SPI_MOSI (0 + 22) +#define PIN_SPI_SCK (0 + 19) +#define PIN_SPI_NSS LORA_CS + +#define LED_BUILTIN (32 + 15) +#define PIN_LED LED_BUILTIN +#define LED_RED (-1) +#define LED_GREEN (-1) +#define LED_BLUE (-1) +#define LED_PIN (-1) +#define P_LORA_TX_LED LED_BUILTIN +#define LED_STATE_ON LOW + +#define PIN_BUTTON1 (32 + 10) +#define BUTTON_PIN PIN_BUTTON1 +#define PIN_USER_BTN BUTTON_PIN + +#define USE_SX1262 +#define SX126X_CS (0 + 24) +#define LORA_CS SX126X_CS +#define SX126X_DIO1 (0 + 20) +#define SX126X_BUSY (0 + 17) +#define SX126X_RESET (0 + 25) +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define P_LORA_NSS LORA_CS +#define P_LORA_DIO_1 SX126X_DIO1 +#define P_LORA_BUSY SX126X_BUSY +#define P_LORA_RESET SX126X_RESET +#define P_LORA_MISO PIN_SPI_MISO +#define P_LORA_MOSI PIN_SPI_MOSI +#define P_LORA_SCLK PIN_SPI_SCK + +#define USE_KCT8103L_PA_ONLY +#define LORA_KCT8103L_EN (0 + 15) +#define LORA_KCT8103L_TX_RX (0 + 16) +#define LORA_PA_POWER LORA_KCT8103L_EN +#define RF_PA_DETECT_PIN (0 + 13) +#define RF_PA_HIGH_POWER_VALUE HIGH + +#define GPS_L76K +#define GPS_RESET_MODE LOW +#define PIN_GPS_RESET (32 + 6) +#define PIN_GPS_RESET_ACTIVE GPS_RESET_MODE +#define PIN_GPS_EN (0 + 7) +#define PIN_GPS_EN_ACTIVE LOW +#define GPS_EN_ACTIVE PIN_GPS_EN_ACTIVE +#define PIN_GPS_STANDBY (32 + 2) +#define PIN_GPS_PPS (32 + 4) +#define GPS_BAUD_RATE 9600 + +// Upstream names are from the GPS perspective. MeshCore's PIN_GPS_TX is the +// CPU RX pin because EnvironmentSensorManager passes it as Serial1 RX. +#define GPS_TX_PIN (32 + 7) +#define GPS_RX_PIN (32 + 5) +#define PIN_GPS_TX GPS_RX_PIN +#define PIN_GPS_RX GPS_TX_PIN + +#define PIN_SERIAL1_RX PIN_GPS_TX +#define PIN_SERIAL1_TX PIN_GPS_RX +#define PIN_SERIAL2_RX (-1) +#define PIN_SERIAL2_TX (-1) + +#define HAS_EXTERNAL_WATCHDOG +#define EXTERNAL_WATCHDOG_DONE_PIN (0 + 9) +#define EXTERNAL_WATCHDOG_WAKE_PIN (0 + 10) +#define EXTERNAL_WATCHDOG_FEED_INTERVAL_MS (8 * 60 * 1000) + +#define SERIAL_PRINT_PORT 0 + +#define PIN_BAT_CTL (0 + 21) +#define ADC_CTRL PIN_BAT_CTL +#define ADC_CTRL_ENABLED HIGH +#define BATTERY_PIN (0 + 4) +#define PIN_VBAT_READ BATTERY_PIN +#define ADC_RESOLUTION 14 +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#define AREF_VOLTAGE 3.0 +#define VBAT_AR_INTERNAL AR_INTERNAL_3_0 +#define ADC_MULTIPLIER (4.916F) +#define MV_LSB (3000.0F / 4096.0F) + +#define NRF52_POWER_MANAGEMENT +#define PWRMGT_VOLTAGE_BOOTLOCK 3100 +#define PWRMGT_LPCOMP_AIN 2 +#define PWRMGT_LPCOMP_REFSEL 1 diff --git a/variants/heltec_tracker/platformio.ini b/variants/heltec_tracker/platformio.ini index 69293d707..75d1cadf3 100644 --- a/variants/heltec_tracker/platformio.ini +++ b/variants/heltec_tracker/platformio.ini @@ -47,7 +47,7 @@ build_src_filter = ${esp32_base.build_src_filter} lib_deps = ${esp32_base.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 - adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + bodmer/TFT_eSPI @ ^2.4.31 [env:Heltec_Wireless_Tracker_companion_radio_usb] extends = Heltec_tracker_base @@ -59,6 +59,7 @@ build_flags = -D DISPLAY_CLASS=ST7735Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D BLE_PIN_CODE=123456 ; HWT will use display for pin ; -D OFFLINE_QUEUE_SIZE=256 ; -D BLE_DEBUG_LOGGING=1 diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index f182c905e..1b7396ddf 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -35,33 +35,17 @@ void HeltecTrackerV2Board::begin() { loRaFEMControl.setRxModeEnable(); } - void HeltecTrackerV2Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + void HeltecTrackerV2Board::powerOff() { + // Turn off PA. Guarded because this board file is also compiled for the + // heltec_tracker_v1_1 envs, which do not define P_LORA_PA_POWER (it is set + // only in variants/heltec_tracker_v2/platformio.ini). Same guard idiom + // LoRaFEMControl.cpp already uses for this macro. +#if defined(P_LORA_PA_POWER) + digitalWrite(P_LORA_PA_POWER, LOW); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); +#endif - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - loRaFEMControl.setRxModeEnableWhenMCUSleep();//It also needs to be enabled in receive mode - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void HeltecTrackerV2Board::powerOff() { - enterDeepSleep(0); + ESP32Board::powerOff(); } uint16_t HeltecTrackerV2Board::getBattMilliVolts() { @@ -80,7 +64,14 @@ void HeltecTrackerV2Board::begin() { } const char* HeltecTrackerV2Board::getManufacturerName() const { + // The v1.1 environment reuses this V2 board implementation (same variant + // dir), so report the correct identity per build flag — this string feeds + // WebConfig and MQTT status/board metadata. +#ifdef HELTEC_TRACKER_V1_1 + return "Heltec Tracker V1.1"; +#else return "Heltec Tracker V2"; +#endif } bool HeltecTrackerV2Board::setLoRaFemLnaEnabled(bool enable) { diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h index ccbecc7ab..2bd6a0254 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h @@ -3,7 +3,6 @@ #include #include #include -#include #include "LoRaFEMControl.h" class HeltecTrackerV2Board : public ESP32Board { @@ -17,7 +16,6 @@ public: void begin(); void onBeforeTransmit(void) override; void onAfterTransmit(void) override; - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index 5e1d2d454..46aeb1a33 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -56,7 +56,7 @@ build_src_filter = ${esp32_base.build_src_filter} lib_deps = ${esp32_base.lib_deps} ${sensor_base.lib_deps} - adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + bodmer/TFT_eSPI @ ^2.4.31 [Heltec_tracker_v1_1] extends = Heltec_tracker_v2 @@ -185,7 +185,8 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 +; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). + -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y @@ -200,7 +201,7 @@ lib_deps = ${Heltec_tracker_v1_1.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -221,7 +222,8 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 +; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). + -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y @@ -236,7 +238,7 @@ lib_deps = ${Heltec_tracker_v2.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -257,8 +259,9 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 +; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). + -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y @@ -273,7 +276,7 @@ lib_deps = ${Heltec_tracker_v1_1.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -294,8 +297,9 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 +; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). + -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y @@ -310,7 +314,7 @@ lib_deps = ${Heltec_tracker_v2.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -337,6 +341,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=ST7735Display + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Heltec_tracker_v2.build_src_filter} diff --git a/variants/heltec_v2/HeltecV2Board.h b/variants/heltec_v2/HeltecV2Board.h index fe800890b..9b08fe943 100644 --- a/variants/heltec_v2/HeltecV2Board.h +++ b/variants/heltec_v2/HeltecV2Board.h @@ -7,8 +7,6 @@ #define PIN_VBAT_READ 37 #define PIN_LED_BUILTIN 25 -#include - class HeltecV2Board : public ESP32Board { public: void begin() { @@ -26,29 +24,6 @@ public: } } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_0, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_0); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_0), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_0) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - uint16_t getBattMilliVolts() override { analogReadResolution(10); diff --git a/variants/heltec_v2/platformio.ini b/variants/heltec_v2/platformio.ini index ba4f86942..78561a14a 100644 --- a/variants/heltec_v2/platformio.ini +++ b/variants/heltec_v2/platformio.ini @@ -137,6 +137,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=160 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v2.build_src_filter} diff --git a/variants/heltec_v3/HeltecV3Board.h b/variants/heltec_v3/HeltecV3Board.h index ba22a7f2b..7e7abe313 100644 --- a/variants/heltec_v3/HeltecV3Board.h +++ b/variants/heltec_v3/HeltecV3Board.h @@ -17,8 +17,6 @@ #define PIN_ADC_CTRL_ACTIVE LOW #define PIN_ADC_CTRL_INACTIVE HIGH -#include - class HeltecV3Board : public ESP32Board { private: bool adc_active_state; @@ -52,33 +50,6 @@ public: } } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void powerOff() override { - enterDeepSleep(0); - } - uint16_t getBattMilliVolts() override { analogReadResolution(10); digitalWrite(PIN_ADC_CTRL, adc_active_state); diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index aa05a8573..9e9e0ea00 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -124,8 +124,8 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 +; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). + -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; Periodic 30s heap/pub-stats serial log — enable only for debugging (use `get mqtt.stats` on demand instead). @@ -156,12 +156,22 @@ lib_deps = ${Heltec_lora32_v3.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone 0neblock/SNMP_Agent paulstoffregen/Time@1.6.1 +; Emulator build (Wokwi): identical to the observer above but with the LoRa radio +; stubbed (SIM_BUILD -> SimRadio) and WiFi pre-seeded to the Wokwi network so it +; boots straight into WiFi/MQTT/CLI without hardware or flashing. See wokwi/. +[env:Heltec_v3_repeater_observer_mqtt_sim] +extends = env:Heltec_v3_repeater_observer_mqtt +build_flags = + ${env:Heltec_v3_repeater_observer_mqtt.build_flags} + -D SIM_BUILD=1 + -D SIM_WIFI_SSID='"Wokwi-GUEST"' + [env:Heltec_v3_room_server] extends = Heltec_lora32_v3 build_flags = @@ -198,9 +208,9 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 +; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). + -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -222,7 +232,7 @@ lib_deps = ${Heltec_lora32_v3.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -250,6 +260,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v3.build_src_filter} @@ -435,6 +446,7 @@ build_flags = ${Heltec_lora32_v3.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v3.build_src_filter} @@ -493,8 +505,8 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 +; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). + -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; Periodic 30s heap/pub-stats serial log — enable only for debugging (use `get mqtt.stats` on demand instead). @@ -522,7 +534,7 @@ lib_deps = ${Heltec_lora32_v3.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone 0neblock/SNMP_Agent @@ -543,9 +555,9 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 +; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). + -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; Keep default observer profile less verbose to reduce runtime contention. @@ -565,7 +577,7 @@ lib_deps = ${Heltec_lora32_v3.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/heltec_v3/target.cpp b/variants/heltec_v3/target.cpp index 9590acff2..db578d41f 100644 --- a/variants/heltec_v3/target.cpp +++ b/variants/heltec_v3/target.cpp @@ -3,6 +3,9 @@ HeltecV3Board board; +#ifdef SIM_BUILD + SimRadio radio_driver(board); // no-op radio for emulator builds +#else #if defined(P_LORA_SCLK) static SPIClass spi; RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); @@ -11,6 +14,7 @@ HeltecV3Board board; #endif WRAPPER_CLASS radio_driver(radio, board); +#endif ESP32RTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); @@ -31,8 +35,10 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); bool radio_init() { fallback_clock.begin(); rtc_clock.begin(Wire); - -#if defined(P_LORA_SCLK) + +#ifdef SIM_BUILD + return true; // no SPI radio to bring up +#elif defined(P_LORA_SCLK) return radio.std_init(&spi); #else return radio.std_init(); @@ -40,7 +46,12 @@ bool radio_init() { } mesh::LocalIdentity radio_new_identity() { +#ifdef SIM_BUILD + SimRNG rng; + return mesh::LocalIdentity(&rng); +#else RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng); // create new random identity +#endif } diff --git a/variants/heltec_v3/target.h b/variants/heltec_v3/target.h index 2944b3843..3ef7b2d70 100644 --- a/variants/heltec_v3/target.h +++ b/variants/heltec_v3/target.h @@ -1,10 +1,17 @@ #pragma once -#define RADIOLIB_STATIC_ONLY 1 -#include -#include -#include -#include +#ifdef SIM_BUILD + // Emulator build (e.g. Wokwi): no SX1262 hardware — use the no-op SimRadio so + // the firmware boots and runs WiFi/MQTT/CLI/display. See src/helpers/sim/. + #include + #include +#else + #define RADIOLIB_STATIC_ONLY 1 + #include + #include + #include + #include +#endif #include #include #include @@ -14,7 +21,11 @@ #endif extern HeltecV3Board board; -extern WRAPPER_CLASS radio_driver; +#ifdef SIM_BUILD + extern SimRadio radio_driver; +#else + extern WRAPPER_CLASS radio_driver; +#endif extern AutoDiscoverRTCClock rtc_clock; extern EnvironmentSensorManager sensors; diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index ac804cc29..3f13f41f6 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -32,33 +32,12 @@ void HeltecV4Board::begin() { loRaFEMControl.setRxModeEnable(); } - void HeltecV4Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + void HeltecV4Board::powerOff() { + // Turn off PA + digitalWrite(P_LORA_PA_POWER, LOW); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - loRaFEMControl.setRxModeEnableWhenMCUSleep();//It also needs to be enabled in receive mode - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void HeltecV4Board::powerOff() { - enterDeepSleep(0); + ESP32Board::powerOff(); } uint16_t HeltecV4Board::getBattMilliVolts() { @@ -88,6 +67,7 @@ void HeltecV4Board::begin() { if (!loRaFEMControl.isLnaCanControl()) { return false; } + loRaFEMControl.setLNAEnable(enable); loRaFEMControl.setRxModeEnable(); return true; diff --git a/variants/heltec_v4/HeltecV4Board.h b/variants/heltec_v4/HeltecV4Board.h index d59f730d4..55166bb37 100644 --- a/variants/heltec_v4/HeltecV4Board.h +++ b/variants/heltec_v4/HeltecV4Board.h @@ -3,7 +3,6 @@ #include #include #include -#include #include "LoRaFEMControl.h" #ifndef ADC_MULTIPLIER @@ -23,8 +22,10 @@ public: void begin(); void onBeforeTransmit(void) override; void onAfterTransmit(void) override; - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); void powerOff() override; + bool setLoRaFemLnaEnabled(bool enable) override; + bool canControlLoRaFemLna() const override; + bool isLoRaFemLnaEnabled() const override; uint16_t getBattMilliVolts() override; bool setAdcMultiplier(float multiplier) override { if (multiplier == 0.0f) { @@ -36,8 +37,4 @@ public: } float getAdcMultiplier() const override { return adc_mult; } const char* getManufacturerName() const override; - - bool setLoRaFemLnaEnabled(bool enable) override; - bool canControlLoRaFemLna() const override; - bool isLoRaFemLnaEnabled() const override; }; diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index de5dc2e0c..ab063277b 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -162,8 +162,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MQTT_MEMORY_DEBUG=1 @@ -192,7 +190,7 @@ lib_deps = ${heltec_v4_oled.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -214,8 +212,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MQTT_MEMORY_DEBUG=1 @@ -246,7 +242,7 @@ lib_deps = ${heltec_v4_oled.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -318,8 +314,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -339,7 +333,7 @@ lib_deps = ${heltec_v4_oled.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -362,8 +356,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -385,7 +377,7 @@ lib_deps = ${heltec_v4_oled.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -432,6 +424,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${heltec_v4_oled.build_src_filter} @@ -596,6 +589,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=ST7789LCDDisplay + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${heltec_v4_tft.build_src_filter} @@ -677,5 +671,10 @@ lib_deps = [env:heltec_v4_kiss_modem] extends = Heltec_lora32_v4 +build_unflags = + -DARDUINO_USB_MODE=0 +build_flags = + ${Heltec_lora32_v4.build_flags} + -DARDUINO_USB_MODE=1 build_src_filter = ${Heltec_lora32_v4.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp new file mode 100644 index 000000000..1fb123b2f --- /dev/null +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -0,0 +1,86 @@ +#include "HeltecV4R8Board.h" + +void HeltecV4R8Board::begin() { + ESP32Board::begin(); + + periph_power.begin(); + periph_power.claim(); // R8 VEXT also feeds the LoRa antenna boost rail. + + loRaFEMControl.init(); + +#ifdef PIN_TOUCH_RST + pinMode(PIN_TOUCH_RST, OUTPUT); + digitalWrite(PIN_TOUCH_RST, HIGH); + delay(10); + digitalWrite(PIN_TOUCH_RST, LOW); + delay(100); + digitalWrite(PIN_TOUCH_RST, HIGH); +#endif + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; + } + + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} + +void HeltecV4R8Board::onBeforeTransmit(void) { + digitalWrite(P_LORA_TX_LED, HIGH); + loRaFEMControl.setTxModeEnable(); +} + +void HeltecV4R8Board::onAfterTransmit(void) { + digitalWrite(P_LORA_TX_LED, LOW); + loRaFEMControl.setRxModeEnable(); +} + +void HeltecV4R8Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + + rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); + rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); + + rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); + loRaFEMControl.setRxModeEnableWhenMCUSleep(); + + if (pin_wake_btn < 0) { + esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); + } else { + esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); + } + + if (secs > 0) { + esp_sleep_enable_timer_wakeup(secs * 1000000); + } + + esp_deep_sleep_start(); +} + +void HeltecV4R8Board::powerOff() { + enterDeepSleep(0); +} + +uint16_t HeltecV4R8Board::getBattMilliVolts() { + analogReadResolution(12); + + uint32_t raw = 0; + for (int i = 0; i < 8; i++) { + raw += analogReadMilliVolts(PIN_VBAT_READ); + } + raw = raw / 8; + + return (adc_mult * raw); +} + +const char* HeltecV4R8Board::getManufacturerName() const { +#ifdef HELTEC_V4_R8_TFT + return "Heltec V4 R8 TFT"; +#else + return "Heltec V4 R8 OLED"; +#endif +} diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.h b/variants/heltec_v4_r8/HeltecV4R8Board.h new file mode 100644 index 000000000..20811abb1 --- /dev/null +++ b/variants/heltec_v4_r8/HeltecV4R8Board.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include "LoRaFEMControl.h" + +#ifndef ADC_MULTIPLIER + #define ADC_MULTIPLIER (4.9f * 1.035f) +#endif + +class HeltecV4R8Board : public ESP32Board { +protected: + float adc_mult = ADC_MULTIPLIER; + +public: + RefCountedDigitalPin periph_power; + LoRaFEMControl loRaFEMControl; + + HeltecV4R8Board() : periph_power(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE) { } + + void begin(); + void onBeforeTransmit(void) override; + void onAfterTransmit(void) override; + void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); + void powerOff() override; + uint16_t getBattMilliVolts() override; + bool setAdcMultiplier(float multiplier) override { + if (multiplier == 0.0f) { + adc_mult = ADC_MULTIPLIER; + } else { + adc_mult = multiplier; + } + return true; + } + float getAdcMultiplier() const override { return adc_mult; } + const char* getManufacturerName() const override; +}; diff --git a/variants/heltec_v4_r8/LoRaFEMControl.cpp b/variants/heltec_v4_r8/LoRaFEMControl.cpp new file mode 100644 index 000000000..bb530de3c --- /dev/null +++ b/variants/heltec_v4_r8/LoRaFEMControl.cpp @@ -0,0 +1,52 @@ +#include "LoRaFEMControl.h" + +#include +#include +#include + +void LoRaFEMControl::init(void) { + pinMode(P_LORA_PA_POWER, OUTPUT); + digitalWrite(P_LORA_PA_POWER, HIGH); + rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason != ESP_RST_DEEPSLEEP) { + delay(1); + } + + rtc_gpio_hold_dis((gpio_num_t)P_LORA_KCT8103L_PA_CSD); + rtc_gpio_hold_dis((gpio_num_t)P_LORA_KCT8103L_PA_CTX); + + pinMode(P_LORA_KCT8103L_PA_CSD, OUTPUT); + digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); + pinMode(P_LORA_KCT8103L_PA_CTX, OUTPUT); + digitalWrite(P_LORA_KCT8103L_PA_CTX, lna_enabled ? LOW : HIGH); +} + +void LoRaFEMControl::setSleepModeEnable(void) { + digitalWrite(P_LORA_KCT8103L_PA_CSD, LOW); +} + +void LoRaFEMControl::setTxModeEnable(void) { + digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); + digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH); +} + +void LoRaFEMControl::setRxModeEnable(void) { + digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); + digitalWrite(P_LORA_KCT8103L_PA_CTX, lna_enabled ? LOW : HIGH); +} + +void LoRaFEMControl::setRxModeEnableWhenMCUSleep(void) { + digitalWrite(P_LORA_PA_POWER, HIGH); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + + digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); + rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CSD); + digitalWrite(P_LORA_KCT8103L_PA_CTX, lna_enabled ? LOW : HIGH); + rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CTX); +} + +void LoRaFEMControl::setLNAEnable(bool enabled) { + lna_enabled = enabled; +} diff --git a/variants/heltec_v4_r8/LoRaFEMControl.h b/variants/heltec_v4_r8/LoRaFEMControl.h new file mode 100644 index 000000000..961cfd070 --- /dev/null +++ b/variants/heltec_v4_r8/LoRaFEMControl.h @@ -0,0 +1,24 @@ +#pragma once + +typedef enum { + KCT8103L_PA, + OTHER_FEM_TYPES +} LoRaFEMType; + +class LoRaFEMControl { +public: + LoRaFEMControl() { } + virtual ~LoRaFEMControl() { } + void init(void); + void setSleepModeEnable(void); + void setTxModeEnable(void); + void setRxModeEnable(void); + void setRxModeEnableWhenMCUSleep(void); + void setLNAEnable(bool enabled); + bool isLnaCanControl(void) { return true; } + void setLnaCanControl(bool can_control) { } + LoRaFEMType getFEMType(void) const { return KCT8103L_PA; } + +private: + bool lna_enabled = false; +}; diff --git a/variants/heltec_v4_r8/pins_arduino.h b/variants/heltec_v4_r8/pins_arduino.h new file mode 100644 index 000000000..9e412aac5 --- /dev/null +++ b/variants/heltec_v4_r8/pins_arduino.h @@ -0,0 +1,56 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +static const uint8_t SS = 8; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 11; +static const uint8_t SCK = 9; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini new file mode 100644 index 000000000..f523ebf9b --- /dev/null +++ b/variants/heltec_v4_r8/platformio.ini @@ -0,0 +1,344 @@ +[Heltec_v4_r8] +extends = esp32_base +board = heltec_v4_r8 +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/heltec_v4_r8 + -D HELTEC_V4_R8 + -D USE_SX1262 + -D ESP32_CPU_FREQ=80 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D P_LORA_DIO_1=14 + -D P_LORA_NSS=8 + -D P_LORA_RESET=12 + -D P_LORA_BUSY=13 + -D P_LORA_SCLK=9 + -D P_LORA_MISO=11 + -D P_LORA_MOSI=10 + -D P_LORA_PA_POWER=7 + -D P_LORA_KCT8103L_PA_CSD=2 + -D P_LORA_KCT8103L_PA_CTX=5 + -D P_LORA_TX_LED=46 + -D PIN_USER_BTN=0 + -D PIN_VEXT_EN=40 + -D PIN_VEXT_EN_ACTIVE=LOW + -D ADC_MULTIPLIER=5.0715f + -D PIN_VBAT_READ=1 + -D LORA_TX_POWER=10 + -D MAX_LORA_TX_POWER=22 + -D SX126X_REGISTER_PATCH=1 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D PIN_GPS_RX=38 + -D PIN_GPS_TX=39 + -D PIN_GPS_EN=42 + -D PIN_GPS_EN_ACTIVE=LOW + -D ENV_INCLUDE_GPS=1 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/heltec_v4_r8> + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + +[heltec_v4_r8_oled] +extends = Heltec_v4_r8 +build_flags = + ${Heltec_v4_r8.build_flags} + -D HELTEC_V4_R8_OLED + -D PIN_BOARD_SDA=17 + -D PIN_BOARD_SCL=18 + -D PIN_OLED_RESET=21 +build_src_filter = ${Heltec_v4_r8.build_src_filter} +lib_deps = ${Heltec_v4_r8.lib_deps} + +[heltec_v4_r8_tft] +extends = Heltec_v4_r8 +build_flags = + ${Heltec_v4_r8.build_flags} + -D HELTEC_V4_R8_TFT + -D PIN_BOARD_SDA=17 + -D PIN_BOARD_SCL=18 + -D DISPLAY_SCALE_X=2.5 + -D DISPLAY_SCALE_Y=3.75 + -D PIN_TFT_RST=-1 + -D PIN_TFT_VDD_CTL=-1 + -D PIN_TFT_LEDA_CTL=44 + -D PIN_TFT_LEDA_CTL_ACTIVE=HIGH + -D PIN_TFT_CS=47 + -D PIN_TFT_DC=48 + -D PIN_TFT_SCL=16 + -D PIN_TFT_SDA=15 + -D PIN_TFT_MISO=45 + -D PIN_BUZZER=4 + -D PIN_TOUCH_RST=21 +build_src_filter = ${Heltec_v4_r8.build_src_filter} + + +lib_deps = + ${Heltec_v4_r8.lib_deps} + adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_v4_r8_repeater] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec R8 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_v4_r8_room_server] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec R8 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_v4_r8_terminal_chat] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_companion_radio_usb] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_companion_radio_ble] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D BLE_PIN_CODE=123456 + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_companion_radio_wifi] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=SSD1306Display + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_sensor] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -D ADVERT_NAME='"Heltec R8 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D DISPLAY_CLASS=SSD1306Display +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + + + +<../examples/simple_sensor> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_v4_r8_tft_repeater] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -D DISPLAY_CLASS=ST7789LCDDisplay + -D ADVERT_NAME='"Heltec R8 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_v4_r8_tft_room_server] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -D DISPLAY_CLASS=ST7789LCDDisplay + -D ADVERT_NAME='"Heltec R8 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_v4_r8_tft_terminal_chat] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_tft_companion_radio_usb] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=ST7789LCDDisplay + -D ENABLE_USB_INTERFACE +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_tft_companion_radio_ble] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=ST7789LCDDisplay + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_tft_companion_radio_wifi] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=ST7789LCDDisplay + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_tft_sensor] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -D ADVERT_NAME='"Heltec R8 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D DISPLAY_CLASS=ST7789LCDDisplay +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + + +<../examples/simple_sensor> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_v4_r8_kiss_modem] +extends = Heltec_v4_r8 +build_src_filter = ${Heltec_v4_r8.build_src_filter} + +<../examples/kiss_modem/> + +[env:heltec_v4_r8_tft_kiss_modem] +extends = heltec_v4_r8_tft +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/heltec_v4_r8/target.cpp b/variants/heltec_v4_r8/target.cpp new file mode 100644 index 000000000..0b38531e3 --- /dev/null +++ b/variants/heltec_v4_r8/target.cpp @@ -0,0 +1,45 @@ +#include +#include "target.h" + +HeltecV4R8Board board; + +#if defined(P_LORA_SCLK) + static SPIClass spi; + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#else + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#endif + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + #include + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock, GPS_RESET, GPS_EN, &board.periph_power); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display(&board.periph_power); + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + +#if defined(P_LORA_SCLK) + return radio.std_init(&spi); +#else + return radio.std_init(); +#endif +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} diff --git a/variants/heltec_v4_r8/target.h b/variants/heltec_v4_r8/target.h new file mode 100644 index 000000000..2d1d7a5bb --- /dev/null +++ b/variants/heltec_v4_r8/target.h @@ -0,0 +1,31 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #ifdef HELTEC_V4_R8_OLED + #include + #elif defined(HELTEC_V4_R8_TFT) + #include + #endif + #include +#endif + +extern HeltecV4R8Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/heltec_wireless_paper/platformio.ini b/variants/heltec_wireless_paper/platformio.ini index 48723d169..55ed62b34 100644 --- a/variants/heltec_wireless_paper/platformio.ini +++ b/variants/heltec_wireless_paper/platformio.ini @@ -73,6 +73,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=E213Display -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${Heltec_Wireless_Paper_base.build_src_filter} + + diff --git a/variants/ikoka_handheld_nrf/platformio.ini b/variants/ikoka_handheld_nrf/platformio.ini index 51b602e40..1c6f17c24 100644 --- a/variants/ikoka_handheld_nrf/platformio.ini +++ b/variants/ikoka_handheld_nrf/platformio.ini @@ -74,6 +74,7 @@ extends = ikoka_handheld_nrf board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld build_flags = ${ikoka_handheld_nrf_ssd1306_companion.build_flags} -D LORA_TX_POWER=20 + -D ENABLE_USB_INTERFACE build_src_filter = ${ikoka_handheld_nrf_ssd1306_companion.build_src_filter} [env:ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_usb] @@ -82,6 +83,7 @@ board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld build_flags = ${ikoka_handheld_nrf_ssd1306_companion.build_flags} -D LORA_TX_POWER=20 -D DISPLAY_ROTATION=2 + -D ENABLE_USB_INTERFACE build_src_filter = ${ikoka_handheld_nrf_ssd1306_companion.build_src_filter} [env:ikoka_handheld_nrf_e22_30dbm_repeater] diff --git a/variants/ikoka_nano_nrf/platformio.ini b/variants/ikoka_nano_nrf/platformio.ini index e72f83ce0..87c6240ee 100644 --- a/variants/ikoka_nano_nrf/platformio.ini +++ b/variants/ikoka_nano_nrf/platformio.ini @@ -31,6 +31,8 @@ debug_tool = jlink upload_protocol = nrfutil lib_deps = ${nrf52_base.lib_deps} ${sensor_base.lib_deps} +build_src_filter = ${nrf52_base.build_src_filter} + + [ikoka_nano_nrf_e22_22dbm] extends = ikoka_nano_nrf @@ -42,7 +44,6 @@ build_flags = build_src_filter = ${ikoka_nano_nrf.build_src_filter} + + - + +<../variants/ikoka_nano_nrf> [ikoka_nano_nrf_e22_30dbm] @@ -56,7 +57,6 @@ build_flags = build_src_filter = ${ikoka_nano_nrf.build_src_filter} + + - + +<../variants/ikoka_nano_nrf> [ikoka_nano_nrf_e22_33dbm] @@ -70,7 +70,6 @@ build_flags = build_src_filter = ${ikoka_nano_nrf.build_src_filter} + + - + +<../variants/ikoka_nano_nrf> [ikoka_nano_nrf_companion_radio_ble] @@ -106,6 +105,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -I examples/companion_radio/ui-new -D QSPIFLASH=1 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ikoka_nano_nrf.build_src_filter} diff --git a/variants/ikoka_stick_nrf/platformio.ini b/variants/ikoka_stick_nrf/platformio.ini index 06e39e84c..c8184fc40 100644 --- a/variants/ikoka_stick_nrf/platformio.ini +++ b/variants/ikoka_stick_nrf/platformio.ini @@ -111,6 +111,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -I examples/companion_radio/ui-new -D QSPIFLASH=1 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ikoka_stick_nrf.build_src_filter} diff --git a/variants/keepteen_lt1/KeepteenLT1Board.h b/variants/keepteen_lt1/KeepteenLT1Board.h index 752b27e75..25d4d8676 100644 --- a/variants/keepteen_lt1/KeepteenLT1Board.h +++ b/variants/keepteen_lt1/KeepteenLT1Board.h @@ -37,8 +37,4 @@ public: digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off } #endif - - void powerOff() override { - sd_power_system_off(); - } }; diff --git a/variants/keepteen_lt1/platformio.ini b/variants/keepteen_lt1/platformio.ini index 27cf809e0..11dc214b0 100644 --- a/variants/keepteen_lt1/platformio.ini +++ b/variants/keepteen_lt1/platformio.ini @@ -66,6 +66,7 @@ build_flags = ${KeepteenLT1.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${KeepteenLT1.build_src_filter} diff --git a/variants/lilygo_t3s3/platformio.ini b/variants/lilygo_t3s3/platformio.ini index 9d5b160ba..2ba573c64 100644 --- a/variants/lilygo_t3s3/platformio.ini +++ b/variants/lilygo_t3s3/platformio.ini @@ -116,8 +116,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 ; -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -140,7 +138,7 @@ lib_deps = ${LilyGo_T3S3_sx1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -163,8 +161,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -181,7 +177,7 @@ lib_deps = ${LilyGo_T3S3_sx1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -227,6 +223,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${LilyGo_T3S3_sx1262.build_src_filter} diff --git a/variants/lilygo_t3s3_sx1276/platformio.ini b/variants/lilygo_t3s3_sx1276/platformio.ini index 5df632020..c6497dcb1 100644 --- a/variants/lilygo_t3s3_sx1276/platformio.ini +++ b/variants/lilygo_t3s3_sx1276/platformio.ini @@ -21,8 +21,8 @@ build_flags = -D RADIO_CLASS=CustomSX1276 -D WRAPPER_CLASS=CustomSX1276Wrapper -D SX127X_CURRENT_LIMIT=120 - -D SX176X_RXEN=21 - -D SX176X_TXEN=10 + -D SX127X_RXEN=21 + -D SX127X_TXEN=10 -D LORA_TX_POWER=20 build_src_filter = ${esp32_base.build_src_filter} +<../variants/lilygo_t3s3_sx1276> @@ -138,6 +138,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE build_src_filter = ${LilyGo_T3S3_sx1276.build_src_filter} + + diff --git a/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h b/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h index ea1782cf7..30bbc3a5c 100644 --- a/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h +++ b/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h @@ -47,13 +47,11 @@ public: return "LilyGo T-Impulse-Plus"; } - void powerOff() override { + void shutdownPeripherals() override { + // power off system + NRF52Board::shutdownPeripherals(); // turn off 3.3v digitalWrite(RT9080_EN, LOW); - - // power off system - sd_power_system_off(); - } }; diff --git a/variants/lilygo_tbeam_1w/platformio.ini b/variants/lilygo_tbeam_1w/platformio.ini index 4a40c0f7b..da354fe09 100644 --- a/variants/lilygo_tbeam_1w/platformio.ini +++ b/variants/lilygo_tbeam_1w/platformio.ini @@ -114,6 +114,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D PERSISTANT_GPS=1 -D ENV_SKIP_GPS_DETECT=1 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${LilyGo_TBeam_1W.build_src_filter} @@ -209,8 +210,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D PERSISTANT_GPS=1 -D ENV_SKIP_GPS_DETECT=1 @@ -235,7 +234,7 @@ lib_deps = ${LilyGo_TBeam_1W.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -257,8 +256,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D PERSISTANT_GPS=1 -D ENV_SKIP_GPS_DETECT=1 @@ -277,7 +274,7 @@ lib_deps = ${LilyGo_TBeam_1W.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/lilygo_tbeam_SX1262/platformio.ini b/variants/lilygo_tbeam_SX1262/platformio.ini index f4734f73d..7728af30c 100644 --- a/variants/lilygo_tbeam_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_SX1262/platformio.ini @@ -161,8 +161,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 ; -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -184,7 +182,7 @@ lib_deps = ${LilyGo_TBeam_SX1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -205,8 +203,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -222,7 +218,7 @@ lib_deps = ${LilyGo_TBeam_SX1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/lilygo_tbeam_SX1276/platformio.ini b/variants/lilygo_tbeam_SX1276/platformio.ini index db287a52c..a17e86ff3 100644 --- a/variants/lilygo_tbeam_SX1276/platformio.ini +++ b/variants/lilygo_tbeam_SX1276/platformio.ini @@ -45,7 +45,7 @@ build_flags = ${LilyGo_TBeam_SX1276.build_flags} -I examples/companion_radio/ui-new -D MAX_CONTACTS=160 - -D MAX_GROUP_CHANNELS=8 + -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=128 @@ -160,8 +160,6 @@ build_flags = -D MAX_NEIGHBOURS=50 -D PERSISTANT_GPS=1 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 ; -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -183,7 +181,7 @@ lib_deps = ${LilyGo_TBeam_SX1276.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -204,8 +202,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -221,7 +217,7 @@ lib_deps = ${LilyGo_TBeam_SX1276.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini index 38dd076c0..d509c4f78 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini @@ -19,6 +19,7 @@ build_flags = -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper -D DISPLAY_CLASS=SH1106Display + -D DISPLAY_ADDRESS=0x3D -D LORA_TX_POWER=22 -D P_LORA_TX_LED=6 -D PIN_BOARD_SDA=17 @@ -120,8 +121,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D MESH_PACKET_LOGGING=1 @@ -143,7 +142,7 @@ lib_deps = ${T_Beam_S3_Supreme_SX1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -164,8 +163,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -181,7 +178,7 @@ lib_deps = ${T_Beam_S3_Supreme_SX1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/lilygo_tdeck/TDeckBoard.h b/variants/lilygo_tdeck/TDeckBoard.h index 7ed007af9..e2844360d 100644 --- a/variants/lilygo_tdeck/TDeckBoard.h +++ b/variants/lilygo_tdeck/TDeckBoard.h @@ -3,7 +3,6 @@ #include #include #include "helpers/ESP32Board.h" -#include #define PIN_VBAT_READ 4 #define BATTERY_SAMPLES 8 @@ -23,29 +22,6 @@ public: } #endif - void enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - uint16_t getBattMilliVolts() { #if defined(PIN_VBAT_READ) && defined(ADC_MULTIPLIER) analogReadResolution(12); diff --git a/variants/lilygo_tdeck/platformio.ini b/variants/lilygo_tdeck/platformio.ini index 745d8ff53..00571db93 100644 --- a/variants/lilygo_tdeck/platformio.ini +++ b/variants/lilygo_tdeck/platformio.ini @@ -71,6 +71,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${LilyGo_TDeck.build_src_filter} + + diff --git a/variants/lilygo_techo/TechoBoard.h b/variants/lilygo_techo/TechoBoard.h index e560cd14b..867fc24cf 100644 --- a/variants/lilygo_techo/TechoBoard.h +++ b/variants/lilygo_techo/TechoBoard.h @@ -23,7 +23,8 @@ public: return "LilyGo T-Echo"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef LED_RED digitalWrite(LED_RED, HIGH); #endif @@ -39,6 +40,5 @@ public: #ifdef PIN_PWR_EN digitalWrite(PIN_PWR_EN, LOW); #endif - sd_power_system_off(); } }; diff --git a/variants/lilygo_techo/platformio.ini b/variants/lilygo_techo/platformio.ini index 5df77f95c..81da2e48c 100644 --- a/variants/lilygo_techo/platformio.ini +++ b/variants/lilygo_techo/platformio.ini @@ -120,6 +120,7 @@ build_flags = -D UI_SENSORS_PAGE=1 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 -D QSPIFLASH=1 + -D ENABLE_USB_INTERFACE build_src_filter = ${LilyGo_T-Echo.build_src_filter} +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> diff --git a/variants/lilygo_techo_card/TechoCardBoard.cpp b/variants/lilygo_techo_card/TechoCardBoard.cpp index f0dcef312..8a3a54b16 100644 --- a/variants/lilygo_techo_card/TechoCardBoard.cpp +++ b/variants/lilygo_techo_card/TechoCardBoard.cpp @@ -87,11 +87,11 @@ void TechoCardBoard::turnOffLeds() { } } -void TechoCardBoard::powerOff() { +void TechoCardBoard::shutdownPeripherals() { nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); turnOffLeds(); digitalWrite(PIN_PWR_EN, LOW); - sd_power_system_off(); + NRF52Board::shutdownPeripherals(); } #endif diff --git a/variants/lilygo_techo_card/TechoCardBoard.h b/variants/lilygo_techo_card/TechoCardBoard.h index 8a2913a63..a2ee3ea1e 100644 --- a/variants/lilygo_techo_card/TechoCardBoard.h +++ b/variants/lilygo_techo_card/TechoCardBoard.h @@ -28,7 +28,7 @@ public: return "LilyGo T-Echo Card"; } - void powerOff() override; + void shutdownPeripherals() override; void toggleTorch(); void turnOffLeds(); diff --git a/variants/lilygo_techo_card/platformio.ini b/variants/lilygo_techo_card/platformio.ini index 07bcebfe4..17ba8d1b2 100644 --- a/variants/lilygo_techo_card/platformio.ini +++ b/variants/lilygo_techo_card/platformio.ini @@ -23,7 +23,7 @@ build_src_filter = ${nrf52_base.build_src_filter} + + + - + + + + +<../variants/lilygo_techo_card> lib_deps = @@ -110,6 +110,7 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 + -D ENABLE_USB_INTERFACE build_src_filter = ${LilyGo_T-Echo_Card.build_src_filter} +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-tiny/*.cpp> @@ -118,3 +119,8 @@ lib_deps = ${LilyGo_T-Echo_Card.lib_deps} end2endzone/NonBlockingRTTTL@^1.3.0 densaugeo/base64 @ ~1.4.0 + +[env:LilyGo_T-Echo_Card_kiss_modem] +extends = LilyGo_T-Echo_Card +build_src_filter = ${LilyGo_T-Echo_Card.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/lilygo_techo_lite/TechoBoard.cpp b/variants/lilygo_techo_lite/TechoBoard.cpp index a11d31b27..e0b69f985 100644 --- a/variants/lilygo_techo_lite/TechoBoard.cpp +++ b/variants/lilygo_techo_lite/TechoBoard.cpp @@ -6,7 +6,7 @@ #ifdef LILYGO_TECHO void TechoBoard::begin() { - NRF52Board::begin(); + NRF52BoardDCDC::begin(); // Configure battery measurement control BEFORE Wire.begin() // to ensure P0.02 is not claimed by another peripheral diff --git a/variants/lilygo_techo_lite/TechoBoard.h b/variants/lilygo_techo_lite/TechoBoard.h index 7a43fd838..1e5651e78 100644 --- a/variants/lilygo_techo_lite/TechoBoard.h +++ b/variants/lilygo_techo_lite/TechoBoard.h @@ -21,7 +21,9 @@ public: return "LilyGo T-Echo Lite"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); + digitalWrite(PIN_VBAT_MEAS_EN, LOW); #ifdef LED_RED digitalWrite(LED_RED, LOW); @@ -38,6 +40,5 @@ public: #ifdef PIN_PWR_EN digitalWrite(PIN_PWR_EN, LOW); #endif - sd_power_system_off(); } }; \ No newline at end of file diff --git a/variants/lilygo_techo_lite/platformio.ini b/variants/lilygo_techo_lite/platformio.ini index 4b5edf691..1a8ad6772 100644 --- a/variants/lilygo_techo_lite/platformio.ini +++ b/variants/lilygo_techo_lite/platformio.ini @@ -12,7 +12,7 @@ build_flags = ${nrf52_base.build_flags} -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 -D SX126X_POWER_EN=30 - -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_DIO3_TCXO_VOLTAGE=3.0 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -D P_LORA_TX_LED=LED_GREEN @@ -116,7 +116,7 @@ build_flags = -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 -D SX126X_POWER_EN=30 - -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_DIO3_TCXO_VOLTAGE=3.0 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -D P_LORA_TX_LED=LED_GREEN @@ -161,6 +161,7 @@ build_flags = -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 -D SX126X_POWER_EN=30 + -D SX126X_DIO3_TCXO_VOLTAGE=3.0 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -D P_LORA_TX_LED=LED_GREEN @@ -173,6 +174,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D UI_RECENT_LIST_SIZE=9 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${nrf52_base.build_src_filter} diff --git a/variants/lilygo_techo_lite/variant.h b/variants/lilygo_techo_lite/variant.h index 702ab3349..38b79c66d 100644 --- a/variants/lilygo_techo_lite/variant.h +++ b/variants/lilygo_techo_lite/variant.h @@ -105,12 +105,14 @@ #define LORA_CS _PINNUM(0, 11) #define SX126X_POWER_EN _PINNUM(0, 30) #define SX126X_DIO1 _PINNUM(1, 8) +#define SX126X_DIO2 _PINNUM(0, 5) #define SX126X_BUSY _PINNUM(0, 14) #define SX126X_RESET _PINNUM(0, 7) #define SX126X_RXEN _PINNUM(1, 1) #define SX126X_TXEN _PINNUM(0, 27) #define P_LORA_DIO_1 SX126X_DIO1 +#define P_LORA_DIO_2 SX126X_DIO2 #define P_LORA_NSS LORA_CS #define P_LORA_RESET SX126X_RESET #define P_LORA_BUSY SX126X_BUSY diff --git a/variants/lilygo_teth_elite/TETHEliteBoard.h b/variants/lilygo_teth_elite/TETHEliteBoard.h new file mode 100644 index 000000000..15eb9533e --- /dev/null +++ b/variants/lilygo_teth_elite/TETHEliteBoard.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +class TETHEliteBoard : public ESP32Board { +public: + const char* getManufacturerName() const override { + return "LilyGO T-ETH Elite"; + } +}; diff --git a/variants/lilygo_teth_elite/platformio.ini b/variants/lilygo_teth_elite/platformio.ini new file mode 100644 index 000000000..2debb0396 --- /dev/null +++ b/variants/lilygo_teth_elite/platformio.ini @@ -0,0 +1,105 @@ +[LilyGo_TETH_Elite_sx1262] +extends = esp32_base +board = esp32s3box +board_build.partitions = default_16MB.csv +board_upload.flash_size = 16MB +build_flags = + ${esp32_base.build_flags} + -I variants/lilygo_teth_elite + -D BOARD_HAS_PSRAM + -D LILYGO_TETH_ELITE + -D LILYGO_T_ETH_ELITE_ESP32S3 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D P_LORA_DIO_1=8 + -D P_LORA_NSS=40 + -D P_LORA_RESET=46 + -D P_LORA_BUSY=16 + -D P_LORA_SCLK=10 + -D P_LORA_MISO=9 + -D P_LORA_MOSI=11 + -D P_LORA_TX_LED=38 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=8 + -D SX126X_RX_BOOSTED_GAIN=1 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/lilygo_teth_elite> +lib_deps = + ${esp32_base.lib_deps} + +[env:LilyGo_TETH_Elite_sx1262_repeater] +extends = LilyGo_TETH_Elite_sx1262 +build_flags = + ${LilyGo_TETH_Elite_sx1262.build_flags} + -D ADVERT_NAME='"T-ETH Elite Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + +<../examples/simple_repeater> +lib_deps = + ${LilyGo_TETH_Elite_sx1262.lib_deps} + ${esp32_ota.lib_deps} + +[env:LilyGo_TETH_Elite_sx1262_room_server] +extends = LilyGo_TETH_Elite_sx1262 +build_flags = + ${LilyGo_TETH_Elite_sx1262.build_flags} + -D ADVERT_NAME='"T-ETH Elite Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${LilyGo_TETH_Elite_sx1262.lib_deps} + ${esp32_ota.lib_deps} + +[env:LilyGo_TETH_Elite_sx1262_companion_radio_usb] +extends = LilyGo_TETH_Elite_sx1262 +build_flags = + ${LilyGo_TETH_Elite_sx1262.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + +<../examples/companion_radio/*.cpp> +lib_deps = + ${LilyGo_TETH_Elite_sx1262.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:LilyGo_TETH_Elite_sx1262_companion_radio_ble] +extends = LilyGo_TETH_Elite_sx1262 +build_flags = + ${LilyGo_TETH_Elite_sx1262.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +lib_deps = + ${LilyGo_TETH_Elite_sx1262.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:LilyGo_TETH_Elite_sx1262_kiss_modem] +extends = LilyGo_TETH_Elite_sx1262 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/lilygo_teth_elite/target.cpp b/variants/lilygo_teth_elite/target.cpp new file mode 100644 index 000000000..4dc377d62 --- /dev/null +++ b/variants/lilygo_teth_elite/target.cpp @@ -0,0 +1,43 @@ +#include +#include "target.h" + +TETHEliteBoard board; + +static SPIClass spi(HSPI); +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +SensorManager sensors; + +#ifndef LORA_CR + #define LORA_CR 5 +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + + return radio.std_init(&spi); +} + +uint32_t radio_get_rng_seed() { + return radio.random(0x7FFFFFFF); +} + +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { + radio.setFrequency(freq); + radio.setSpreadingFactor(sf); + radio.setBandwidth(bw); + radio.setCodingRate(cr); +} + +void radio_set_tx_power(int8_t dbm) { + radio.setOutputPower(dbm); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} diff --git a/variants/lilygo_teth_elite/target.h b/variants/lilygo_teth_elite/target.h new file mode 100644 index 000000000..a842186cf --- /dev/null +++ b/variants/lilygo_teth_elite/target.h @@ -0,0 +1,20 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include "TETHEliteBoard.h" + +extern TETHEliteBoard board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern SensorManager sensors; + +bool radio_init(); +uint32_t radio_get_rng_seed(); +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); +void radio_set_tx_power(int8_t dbm); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index 450a978ec..cc3df6ca0 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -89,6 +89,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=160 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter} @@ -142,7 +143,16 @@ lib_deps = ; Use ONE active WSS broker preset at a time. Two concurrent TLS sessions usually exhaust ; contiguous internal heap; the second slot fails (mbedtls_ssl_setup / esp-tls 0x8017). ; Set extra slots to "none" (e.g. set mqtt2.preset none). See MQTT_IMPLEMENTATION.md. -[env:LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt] +; TEMPORARILY EXCLUDED FROM AUTOMATIC BUILDS (trailing underscore, same +; convention as the nibble_screen_connect envs). This board does not fit: +; flex baseline (no webconfig, no upstream merge): 2,069,397 / 1,966,080 = 105.3% +; It has been silently failing on production for some time — the observer release +; ships 30 envs, not 32, and build.sh swallowing pio's exit code hid it. Dropping +; webconfig would recover only ~46 KB of a ~101 KB (flex) / ~242 KB (dev) deficit, +; so a NO_WEBCONFIG flag cannot rescue it. ESP32 (not S3), 4 MB flash, already on +; min_spiffs.csv. Real options: single-app partition (fits, but loses OTA), or +; trim ~250 KB for this board. See .scratch/tlora-v2-oversize.md. +[env:LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_] extends = LilyGo_TLora_V2_1_1_6_core extra_scripts = ${esp32_base.extra_scripts} @@ -157,8 +167,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_TASK_STACK_SIZE=16384 -D ESP32_CPU_FREQ=240 @@ -182,13 +190,14 @@ lib_deps = ${LilyGo_TLora_V2_1_1_6_core.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 ; Same single-broker guidance as LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt (see comment block above). -[env:LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt] +; TEMPORARILY EXCLUDED — see the repeater env above. +[env:LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt_] extends = LilyGo_TLora_V2_1_1_6_core extra_scripts = ${esp32_base.extra_scripts} @@ -203,9 +212,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_TASK_STACK_SIZE=16384 -D ESP32_CPU_FREQ=240 @@ -226,7 +232,7 @@ lib_deps = ${LilyGo_TLora_V2_1_1_6_core.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/m5stack_unit_c6l/platformio.ini b/variants/m5stack_unit_c6l/platformio.ini index 94083eb48..ea4d852d6 100644 --- a/variants/m5stack_unit_c6l/platformio.ini +++ b/variants/m5stack_unit_c6l/platformio.ini @@ -2,6 +2,7 @@ extends = esp32c6_base board = esp32-c6-devkitm-1 board_build.partitions = min_spiffs.csv ; get around 4mb flash limit +board_build.flash_mode = dio ; board manifest defaults to qio, which causes a boot crash-loop on this module build_flags = ${esp32c6_base.build_flags} ${sensor_base.build_flags} @@ -97,6 +98,7 @@ build_flags = ${M5Stack_Unit_C6L.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D ARDUINO_USB_CDC_ON_BOOT=1 -D ARDUINO_USB_MODE=1 + -D ENABLE_USB_INTERFACE build_src_filter = ${M5Stack_Unit_C6L.build_src_filter} + - diff --git a/variants/mesh_pocket/MeshPocket.h b/variants/mesh_pocket/MeshPocket.h index 478bd56d7..e215eb776 100644 --- a/variants/mesh_pocket/MeshPocket.h +++ b/variants/mesh_pocket/MeshPocket.h @@ -32,8 +32,4 @@ public: const char* getManufacturerName() const override { return "Heltec MeshPocket"; } - - void powerOff() override { - sd_power_system_off(); - } }; diff --git a/variants/mesh_pocket/platformio.ini b/variants/mesh_pocket/platformio.ini index 52a0d8352..a6f823dbf 100644 --- a/variants/mesh_pocket/platformio.ini +++ b/variants/mesh_pocket/platformio.ini @@ -1,7 +1,6 @@ [Mesh_pocket] extends = nrf52_base board = heltec_mesh_pocket -platform_packages = framework-arduinoadafruitnrf52 board_build.ldscript = boards/nrf52840_s140_v6.ld build_flags = ${nrf52_base.build_flags} -I src/helpers/nrf52 @@ -32,7 +31,6 @@ lib_deps = stevemarple/MicroNMEA @ ^2.0.6 zinggjm/GxEPD2 @ 1.6.2 bakercp/CRC32 @ ^2.0.0 - debug_tool = jlink upload_protocol = nrfutil @@ -40,7 +38,6 @@ upload_protocol = nrfutil extends = Mesh_pocket build_src_filter = ${Mesh_pocket.build_src_filter} +<../examples/simple_repeater> - build_flags = ${Mesh_pocket.build_flags} -D ADVERT_NAME='"Heltec_Mesh_Pocket Repeater"' @@ -99,6 +96,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D AUTO_OFF_MILLIS=0 + -D ENABLE_USB_INTERFACE ; -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/meshadventurer/platformio.ini b/variants/meshadventurer/platformio.ini index f85be2388..4af0f7f70 100644 --- a/variants/meshadventurer/platformio.ini +++ b/variants/meshadventurer/platformio.ini @@ -186,6 +186,7 @@ build_flags = -D MAX_CONTACTS=160 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=128 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = @@ -209,8 +210,8 @@ build_flags = -D BLE_PIN_CODE=123456 -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=128 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 lib_deps = ${Meshadventurer.lib_deps} densaugeo/base64 @ ~1.4.0 @@ -266,6 +267,7 @@ build_flags = -D MAX_CONTACTS=160 -D OFFLINE_QUEUE_SIZE=128 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = @@ -289,8 +291,8 @@ build_flags = -D BLE_PIN_CODE=123456 -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=128 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 lib_deps = ${Meshadventurer.lib_deps} densaugeo/base64 @ ~1.4.0 diff --git a/variants/meshnology_w12/MeshnologyW12Board.cpp b/variants/meshnology_w12/MeshnologyW12Board.cpp new file mode 100644 index 000000000..25c0d4f1a --- /dev/null +++ b/variants/meshnology_w12/MeshnologyW12Board.cpp @@ -0,0 +1,83 @@ +#include "MeshnologyW12Board.h" + +void MeshnologyW12Board::begin() { + ESP32Board::begin(); + + pinMode(PIN_ADC_CTRL, OUTPUT); + digitalWrite(PIN_ADC_CTRL, LOW); // Disable battery sense until required + + pinMode(P_LORA_LF_PA_POWER, OUTPUT); + digitalWrite(P_LORA_LF_PA_POWER, HIGH); // PA_EN_M — power the GC1109 FEM + + pinMode(P_LORA_HF_PA_POWER, OUTPUT); + digitalWrite(P_LORA_HF_PA_POWER, LOW); // PA_EN_G — disable the 2.4GHz FEM + + delay(100); // give the GC1109 some time to start + + periph_power.begin(); + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { // received a LoRa packet (while in deep sleep) + startup_reason = BD_STARTUP_RX_PACKET; + } + + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } + } + + void MeshnologyW12Board::onBeforeTransmit(void) { + neopixelWrite(NEOPIXEL_LED, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS); // turn TX neopixel on (White) + } + + void MeshnologyW12Board::onAfterTransmit(void) { + neopixelWrite(NEOPIXEL_LED, 0, 0, 0); // turn TX neopixel off + } + + void MeshnologyW12Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + + // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep + rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); + rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); + + rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); + + if (pin_wake_btn < 0) { + esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet + } else { + esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn + } + + if (secs > 0) { + esp_sleep_enable_timer_wakeup(secs * 1000000); + } + + // Finally set ESP32 into sleep + esp_deep_sleep_start(); // CPU halts here and never returns! + } + + void MeshnologyW12Board::powerOff() { + enterDeepSleep(0); + } + + uint16_t MeshnologyW12Board::getBattMilliVolts() { + analogReadResolution(12); + analogSetAttenuation(ADC_11db); + digitalWrite(PIN_ADC_CTRL, HIGH); + delay(10); + uint32_t raw = 0; + for (int i = 0; i < 8; i++) { + raw += analogRead(PIN_VBAT_READ); + } + raw = raw / 8; + + digitalWrite(PIN_ADC_CTRL, LOW); + + return (adc_mult * (3.3 / 4096.0) * raw) * 1000; + } + + const char* MeshnologyW12Board::getManufacturerName() const { + return "Meshnology W12"; + } \ No newline at end of file diff --git a/variants/meshnology_w12/MeshnologyW12Board.h b/variants/meshnology_w12/MeshnologyW12Board.h new file mode 100644 index 000000000..85bec9904 --- /dev/null +++ b/variants/meshnology_w12/MeshnologyW12Board.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +#ifndef ADC_MULTIPLIER + #define ADC_MULTIPLIER 5.42 +#endif + +class MeshnologyW12Board : public ESP32Board { + +protected: + float adc_mult = ADC_MULTIPLIER; + +public: + RefCountedDigitalPin periph_power; + MeshnologyW12Board() : periph_power(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE) { } + + void begin(); + void onBeforeTransmit(void) override; + void onAfterTransmit(void) override; + void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); + void powerOff() override; + uint16_t getBattMilliVolts() override; + bool setAdcMultiplier(float multiplier) override { + if (multiplier == 0.0f) { + adc_mult = ADC_MULTIPLIER; + } else { + adc_mult = multiplier; + } + return true; + } + float getAdcMultiplier() const override { return adc_mult; } + const char* getManufacturerName() const override; +}; diff --git a/variants/meshnology_w12/pins_arduino.h b/variants/meshnology_w12/pins_arduino.h new file mode 100644 index 000000000..5cfb36970 --- /dev/null +++ b/variants/meshnology_w12/pins_arduino.h @@ -0,0 +1,69 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +static const uint8_t LED_BUILTIN = -1; +#define BUILTIN_LED LED_BUILTIN // backward compatibility +#define LED_BUILTIN LED_BUILTIN // allow testing #ifdef LED_BUILTIN + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 3; +static const uint8_t SCL = 4; + +static const uint8_t SS = 8; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 11; +static const uint8_t SCK = 9; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +static const uint8_t Vext = 45; +static const uint8_t LED = -1; +static const uint8_t RST_OLED = 21; +static const uint8_t SCL_OLED = 18; +static const uint8_t SDA_OLED = 17; + +static const uint8_t RST_LoRa = 12; +static const uint8_t BUSY_LoRa = 13; +static const uint8_t DIO0 = 14; + + + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/meshnology_w12/platformio.ini b/variants/meshnology_w12/platformio.ini new file mode 100644 index 000000000..1168f10a0 --- /dev/null +++ b/variants/meshnology_w12/platformio.ini @@ -0,0 +1,216 @@ +[meshnology_w12] +extends = esp32_base +board = meshnology_w12 +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/meshnology_w12 + -D MESHNOLOGY_W12 + -D ESP32_CPU_FREQ=80 + -D RADIO_CLASS=CustomLR2021 + -D WRAPPER_CLASS=CustomLR2021Wrapper + -D USE_LR2021 + -D NEOPIXEL_LED=46 + -D NEOPIXEL_BRIGHTNESS=64 + -D P_LORA_DIO_1=14 + -D LR2021_IRQ_DIO=8 ; GPIO14 is connected to LR2021 DIO8 + -D P_LORA_NSS=8 + -D P_LORA_SCLK=9 + -D P_LORA_MOSI=10 + -D P_LORA_MISO=11 + -D P_LORA_RESET=12 + -D P_LORA_BUSY=13 + -D LR2021_TCXO_VOLTAGE=0.0f ; this board has no tcxo + -D RF_SWITCH_TABLE ; used below in commented code for the RF switch table. + -D P_LORA_LF_PA_POWER=4 ; PA_EN_M - power enable for the GC1109 868/915 PA + -D P_LORA_HF_PA_POWER=3 ; PA_EN_G - power enable for RFX2402E 2.4G PA + -D PIN_USER_BTN=0 + -D PIN_VEXT_EN=45 + -D PIN_VEXT_EN_ACTIVE=HIGH + -D LORA_TX_POWER=4 + -D MAX_LORA_TX_POWER=4 ; GC1109 datasheet says max input at TX port is +5dbm, confirmed full saturation seems to occur at around 3-4dbm + -D PIN_GPS_RX=38 + -D PIN_GPS_TX=39 + -D PIN_GPS_RESET=42 + -D PIN_GPS_RESET_ACTIVE=LOW + -D PIN_GPS_EN=48 + -D PIN_GPS_EN_ACTIVE=LOW + ; -D ENV_INCLUDE_GPS=1 + -D PIN_VBAT_READ=1 + -D PIN_ADC_CTRL=2 + -D PIN_BOARD_SDA=17 + -D PIN_BOARD_SCL=18 + -D PIN_RESET=47 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/meshnology_w12> + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + +[env:meshnology_w12_repeater] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Meshnology W12 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${meshnology_w12.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:meshnology_w12_repeater_bridge_espnow] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 + ; -D BRIDGE_DEBUG=1 + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${meshnology_w12.lib_deps} + ${esp32_ota.lib_deps} + +[env:meshnology_w12_room_server] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Meshnology W12 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${meshnology_w12.lib_deps} + ${esp32_ota.lib_deps} + +[env:meshnology_w12_terminal_chat] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${meshnology_w12.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:meshnology_w12_companion_radio_usb] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display +; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 +; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${meshnology_w12.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:meshnology_w12_companion_radio_ble] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D BLE_PIN_CODE=123456 ; dynamic, random PIN + ; -D AUTO_SHUTDOWN_MILLIVOLTS=3400 ; disabled by default, otherwise reading bounce causes shutdown when there's no battery connected. + ; -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${meshnology_w12.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:meshnology_w12_companion_radio_wifi] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=SSD1306Display + ; -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${meshnology_w12.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:meshnology_w12_sensor] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -D ADVERT_NAME='"Meshnology W12 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ENV_PIN_SDA=3 + -D ENV_PIN_SCL=4 + -D DISPLAY_CLASS=SSD1306Display + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + + + +<../examples/simple_sensor> +lib_deps = + ${meshnology_w12.lib_deps} + ${esp32_ota.lib_deps} + +[env:meshnology_w12_kiss_modem] +extends = meshnology_w12 +build_src_filter = ${meshnology_w12.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/meshnology_w12/target.cpp b/variants/meshnology_w12/target.cpp new file mode 100644 index 000000000..bf04ba364 --- /dev/null +++ b/variants/meshnology_w12/target.cpp @@ -0,0 +1,73 @@ +#include +#include "target.h" + +MeshnologyW12Board board; + +#if defined(P_LORA_SCLK) + static SPIClass spi; + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#else + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#endif + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + #include + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display(NULL); + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +const uint32_t rfswitch_dios[] = { + RADIOLIB_LR2021_DIO5, // RFX2402E 2.4G_TX_EN + RADIOLIB_LR2021_DIO6, // RFX2402E 2.4G_RX_EN + RADIOLIB_LR2021_DIO9, // GC1109 CTX (Transmit mode) + RADIOLIB_LR2021_DIO10, // GC1109 CPS (Bypass mode) + RADIOLIB_LR2021_DIO11, // GC1109 CSD (Shutdown) +}; + +static const Module::RfSwitchMode_t rfswitch_table[] = { + // DIO5 DIO6 DIO9 DIO10 DIO11 + { LR2021::MODE_STBY, { LOW, LOW, LOW, LOW, LOW } }, // everything off + { LR2021::MODE_RX, { LOW, LOW, LOW, LOW, HIGH } }, // rx_lf: GC1109 LNA + { LR2021::MODE_TX, { LOW, LOW, HIGH, HIGH, HIGH } }, // tx_lf: GC1109 full PA + { LR2021::MODE_RX_HF, { LOW, HIGH, LOW, LOW, LOW } }, // rx_hf: 2G4 LNA, GC1109 off + { LR2021::MODE_TX_HF, { HIGH, LOW, LOW, LOW, LOW } }, // tx_hf: 2G4 PA, GC1109 off + END_OF_MODE_TABLE, +}; + + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + +#if defined(P_LORA_SCLK) + int err = radio.std_init(&spi); + if (err != 1) return err; +#else + int err = radio.std_init(); + if (err != 1) return err; +#endif + +#ifdef RF_SWITCH_TABLE + radio.setRfSwitchTable(rfswitch_dios, rfswitch_table); +#endif + + return true; +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + diff --git a/variants/meshnology_w12/target.h b/variants/meshnology_w12/target.h new file mode 100644 index 000000000..1bf8876b2 --- /dev/null +++ b/variants/meshnology_w12/target.h @@ -0,0 +1,28 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #include + #include +#endif + +extern MeshnologyW12Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + diff --git a/variants/meshtiny/MeshtinyBoard.h b/variants/meshtiny/MeshtinyBoard.h index b69c0e419..67a521fe9 100644 --- a/variants/meshtiny/MeshtinyBoard.h +++ b/variants/meshtiny/MeshtinyBoard.h @@ -60,7 +60,7 @@ public: nrf_gpio_cfg_sense_input(g_ADigitalPinMap[PIN_USER_BTN], NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/meshtiny/platformio.ini b/variants/meshtiny/platformio.ini index c5439c88f..c0a3c5f94 100644 --- a/variants/meshtiny/platformio.ini +++ b/variants/meshtiny/platformio.ini @@ -36,6 +36,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Meshtiny.build_src_filter} diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.cpp b/variants/meshtracker_x1/MeshTrackerX1Board.cpp new file mode 100644 index 000000000..56e537a23 --- /dev/null +++ b/variants/meshtracker_x1/MeshTrackerX1Board.cpp @@ -0,0 +1,23 @@ +#include +#include + +#include "MeshTrackerX1Board.h" + +void MeshTrackerX1Board::begin() { + NRF52BoardDCDC::begin(); + btn_prev_state = LOW; // button is active HIGH + +#ifdef BUTTON_PIN + pinMode(BATTERY_PIN, INPUT); + pinMode(BUTTON_PIN, INPUT_PULLDOWN); + pinMode(LED_PIN, OUTPUT); +#endif + +#if defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); +#endif + + Wire.begin(); + + delay(10); // give lr2021 some time to power up +} diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.h b/variants/meshtracker_x1/MeshTrackerX1Board.h new file mode 100644 index 000000000..51f022e0b --- /dev/null +++ b/variants/meshtracker_x1/MeshTrackerX1Board.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include + +class MeshTrackerX1Board : public NRF52BoardDCDC { +protected: + uint8_t btn_prev_state; + +public: + MeshTrackerX1Board() : NRF52Board("X1_OTA") {} + void begin(); + + uint16_t getBattMilliVolts() override { + #ifdef BATTERY_PIN + #ifdef PIN_BAT_ADC_EN + digitalWrite(PIN_BAT_ADC_EN, HIGH); + #endif + analogReference(AR_INTERNAL_3_0); + analogReadResolution(12); + delay(10); + float volts = (analogRead(BATTERY_PIN) * ADC_MULTIPLIER * AREF_VOLTAGE) / 4096; + + analogReference(AR_DEFAULT); // put back to default + analogReadResolution(10); + + return volts * 1000; + #else + return 0; + #endif + } + + const char* getManufacturerName() const override { + return "Seeed SenseCAP MeshTracker X1"; + } + + int buttonStateChanged() { + #ifdef BUTTON_PIN + uint8_t v = digitalRead(BUTTON_PIN); + if (v != btn_prev_state) { + btn_prev_state = v; + return (v == USER_BTN_PRESSED) ? 1 : -1; + } + #endif + return 0; + } + + void powerOff() override { + #ifdef HAS_GPS + digitalWrite(GPS_VRTC_EN, LOW); + digitalWrite(GPS_RESET, LOW); + digitalWrite(GPS_SLEEP_INT, LOW); + digitalWrite(GPS_RTC_INT, LOW); + digitalWrite(GPS_EN, LOW); + #endif + + #ifdef PIN_DRV_EN + digitalWrite(PIN_DRV_EN, LOW); + #endif + + #ifdef PIN_BAT_ADC_EN + digitalWrite(PIN_BAT_ADC_EN, LOW); + #endif + + #ifdef PIN_3V3_EN + digitalWrite(PIN_3V3_EN, LOW); + #endif + + // set led on and wait for button release before poweroff + #ifdef LED_PIN + digitalWrite(LED_PIN, HIGH); + #endif + #ifdef BUTTON_PIN + while(digitalRead(BUTTON_PIN)); + #endif + #ifdef LED_PIN + digitalWrite(LED_PIN, LOW); + #endif + + #ifdef BUTTON_PIN + nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_PULLDOWN, NRF_GPIO_PIN_SENSE_HIGH); + #endif + + sd_power_system_off(); + } +}; diff --git a/variants/meshtracker_x1/platformio.ini b/variants/meshtracker_x1/platformio.ini new file mode 100644 index 000000000..2194d48d9 --- /dev/null +++ b/variants/meshtracker_x1/platformio.ini @@ -0,0 +1,137 @@ +[MeshTracker_X1] +extends = nrf52_base +board = seeed-mesh-tracker-x1 +board_build.ldscript = boards/nrf52840_s140_v7.ld +build_flags = ${nrf52_base.build_flags} + -I src/helpers/nrf52 + -I lib/nrf52/s140_nrf52_7.3.0_API/include + -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 + -I variants/meshtracker_x1 + -I src/helpers/ui + -D MESH_TRACKER_X1 + -D PIN_USER_BTN=6 + -D USER_BTN_PRESSED=HIGH + -D PIN_STATUS_LED=24 + -D PIN_STATUS_LED_R=3 ; P0.3 red + -D PIN_STATUS_LED_G=24 ; P0.24 green + -D PIN_STATUS_LED_B=28 ; P0.28 blue + -D RADIO_CLASS=CustomLR2021 + -D USE_LR2021 + -D WRAPPER_CLASS=CustomLR2021Wrapper + -D LORA_TX_POWER=22 + -D P_LORA_BUSY=7 ; P0.7 + -D P_LORA_SCLK=11 ; P0.11 + -D P_LORA_NSS=12 ; P0.12 + -D P_LORA_DIO_1=33 ; P1.1 + -D P_LORA_MISO=40 ; P1.8 + -D P_LORA_MOSI=41 ; P1.9 + -D P_LORA_RESET=42 ; P1.10 + -D LR2021_IRQ_DIO=8 ; P1.1 is connected to LR2021 DIO8 + -D LR2021_TCXO_VOLTAGE=1.6f + -D ENV_INCLUDE_GPS=1 +build_src_filter = ${nrf52_base.build_src_filter} + + + +<../variants/meshtracker_x1> +lib_deps = ${nrf52_base.lib_deps} + https://github.com/adafruit/Adafruit_SPA06_003/archive/refs/tags/1.0.2.zip +debug_tool = jlink +upload_protocol = nrfutil + +[env:MeshTracker_X1_repeater] +extends = MeshTracker_X1 +build_flags = ${MeshTracker_X1.build_flags} + -I examples/companion_radio/ui-orig + -D ADVERT_NAME='"MeshTracker X1 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MeshTracker_X1.build_src_filter} + +<../examples/simple_repeater> +lib_deps = ${MeshTracker_X1.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 + +[env:MeshTracker_X1_room_server] +extends = MeshTracker_X1 +build_flags = ${MeshTracker_X1.build_flags} + -I examples/companion_radio/ui-orig + -D ADVERT_NAME='"MeshTracker X1 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MeshTracker_X1.build_src_filter} + +<../examples/simple_room_server> +lib_deps = ${MeshTracker_X1.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 + +[env:MeshTracker_X1_companion_radio_usb] +extends = MeshTracker_X1 +board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 +build_flags = ${MeshTracker_X1.build_flags} + -I examples/companion_radio/ui-orig + -D QSPIFLASH=1 + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=NullDisplayDriver + -D PIN_BUZZER=25 + -D HAS_DRV2605=1 + -D DRV2605_USE_LRA=1 + -D ENABLE_USB_INTERFACE +build_src_filter = ${MeshTracker_X1.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = ${MeshTracker_X1.lib_deps} + densaugeo/base64 @ ~1.4.0 + stevemarple/MicroNMEA @ ^2.0.6 + end2endzone/NonBlockingRTTTL@^1.3.0 + adafruit/Adafruit DRV2605 Library @ ^1.2.4 + +[env:MeshTracker_X1_companion_radio_ble] +extends = MeshTracker_X1 +board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 +build_flags = ${MeshTracker_X1.build_flags} + -I examples/companion_radio/ui-orig + -D QSPIFLASH=1 + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D BLE_TX_POWER=0 +; -D BLE_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=NullDisplayDriver + -D PIN_BUZZER=25 + -D HAS_DRV2605=1 + -D DRV2605_USE_LRA=1 + -D ADVERT_NAME='"@@MAC"' +build_src_filter = ${MeshTracker_X1.build_src_filter} + + + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = ${MeshTracker_X1.lib_deps} + densaugeo/base64 @ ~1.4.0 + stevemarple/MicroNMEA @ ^2.0.6 + end2endzone/NonBlockingRTTTL@^1.3.0 + adafruit/Adafruit DRV2605 Library @ ^1.2.4 + +[env:MeshTracker_X1_kiss_modem] +extends = MeshTracker_X1 +build_src_filter = ${MeshTracker_X1.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/meshtracker_x1/target.cpp b/variants/meshtracker_x1/target.cpp new file mode 100644 index 000000000..d6d548b51 --- /dev/null +++ b/variants/meshtracker_x1/target.cpp @@ -0,0 +1,132 @@ +#include +#include "target.h" +#include + +MeshTrackerX1Board board; + +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); + +WRAPPER_CLASS radio_driver(radio, board); + +VolatileRTCClock rtc_clock; +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); +MeshTrackerX1SensorManager sensors = MeshTrackerX1SensorManager(nmea); + +#ifdef DISPLAY_CLASS + NullDisplayDriver display; +#endif + +bool radio_init() { + return radio.std_init(&SPI); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + +void MeshTrackerX1SensorManager::start_gps() { + gps_active = true; + // this init sequence comes from seeed examples and deals with all gps pins + pinMode(GPS_EN, OUTPUT); + digitalWrite(GPS_EN, HIGH); + delay(10); + pinMode(GPS_VRTC_EN, OUTPUT); + digitalWrite(GPS_VRTC_EN, HIGH); + delay(10); + + pinMode(GPS_RESET, OUTPUT); + digitalWrite(GPS_RESET, HIGH); + delay(10); + digitalWrite(GPS_RESET, LOW); + + pinMode(GPS_SLEEP_INT, OUTPUT); + digitalWrite(GPS_SLEEP_INT, HIGH); + pinMode(GPS_RTC_INT, OUTPUT); + digitalWrite(GPS_RTC_INT, LOW); +} + +void MeshTrackerX1SensorManager::sleep_gps() { + gps_active = false; + digitalWrite(GPS_VRTC_EN, HIGH); // keep RTC alive for faster fix on wake + digitalWrite(GPS_EN, LOW); + digitalWrite(GPS_RESET, LOW); + digitalWrite(GPS_SLEEP_INT, HIGH); + digitalWrite(GPS_RTC_INT, LOW); +} + +void MeshTrackerX1SensorManager::stop_gps() { + gps_active = false; + digitalWrite(GPS_VRTC_EN, LOW); + digitalWrite(GPS_EN, LOW); + digitalWrite(GPS_RESET, LOW); + digitalWrite(GPS_SLEEP_INT, HIGH); + digitalWrite(GPS_RTC_INT, LOW); +} + +bool MeshTrackerX1SensorManager::begin() { + // init GPS + Serial1.begin(GPS_BAUD_RATE); + + // init SPA06-003 barometer + baro_ok = spa06.begin(SPA06_003_DEFAULT_ADDR, &Wire) || spa06.begin(0x76, &Wire); + if (baro_ok) { + spa06.setPressureOversampling(SPA06_003_OVERSAMPLE_8); + spa06.setTemperatureOversampling(SPA06_003_OVERSAMPLE_8); + // 1 Hz continuous keeps reads non-blocking at minimal power cost + spa06.setPressureMeasureRate(SPA06_003_RATE_1); + spa06.setTemperatureMeasureRate(SPA06_003_RATE_1); + spa06.setMeasurementMode(SPA06_003_MEAS_CONTINUOUS_BOTH); + } + return true; +} + +bool MeshTrackerX1SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { + if (requester_permissions & TELEM_PERM_LOCATION) { // does requester have permission? + telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude); + } + if (requester_permissions & TELEM_PERM_ENVIRONMENT && baro_ok) { + telemetry.addTemperature(TELEM_CHANNEL_SELF, spa06.readTemperature()); + telemetry.addBarometricPressure(TELEM_CHANNEL_SELF, spa06.readPressure()); + } + return true; +} + +void MeshTrackerX1SensorManager::loop() { + static long next_gps_update = 0; + + + _nmea->loop(); + + if (millis() > next_gps_update) { + if (gps_active && _nmea->isValid()) { + node_lat = ((double)_nmea->getLatitude())/1000000.; + node_lon = ((double)_nmea->getLongitude())/1000000.; + node_altitude = ((double)_nmea->getAltitude()) / 1000.0; + } + next_gps_update = millis() + 1000; + } +} + +int MeshTrackerX1SensorManager::getNumSettings() const { return 1; } // just one supported: "gps" (power switch) + +const char* MeshTrackerX1SensorManager::getSettingName(int i) const { + return i == 0 ? "gps" : NULL; +} +const char* MeshTrackerX1SensorManager::getSettingValue(int i) const { + if (i == 0) { + return gps_active ? "1" : "0"; + } + return NULL; +} +bool MeshTrackerX1SensorManager::setSettingValue(const char* name, const char* value) { + if (strcmp(name, "gps") == 0) { + if (strcmp(value, "0") == 0) { + sleep_gps(); // sleep for faster fix ! + } else { + start_gps(); + } + return true; + } + return false; // not supported +} diff --git a/variants/meshtracker_x1/target.h b/variants/meshtracker_x1/target.h new file mode 100644 index 000000000..9741efb6a --- /dev/null +++ b/variants/meshtracker_x1/target.h @@ -0,0 +1,47 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include "MeshTrackerX1Board.h" +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #include "NullDisplayDriver.h" +#endif + +class MeshTrackerX1SensorManager: public SensorManager { + bool gps_active = false; + bool baro_ok = false; + Adafruit_SPA06_003 spa06; + LocationProvider * _nmea; + + void start_gps(); + void sleep_gps(); + void stop_gps(); +public: + MeshTrackerX1SensorManager(LocationProvider &nmea): _nmea(&nmea) { } + bool begin() override; + bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override; + void loop() override; + int getNumSettings() const override; + const char* getSettingName(int i) const override; + const char* getSettingValue(int i) const override; + bool setSettingValue(const char* name, const char* value) override; + LocationProvider* getLocationProvider() { return _nmea; } +}; + +#ifdef DISPLAY_CLASS + extern NullDisplayDriver display; +#endif + +extern MeshTrackerX1Board board; +extern WRAPPER_CLASS radio_driver; +extern VolatileRTCClock rtc_clock; +extern MeshTrackerX1SensorManager sensors; + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/meshtracker_x1/variant.cpp b/variants/meshtracker_x1/variant.cpp new file mode 100644 index 000000000..3f38a604d --- /dev/null +++ b/variants/meshtracker_x1/variant.cpp @@ -0,0 +1,109 @@ +/* + * variant.cpp - Seeed SenseCAP MeshTracker X1 (nRF52840 + Semtech LR2021) + */ + +#include "variant.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[PINS_COUNT + 1] = +{ + 0, // P0.00 + 1, // P0.01 + 2, // P0.02, AIN0 BATTERY_PIN + 3, // P0.03, LED_RED + 4, // P0.04, AIN2 VCC_ADC + 5, // P0.05, AIN3 EXT_PWR_DETECT + 6, // P0.06, PIN_BUTTON1 + 7, // P0.07, LORA_BUSY + 8, // P0.08, GPS_RESET + 9, // P0.09, PIN_RTC_EN + 10, // P0.10 + 11, // P0.11, PIN_SPI_SCK + 12, // P0.12, PIN_SPI_NSS + 13, // P0.13, PIN_SERIAL1_TX + 14, // P0.14, PIN_SERIAL1_RX + 15, // P0.15 + 16, // P0.16, PIN_SERIAL2_TX + 17, // P0.17, PIN_SERIAL2_RX + 18, // P0.18 + 19, // P0.19 + 20, // P0.20 + 21, // P0.21 + 22, // P0.22 + 23, // P0.23 + 24, // P0.24, LED_GREEN + 25, // P0.25, BUZZER_PIN + 26, // P0.26 + 27, // P0.27 + 28, // P0.28, LED_BLUE + 29, // P0.29, GPS_RTC_INT + 30, // P0.30, GPS_SLEEP_INT + 31, // P0.31 + 32, // P1.00 + 33, // P1.01, LORA_DIO_1 (LR2021 DIO8 IRQ) + 34, // P1.02 + 35, // P1.03, EXT_CHRG_DETECT + 36, // P1.04, CHARGE_DONE + 37, // P1.05, PIN_DRV_EN + 38, // P1.06, PIN_BAT_ADC_EN + 39, // P1.07, PIN_3V3_EN + 40, // P1.08, PIN_SPI_MISO + 41, // P1.09, PIN_SPI_MOSI + 42, // P1.10, LORA_RESET + 43, // P1.11, GPS_EN + 44, // P1.12 + 45, // P1.13, GPS_VRTC_EN + 46, // P1.14, PIN_WIRE_SCL + 47, // P1.15, PIN_WIRE_SDA + 255, // NRFX_SPIM_PIN_NOT_USED +}; + +void initVariant() +{ + pinMode(BATTERY_PIN, INPUT); + pinMode(EXT_CHRG_DETECT, INPUT_PULLUP); + pinMode(EXT_PWR_DETECT, INPUT); + pinMode(PIN_BUTTON1, INPUT_PULLDOWN); + + pinMode(PIN_3V3_EN, OUTPUT); + digitalWrite(PIN_3V3_EN, HIGH); + + pinMode(PIN_FLASH_EN, OUTPUT); + digitalWrite(PIN_FLASH_EN, HIGH); + + pinMode(PIN_BAT_ADC_EN, OUTPUT); + digitalWrite(PIN_BAT_ADC_EN, HIGH); + + pinMode(PIN_RTC_EN, OUTPUT); + digitalWrite(PIN_RTC_EN, LOW); + + pinMode(PIN_DRV_EN, OUTPUT); + digitalWrite(PIN_DRV_EN, LOW); + + pinMode(LED_RED, OUTPUT); + digitalWrite(LED_RED, LOW); + + pinMode(LED_GREEN, OUTPUT); + digitalWrite(LED_GREEN, LOW); + + pinMode(LED_BLUE, OUTPUT); + digitalWrite(LED_BLUE, LOW); + + pinMode(GPS_EN, OUTPUT); + digitalWrite(GPS_EN, LOW); + + pinMode(GPS_VRTC_EN, OUTPUT); + digitalWrite(GPS_VRTC_EN, HIGH); + + pinMode(GPS_RESET, OUTPUT); + digitalWrite(GPS_RESET, LOW); + + pinMode(GPS_SLEEP_INT, OUTPUT); + digitalWrite(GPS_SLEEP_INT, HIGH); + + pinMode(GPS_RTC_INT, OUTPUT); + digitalWrite(GPS_RTC_INT, LOW); + + pinMode(BUZZER_PIN, INPUT_PULLDOWN); +} diff --git a/variants/meshtracker_x1/variant.h b/variants/meshtracker_x1/variant.h new file mode 100644 index 000000000..7f2b246b5 --- /dev/null +++ b/variants/meshtracker_x1/variant.h @@ -0,0 +1,139 @@ +/* + * variant.h - Seeed SenseCAP MeshTracker X1 (nRF52840 + Semtech LR2021) + */ + +#pragma once + +#include "WVariant.h" + +//////////////////////////////////////////////////////////////////////////////// +// Low frequency clock source + +#define USE_LFXO // 32.768 kHz crystal oscillator +#define VARIANT_MCK (64000000ul) + +//////////////////////////////////////////////////////////////////////////////// +// Power + +#define NRF_APM // detect usb power +#define PIN_3V3_EN (39) // P1.7 Power to sensors +#define PIN_BAT_ADC_EN (38) // P1.6 Power to battery ADC divider +#define PIN_RTC_EN (9) // P0.9 Power to RTC + +#define BATTERY_PIN (2) // P0.2/AIN0 +#define BATTERY_IMMUTABLE +#define ADC_MULTIPLIER (2.0F) + +#define EXT_CHRG_DETECT (35) // P1.3, LOW while charging +// P1.4 is the charger's second status line, but it reads LOW regardless of +// charge state on this board, so it is not used (Meshtastic leaves it out too) +#define EXT_PWR_DETECT (5) // P0.5 + +#define ADC_RESOLUTION (14) +#define BATTERY_SENSE_RES (12) + +#define AREF_VOLTAGE (3.0) + +//////////////////////////////////////////////////////////////////////////////// +// Number of pins + +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (6) +#define NUM_ANALOG_OUTPUTS (0) + +//////////////////////////////////////////////////////////////////////////////// +// UART pin definition + +#define PIN_SERIAL1_RX (14) // P0.14 (GPS) +#define PIN_SERIAL1_TX (13) // P0.13 (GPS) + +#define PIN_SERIAL2_RX (17) // P0.17 +#define PIN_SERIAL2_TX (16) // P0.16 + +//////////////////////////////////////////////////////////////////////////////// +// I2C pin definition + +#define HAS_WIRE (1) +#define WIRE_INTERFACES_COUNT (1) + +#define PIN_WIRE_SDA (47) // P1.15 +#define PIN_WIRE_SCL (46) // P1.14 +#define I2C_NO_RESCAN + +//////////////////////////////////////////////////////////////////////////////// +// SPI pin definition + +#define SPI_INTERFACES_COUNT (1) + +#define PIN_SPI_MISO (40) // P1.8 +#define PIN_SPI_MOSI (41) // P1.9 +#define PIN_SPI_SCK (11) // P0.11 +#define PIN_SPI_NSS (12) // P0.12 + +//////////////////////////////////////////////////////////////////////////////// +// Builtin LEDs (RGB) + +#define LED_BUILTIN (-1) +#define LED_RED (3) // P0.3 +#define LED_GREEN (24) // P0.24 +#define LED_BLUE (28) // P0.28 +#define LED_PIN LED_GREEN + +#define LED_STATE_ON HIGH + +//////////////////////////////////////////////////////////////////////////////// +// Builtin buttons + +#define PIN_BUTTON1 (6) // P0.6, active HIGH (internal pull-down) +#define BUTTON_PIN PIN_BUTTON1 + +//////////////////////////////////////////////////////////////////////////////// +// LR2021 + +#define LORA_DIO_1 (33) // P1.1, IRQ line (LR2021 DIO8) +#define LORA_NSS (PIN_SPI_NSS) // P0.12 +#define LORA_RESET (42) // P1.10 +#define LORA_BUSY (7) // P0.7 +#define LORA_SCLK (PIN_SPI_SCK) // P0.11 +#define LORA_MISO (PIN_SPI_MISO) // P1.8 +#define LORA_MOSI (PIN_SPI_MOSI) // P1.9 + +//////////////////////////////////////////////////////////////////////////////// +// GPS (Airoha, dual-band L1+L5) + +#define HAS_GPS 1 +#define GPS_RX_PIN PIN_SERIAL1_RX +#define GPS_TX_PIN PIN_SERIAL1_TX +#define GPS_BAUD_RATE (115200) + +#define GPS_EN (43) // P1.11, active HIGH +#define GPS_RESET (8) // P0.8, active HIGH + +#define GPS_VRTC_EN (45) // P1.13 +#define GPS_SLEEP_INT (30) // P0.30 +#define GPS_RTC_INT (29) // P0.29 + +//////////////////////////////////////////////////////////////////////////////// +// Haptic driver (DRV2605 on I2C) + +#define PIN_DRV_EN (37) // P1.5, power to haptic driver + +//////////////////////////////////////////////////////////////////////////////// +// Buzzer + +#define BUZZER_PIN (25) // P0.25, pwm output + +//////////////////////////////////////////////////////////////////////////////// +// QSPI Flash + +#define PIN_FLASH_EN (15) // P0.15 (Flash power enable) + +#define PIN_QSPI_SCK (19) // P0.19 +#define PIN_QSPI_CS (20) // P0.20 +#define PIN_QSPI_IO0 (21) // P0.21 +#define PIN_QSPI_IO1 (22) // P0.22 +#define PIN_QSPI_IO2 (23) // P0.23 +#define PIN_QSPI_IO3 (32) // P1.00 + +#define EXTERNAL_FLASH_DEVICES GD25Q64C \ No newline at end of file diff --git a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h index 4fa5cd41e..450a3d13b 100644 --- a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h +++ b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h @@ -65,7 +65,7 @@ public: #ifdef BUTTON_PIN nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - sd_power_system_off(); + NRF52Board::powerOff(); } #if defined(P_LORA_TX_LED) diff --git a/variants/minewsemi_me25ls01/NullDisplayDriver.h b/variants/minewsemi_me25ls01/NullDisplayDriver.h deleted file mode 100644 index 38bf93f15..000000000 --- a/variants/minewsemi_me25ls01/NullDisplayDriver.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include - -class NullDisplayDriver : public DisplayDriver { -public: - NullDisplayDriver() : DisplayDriver(128, 64) { } - bool begin() { return false; } // not present - - bool isOn() override { return false; } - void turnOn() override { } - void turnOff() override { } - void clear() override { } - void startFrame(Color bkg = DARK) override { } - void setTextSize(int sz) override { } - void setColor(Color c) override { } - void setCursor(int x, int y) override { } - void print(const char* str) override { } - void fillRect(int x, int y, int w, int h) override { } - void drawRect(int x, int y, int w, int h) override { } - void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { } - uint16_t getTextWidth(const char* str) override { return 0; } - void endFrame() { } -}; diff --git a/variants/minewsemi_me25ls01/platformio.ini b/variants/minewsemi_me25ls01/platformio.ini index 39d4252de..d115a1f1a 100644 --- a/variants/minewsemi_me25ls01/platformio.ini +++ b/variants/minewsemi_me25ls01/platformio.ini @@ -46,8 +46,8 @@ build_flags = ${me25ls01.build_flags} -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 -D RX_BOOSTED_GAIN=true -D RF_SWITCH_TABLE @@ -55,6 +55,7 @@ build_flags = ${me25ls01.build_flags} ;-D PIN_BUZZER=25 ;-D PIN_BUZZER_EN=37 build_src_filter = ${me25ls01.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> @@ -66,8 +67,8 @@ build_flags = ${me25ls01.build_flags} -D MAX_GROUP_CHANNELS=8 -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 -D RX_BOOSTED_GAIN=true -D RF_SWITCH_TABLE @@ -79,6 +80,7 @@ build_flags = ${me25ls01.build_flags} -D DISPLAY_CLASS=NullDisplayDriver build_src_filter = ${me25ls01.build_src_filter} +<../examples/simple_repeater> + + [env:Minewsemi_me25ls01_room_server] extends = me25ls01 @@ -101,6 +103,7 @@ build_flags = ${me25ls01.build_flags} -D DISPLAY_CLASS=NullDisplayDriver build_src_filter = ${me25ls01.build_src_filter} +<../examples/simple_room_server> + + [env:Minewsemi_me25ls01_terminal_chat] extends = me25ls01 @@ -109,8 +112,8 @@ build_flags = ${me25ls01.build_flags} -D MAX_GROUP_CHANNELS=8 -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 -D RX_BOOSTED_GAIN=true -D RF_SWITCH_TABLE @@ -123,6 +126,7 @@ build_flags = ${me25ls01.build_flags} -D DISPLAY_CLASS=NullDisplayDriver build_src_filter = ${me25ls01.build_src_filter} +<../examples/simple_secure_chat/main.cpp> + + [env:Minewsemi_me25ls01_companion_radio_usb] extends = me25ls01 @@ -134,14 +138,16 @@ build_flags = ${me25ls01.build_flags} -D MAX_GROUP_CHANNELS=40 ;-D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 -D RX_BOOSTED_GAIN=true -D RF_SWITCH_TABLE -D DISPLAY_CLASS=NullDisplayDriver + -D ENABLE_USB_INTERFACE build_src_filter = ${me25ls01.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> diff --git a/variants/minewsemi_me25ls01/target.h b/variants/minewsemi_me25ls01/target.h index f8d42863b..978e616b9 100644 --- a/variants/minewsemi_me25ls01/target.h +++ b/variants/minewsemi_me25ls01/target.h @@ -10,7 +10,7 @@ #include #include #ifdef DISPLAY_CLASS - #include "NullDisplayDriver.h" + #include #endif #ifdef DISPLAY_CLASS diff --git a/variants/muziworks_r1_neo/platformio.ini b/variants/muziworks_r1_neo/platformio.ini index 3dbecf1e8..52dc3e38f 100644 --- a/variants/muziworks_r1_neo/platformio.ini +++ b/variants/muziworks_r1_neo/platformio.ini @@ -66,6 +66,7 @@ build_flags = -I examples/companion_radio/ui-orig -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${R1Neo.build_src_filter} @@ -127,7 +128,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' ; -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 build_src_filter = ${R1Neo.build_src_filter} +<../examples/simple_sensor> diff --git a/variants/nano_g2_ultra/nano-g2.h b/variants/nano_g2_ultra/nano-g2.h index cf771efe7..bf9543b03 100644 --- a/variants/nano_g2_ultra/nano-g2.h +++ b/variants/nano_g2_ultra/nano-g2.h @@ -52,6 +52,6 @@ public: nrf_gpio_cfg_sense_input(digitalPinToInterrupt(PIN_USER_BTN), NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_LOW); - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/nano_g2_ultra/platformio.ini b/variants/nano_g2_ultra/platformio.ini index 3cdc29ffb..b817b3e99 100644 --- a/variants/nano_g2_ultra/platformio.ini +++ b/variants/nano_g2_ultra/platformio.ini @@ -99,6 +99,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=SH1106Display -D PIN_BUZZER=4 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Nano_G2_Ultra.build_src_filter} diff --git a/variants/nibble_screen_connect/platformio.ini b/variants/nibble_screen_connect/platformio.ini index 20f7dc243..112181df2 100644 --- a/variants/nibble_screen_connect/platformio.ini +++ b/variants/nibble_screen_connect/platformio.ini @@ -34,7 +34,7 @@ lib_deps = adafruit/Adafruit SSD1306 @ ^2.5.13 adafruit/Adafruit NeoPixel @ ^1.12.3 -[env:nibble_screen_connect_repeater] +[env:nibble_screen_connect_repeater_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -51,7 +51,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} ${esp32_ota.lib_deps} -[env:nibble_screen_connect_repeater_bridge_espnow] +[env:nibble_screen_connect_repeater_bridge_espnow_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -70,7 +70,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} ${esp32_ota.lib_deps} -[env:nibble_screen_connect_terminal_chat] +[env:nibble_screen_connect_terminal_chat_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -82,7 +82,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} densaugeo/base64 @ ~1.4.0 -[env:nibble_screen_connect_room_server] +[env:nibble_screen_connect_room_server_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -99,7 +99,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} ${esp32_ota.lib_deps} -[env:nibble_screen_connect_companion_radio_usb] +[env:nibble_screen_connect_companion_radio_usb_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -107,6 +107,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=300 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE build_src_filter = ${nibble_screen_connect_base.build_src_filter} + + @@ -116,7 +117,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} densaugeo/base64 @ ~1.4.0 -[env:nibble_screen_connect_companion_radio_ble] +[env:nibble_screen_connect_companion_radio_ble_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -137,7 +138,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} densaugeo/base64 @ ~1.4.0 -[env:nibble_screen_connect_companion_radio_wifi] +[env:nibble_screen_connect_companion_radio_wifi_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -160,7 +161,7 @@ lib_deps = densaugeo/base64 @ ~1.4.0 -[env:nibble_screen_connect_kiss_modem] +[env:nibble_screen_connect_kiss_modem_] extends = nibble_screen_connect_base build_src_filter = ${nibble_screen_connect_base.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/nibble_zero_connect/platformio.ini b/variants/nibble_zero_connect/platformio.ini new file mode 100644 index 000000000..1161743ea --- /dev/null +++ b/variants/nibble_zero_connect/platformio.ini @@ -0,0 +1,159 @@ +[nibble_zero_connect_base] +extends = esp32_base +board = esp32-s3-zero +build_flags = + ${esp32_base.build_flags} + -I variants/nibble_zero_connect + -D NIBBLE_ZERO_CONNECT + -D P_LORA_DIO_1=4 + -D P_LORA_NSS=10 + -D P_LORA_RESET=6 + -D P_LORA_BUSY=5 + -D P_LORA_SCLK=12 + -D P_LORA_MISO=13 + -D P_LORA_MOSI=11 + -D PIN_USER_BTN=1 + -D PIN_BOARD_SDA=8 + -D PIN_BOARD_SCL=7 + -D PIN_STATUS_LED=39 + -D P_LORA_TX_LED=39 + -D DISPLAY_ROTATION=0 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_RX_BOOSTED_GAIN=1 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/nibble_zero_connect> +lib_deps = + ${esp32_base.lib_deps} + adafruit/Adafruit SSD1306 @ ^2.5.13 + +[env:nibble_zero_connect_repeater_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Nibble Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + ${esp32_ota.lib_deps} + +[env:nibble_zero_connect_repeater_bridge_espnow_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + ${esp32_ota.lib_deps} + +[env:nibble_zero_connect_terminal_chat_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -D MAX_CONTACTS=300 + -D MAX_GROUP_CHANNELS=1 +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:nibble_zero_connect_room_server_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Nibble Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + ${esp32_ota.lib_deps} + +[env:nibble_zero_connect_companion_radio_usb_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=300 + -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:nibble_zero_connect_companion_radio_ble_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=300 + -D MAX_GROUP_CHANNELS=8 + -D BLE_PIN_CODE=123456 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:nibble_zero_connect_companion_radio_wifi_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=300 + -D MAX_GROUP_CHANNELS=8 + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + + diff --git a/variants/nibble_zero_connect/target.cpp b/variants/nibble_zero_connect/target.cpp new file mode 100644 index 000000000..d53b6a1c3 --- /dev/null +++ b/variants/nibble_zero_connect/target.cpp @@ -0,0 +1,35 @@ +#include +#include "target.h" + +ESP32Board board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +SensorManager sensors; + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +#ifndef LORA_CR + #define LORA_CR 5 +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + + return radio.std_init(&spi); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} + + diff --git a/variants/nibble_zero_connect/target.h b/variants/nibble_zero_connect/target.h new file mode 100644 index 000000000..87c55f0f2 --- /dev/null +++ b/variants/nibble_zero_connect/target.h @@ -0,0 +1,28 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #include + #include +#endif + +extern ESP32Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern SensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + + diff --git a/variants/promicro/PromicroBoard.h b/variants/promicro/PromicroBoard.h index 7b6afb1b3..b190c47c8 100644 --- a/variants/promicro/PromicroBoard.h +++ b/variants/promicro/PromicroBoard.h @@ -72,8 +72,4 @@ public: #endif return 0; } - - void powerOff() override { - sd_power_system_off(); - } }; diff --git a/variants/promicro/platformio.ini b/variants/promicro/platformio.ini index 5415e1586..90cb475f5 100644 --- a/variants/promicro/platformio.ini +++ b/variants/promicro/platformio.ini @@ -119,6 +119,7 @@ build_flags = ${Promicro.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Promicro.build_src_filter} @@ -143,7 +144,7 @@ build_flags = ${Promicro.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=SSD1306Display ; -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 build_src_filter = ${Promicro.build_src_filter} + + diff --git a/variants/rak11310/platformio.ini b/variants/rak11310/platformio.ini index ab820cf6a..c8526317a 100644 --- a/variants/rak11310/platformio.ini +++ b/variants/rak11310/platformio.ini @@ -85,6 +85,7 @@ extends = rak11310 build_flags = ${rak11310.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak11310.build_src_filter} diff --git a/variants/rak3112/RAK3112Board.h b/variants/rak3112/RAK3112Board.h index 8ba3197cf..704162b8c 100644 --- a/variants/rak3112/RAK3112Board.h +++ b/variants/rak3112/RAK3112Board.h @@ -16,8 +16,6 @@ #define ADC_MULTIPLIER (3 * 1.73 * 1.187 * 1000) #define BATTERY_SAMPLES 8 -#include - class RAK3112Board : public ESP32Board { private: bool adc_active_state; @@ -51,33 +49,6 @@ public: } } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void powerOff() override { - enterDeepSleep(0); - } - uint16_t getBattMilliVolts() override { analogReadResolution(12); diff --git a/variants/rak3112/platformio.ini b/variants/rak3112/platformio.ini index 0998a1806..ea797cabf 100644 --- a/variants/rak3112/platformio.ini +++ b/variants/rak3112/platformio.ini @@ -114,8 +114,8 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 +; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). + -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D MESH_PACKET_LOGGING=1 @@ -142,7 +142,7 @@ lib_deps = ${rak3112.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone 0neblock/SNMP_Agent @@ -181,9 +181,9 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 +; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). + -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -204,7 +204,7 @@ lib_deps = ${rak3112.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -231,6 +231,7 @@ build_flags = -I examples/companion_radio/ui-orig -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak3112.build_src_filter} diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index 20a8a548b..48e719226 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -67,6 +67,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak3401.build_src_filter} diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index f96678608..c23019c80 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -37,6 +37,7 @@ build_flags = ${rak3x72.build_flags} ; -D FORMAT_FS=true -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE build_src_filter = ${rak3x72.build_src_filter} +<../examples/companion_radio/*.cpp> lib_deps = ${rak3x72.lib_deps} diff --git a/variants/rak4631/RAK4631Board.cpp b/variants/rak4631/RAK4631Board.cpp index 9fb47b432..1b5698d0e 100644 --- a/variants/rak4631/RAK4631Board.cpp +++ b/variants/rak4631/RAK4631Board.cpp @@ -1,8 +1,21 @@ #include #include +#include "nrf_gpio.h" #include "RAK4631Board.h" +#ifdef ETHERNET_ENABLED +// Drive WB_IO2 HIGH as early as possible using direct register access. +// WB_IO2 (P1.02, Arduino pin 34) controls the WisBlock slot power switch. +// With POE through RAK13800, this must be enabled before the framework +// initializes or the board will brownout from insufficient power delivery. +// Priority 102 runs just after SystemInit. +static void __attribute__((constructor(102))) rak4631_early_poe_power() { + nrf_gpio_cfg_output(NRF_GPIO_PIN_MAP(1, 2)); // WB_IO2 = P1.02 + nrf_gpio_pin_set(NRF_GPIO_PIN_MAP(1, 2)); +} +#endif + #ifdef NRF52_POWER_MANAGEMENT // Static configuration for power management // Values set in variant.h defines @@ -36,6 +49,7 @@ void RAK4631Board::begin() { pinMode(PIN_USER_BTN_ANA, INPUT_PULLUP); #endif + #if defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); #endif diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 2bbba3146..0dfbf7977 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -53,6 +53,25 @@ build_src_filter = ${rak4631.build_src_filter} + +<../examples/simple_repeater> +[env:RAK_4631_repeater_ethernet] +extends = rak4631 +build_flags = + ${rak4631.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"RAK4631 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D ETHERNET_ENABLED=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rak4631.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${rak4631.lib_deps} + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + [env:RAK_4631_repeater_bridge_rs232_serial1] extends = rak4631 build_flags = @@ -115,6 +134,25 @@ build_src_filter = ${rak4631.build_src_filter} + +<../examples/simple_room_server> +[env:RAK_4631_room_server_ethernet] +extends = rak4631 +build_flags = + ${rak4631.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"RAK4631 Room Server"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D ETHERNET_ENABLED=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rak4631.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${rak4631.lib_deps} + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + [env:RAK_4631_companion_radio_usb] extends = rak4631 board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld @@ -127,6 +165,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak4631.build_src_filter} @@ -136,6 +175,38 @@ lib_deps = ${rak4631.lib_deps} densaugeo/base64 @ ~1.4.0 + +[env:RAK_4631_companion_radio_ethernet] +extends = rak4631 +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 712704 +build_unflags = + -D EXTRAFS=1 +build_flags = + ${rak4631.build_flags} + -I examples/companion_radio/ui-new + -D PIN_USER_BTN=9 + -D PIN_USER_BTN_ANA=31 + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D ETHERNET_ENABLED=1 + -D ETHERNET_USE_RAK13800 + -D ETHERNET_CLASS=RAK13800EthernetInterface +; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 +; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 +; -D ETHERNET_DEBUG_LOGGING=1 +build_src_filter = ${rak4631.build_src_filter} + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> + + + + +lib_deps = + ${rak4631.lib_deps} + densaugeo/base64 @ ~1.4.0 + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + + [env:RAK_4631_companion_radio_ble] extends = rak4631 board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld @@ -187,7 +258,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' ; -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 build_src_filter = ${rak4631.build_src_filter} + +<../examples/simple_sensor> @@ -199,4 +270,4 @@ build_flags = build_src_filter = ${rak4631.build_src_filter} +<../examples/kiss_modem/> lib_deps = - ${rak4631.lib_deps} \ No newline at end of file + ${rak4631.lib_deps} diff --git a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h index cc5aa06f5..daa90d746 100644 --- a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h +++ b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h @@ -69,6 +69,6 @@ public: nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/rak_wismesh_tag/platformio.ini b/variants/rak_wismesh_tag/platformio.ini index e9cddb74d..d61401871 100644 --- a/variants/rak_wismesh_tag/platformio.ini +++ b/variants/rak_wismesh_tag/platformio.ini @@ -71,6 +71,7 @@ build_flags = -I examples/companion_radio/ui-orig -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak_wismesh_tag.build_src_filter} diff --git a/variants/rpi_picow/platformio.ini b/variants/rpi_picow/platformio.ini index 66c7b9f89..0fe8c4369 100644 --- a/variants/rpi_picow/platformio.ini +++ b/variants/rpi_picow/platformio.ini @@ -58,6 +58,7 @@ extends = rpi_picow build_flags = ${rpi_picow.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rpi_picow.build_src_filter} diff --git a/variants/sensecap_indicator-espnow/platformio.ini b/variants/sensecap_indicator-espnow/platformio.ini index e643d0339..a5952d6e3 100644 --- a/variants/sensecap_indicator-espnow/platformio.ini +++ b/variants/sensecap_indicator-espnow/platformio.ini @@ -33,7 +33,7 @@ lib_deps=${esp32_base.lib_deps} lovyan03/LovyanGFX @ ^1.2.7 [env:SenseCapIndicator-ESPNow_comp_radio_usb] -extends =SenseCapIndicator-ESPNow +extends = SenseCapIndicator-ESPNow build_flags = ${SenseCapIndicator-ESPNow.build_flags} -I examples/companion_radio/ui-new @@ -47,4 +47,4 @@ build_src_filter = ${SenseCapIndicator-ESPNow.build_src_filter} +<../examples/companion_radio/*.cpp> lib_deps = ${SenseCapIndicator-ESPNow.lib_deps} - densaugeo/base64 @ ~1.4.0 \ No newline at end of file + densaugeo/base64 @ ~1.4.0 diff --git a/variants/sensecap_solar/SenseCapSolarBoard.h b/variants/sensecap_solar/SenseCapSolarBoard.h index 6799a5e92..5a8861c29 100644 --- a/variants/sensecap_solar/SenseCapSolarBoard.h +++ b/variants/sensecap_solar/SenseCapSolarBoard.h @@ -54,7 +54,7 @@ public: #ifdef NRF52_POWER_MANAGEMENT initiateShutdown(SHUTDOWN_REASON_USER); #else - sd_power_system_off(); + NRF52Board::powerOff(); #endif } }; diff --git a/variants/sensecap_solar/platformio.ini b/variants/sensecap_solar/platformio.ini index 8f3d89a0d..87c657fcb 100644 --- a/variants/sensecap_solar/platformio.ini +++ b/variants/sensecap_solar/platformio.ini @@ -92,6 +92,7 @@ build_flags = ${SenseCap_Solar.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${SenseCap_Solar.build_src_filter} @@ -100,3 +101,8 @@ build_src_filter = ${SenseCap_Solar.build_src_filter} lib_deps = ${SenseCap_Solar.lib_deps} densaugeo/base64 @ ~1.4.0 + +[env:SenseCap_Solar_kiss_modem] +extends = SenseCap_Solar +build_src_filter = ${SenseCap_Solar.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/station_g2/StationG2Board.h b/variants/station_g2/StationG2Board.h index a905682c8..d1989ee0f 100644 --- a/variants/station_g2/StationG2Board.h +++ b/variants/station_g2/StationG2Board.h @@ -2,7 +2,6 @@ #include #include -#include class StationG2Board : public ESP32Board { public: @@ -21,29 +20,6 @@ public: } } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - uint16_t getBattMilliVolts() override { return 0; } diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini index 892197482..38bca3d8b 100644 --- a/variants/station_g2/platformio.ini +++ b/variants/station_g2/platformio.ini @@ -23,8 +23,8 @@ build_flags = -D PIN_BOARD_SDA=5 -D PIN_BOARD_SCL=6 -D PIN_USER_BTN=38 - -D PIN_GPS_RX=7 - -D PIN_GPS_TX=15 + -D PIN_GPS_RX=15 + -D PIN_GPS_TX=7 -D SX126X_DIO2_AS_RF_SWITCH=true -D SX126X_DIO3_TCXO_VOLTAGE=1.8 -D SX126X_CURRENT_LIMIT=140 @@ -114,7 +114,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D MESH_PACKET_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 -D SX126X_RX_BOOSTED_GAIN=1 ; https://wiki.uniteng.com/en/meshtastic/station-g2#impact-of-lora-node-dense-areashigh-noise-environments-on-rf-performance ; -D MESH_DEBUG=1 @@ -159,7 +159,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D MESH_PACKET_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 -D SX126X_RX_BOOSTED_GAIN=1 -D WITH_ESPNOW_BRIDGE=1 ; -D BRIDGE_DEBUG=1 @@ -188,8 +188,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -208,7 +206,7 @@ lib_deps = ${Station_G2.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -238,6 +236,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Station_G2.build_src_filter} @@ -278,7 +277,7 @@ build_flags = -D WIFI_DEBUG_LOGGING=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' - -D OFFLINE_QUEUE_SIZE=256 + -D OFFLINE_QUEUE_SIZE=256 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Station_G2.build_src_filter} @@ -291,6 +290,9 @@ lib_deps = [env:Station_G2_kiss_modem] extends = Station_G2 +build_flags = + ${Station_G2.build_flags} + -D ARDUINO_USB_MODE=1 build_src_filter = ${Station_G2.build_src_filter} +<../examples/kiss_modem/> @@ -310,8 +312,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 # -D MESH_PACKET_LOGGING=1 @@ -336,7 +336,7 @@ lib_deps = ${Station_G2.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/station_g3_esp32/StationG3Board.cpp b/variants/station_g3_esp32/StationG3Board.cpp new file mode 100644 index 000000000..4a4983110 --- /dev/null +++ b/variants/station_g3_esp32/StationG3Board.cpp @@ -0,0 +1,15 @@ +#include "StationG3Board.h" + +void StationG3Board::powerOff() { +#ifdef P_PA1_EN + setPAModeHigh(false); + rtc_gpio_hold_en((gpio_num_t)P_PA1_EN); +#endif + +#ifdef P_PRIMARY_LNA_EN + setPrimaryLNAControl(true); + rtc_gpio_hold_en((gpio_num_t)P_PRIMARY_LNA_EN); +#endif + + ESP32Board::powerOff(); +} diff --git a/variants/station_g3_esp32/StationG3Board.h b/variants/station_g3_esp32/StationG3Board.h index dc440d95f..4b1fb81c1 100644 --- a/variants/station_g3_esp32/StationG3Board.h +++ b/variants/station_g3_esp32/StationG3Board.h @@ -73,36 +73,7 @@ public: setPrimaryLNAControl(true); } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - -#ifdef P_PA1_EN - setPAModeHigh(false); - rtc_gpio_hold_en((gpio_num_t)P_PA1_EN); -#endif - -#ifdef P_PRIMARY_LNA_EN - setPrimaryLNAControl(true); - rtc_gpio_hold_en((gpio_num_t)P_PRIMARY_LNA_EN); -#endif - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); - } else { - esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1) | (1ULL << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - esp_deep_sleep_start(); - } + void powerOff() override; uint16_t getBattMilliVolts() override { return 0; diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini index 039a12cb9..02d0aa76e 100644 --- a/variants/station_g3_esp32/platformio.ini +++ b/variants/station_g3_esp32/platformio.ini @@ -71,7 +71,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D MESH_PACKET_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Station_G3_ESP32.build_src_filter} +<../examples/simple_repeater> @@ -103,6 +103,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Station_G3_ESP32.build_src_filter} @@ -175,8 +176,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 # -D MESH_PACKET_LOGGING=1 @@ -223,9 +222,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index e7653fb2f..1111d3c40 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -88,6 +88,6 @@ public: nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_HIGH); #endif - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/t1000-e/platformio.ini b/variants/t1000-e/platformio.ini index 43a3d93f6..8456dc915 100644 --- a/variants/t1000-e/platformio.ini +++ b/variants/t1000-e/platformio.ini @@ -82,8 +82,10 @@ build_flags = ${t1000-e.build_flags} -D DISPLAY_CLASS=NullDisplayDriver -D PIN_BUZZER=25 -D PIN_BUZZER_EN=37 ; P1/5 - required for T1000-E + -D ENABLE_USB_INTERFACE build_src_filter = ${t1000-e.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${t1000-e.lib_deps} @@ -112,6 +114,7 @@ build_flags = ${t1000-e.build_flags} build_src_filter = ${t1000-e.build_src_filter} + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${t1000-e.lib_deps} diff --git a/variants/tenstar_c3/target.h b/variants/tenstar_c3/target.h index b3ee6d177..7839fa973 100644 --- a/variants/tenstar_c3/target.h +++ b/variants/tenstar_c3/target.h @@ -3,7 +3,7 @@ #define RADIOLIB_STATIC_ONLY 1 #include #include -#include +#include <../variants/xiao_c3/XiaoC3Board.h> #include #include #include diff --git a/variants/thinknode_m1/ThinkNodeM1Board.h b/variants/thinknode_m1/ThinkNodeM1Board.h index ebc46e6e6..94224d9c2 100644 --- a/variants/thinknode_m1/ThinkNodeM1Board.h +++ b/variants/thinknode_m1/ThinkNodeM1Board.h @@ -40,7 +40,6 @@ public: #endif // power off board - sd_power_system_off(); - + NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m1/platformio.ini b/variants/thinknode_m1/platformio.ini index 356edfee6..89c48403b 100644 --- a/variants/thinknode_m1/platformio.ini +++ b/variants/thinknode_m1/platformio.ini @@ -58,6 +58,7 @@ build_flags = -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ThinkNode_M1.build_src_filter} @@ -116,6 +117,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=6 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 + -D ENABLE_USB_INTERFACE build_src_filter = ${ThinkNode_M1.build_src_filter} + + diff --git a/variants/thinknode_m2/ThinknodeM2Board.cpp b/variants/thinknode_m2/ThinknodeM2Board.cpp index 059651036..8d68006d2 100644 --- a/variants/thinknode_m2/ThinknodeM2Board.cpp +++ b/variants/thinknode_m2/ThinknodeM2Board.cpp @@ -1,40 +1,30 @@ #include "ThinknodeM2Board.h" - - void ThinknodeM2Board::begin() { - pinMode(PIN_VEXT_EN, OUTPUT); - digitalWrite(PIN_VEXT_EN, !PIN_VEXT_EN_ACTIVE); // force power cycle - delay(20); // allow power rail to discharge - digitalWrite(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE); // turn backlight back on - delay(120); // give display time to bias on cold boot - ESP32Board::begin(); - pinMode(PIN_STATUS_LED, OUTPUT); // init power led - } - - void ThinknodeM2Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_deep_sleep_start(); - } - - void ThinknodeM2Board::powerOff() { - enterDeepSleep(0); - } - - uint16_t ThinknodeM2Board::getBattMilliVolts() { - analogReadResolution(12); - analogSetPinAttenuation(PIN_VBAT_READ, ADC_11db); - - uint32_t mv = 0; - for (int i = 0; i < 8; ++i) { - mv += analogReadMilliVolts(PIN_VBAT_READ); - delayMicroseconds(200); - } - mv /= 8; - - analogReadResolution(10); - return static_cast(mv * ADC_MULTIPLIER ); + pinMode(PIN_VEXT_EN, OUTPUT); + digitalWrite(PIN_VEXT_EN, !PIN_VEXT_EN_ACTIVE); // force power cycle + delay(20); // allow power rail to discharge + digitalWrite(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE); // turn backlight back on + delay(120); // give display time to bias on cold boot + ESP32Board::begin(); + pinMode(PIN_STATUS_LED, OUTPUT); // init power led } - const char* ThinknodeM2Board::getManufacturerName() const { - return "Elecrow ThinkNode M2"; +uint16_t ThinknodeM2Board::getBattMilliVolts() { + analogReadResolution(12); + analogSetPinAttenuation(PIN_VBAT_READ, ADC_11db); + + uint32_t mv = 0; + for (int i = 0; i < 8; ++i) { + mv += analogReadMilliVolts(PIN_VBAT_READ); + delayMicroseconds(200); } + mv /= 8; + + analogReadResolution(10); + return static_cast(mv * ADC_MULTIPLIER); +} + +const char *ThinknodeM2Board::getManufacturerName() const { + return "Elecrow ThinkNode M2"; +} diff --git a/variants/thinknode_m2/ThinknodeM2Board.h b/variants/thinknode_m2/ThinknodeM2Board.h index 8011fae67..025567778 100644 --- a/variants/thinknode_m2/ThinknodeM2Board.h +++ b/variants/thinknode_m2/ThinknodeM2Board.h @@ -3,15 +3,11 @@ #include #include #include -#include class ThinknodeM2Board : public ESP32Board { public: - void begin(); - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/thinknode_m2/platformio.ini b/variants/thinknode_m2/platformio.ini index aae83324e..583f913c9 100644 --- a/variants/thinknode_m2/platformio.ini +++ b/variants/thinknode_m2/platformio.ini @@ -30,7 +30,7 @@ build_flags = ${esp32_base.build_flags} -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 -D SX126X_RX_BOOSTED_GAIN=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 build_src_filter = ${esp32_base.build_src_filter} + + @@ -159,6 +159,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${ThinkNode_M2.build_src_filter} + + diff --git a/variants/thinknode_m3/ThinkNodeM3Board.h b/variants/thinknode_m3/ThinkNodeM3Board.h index 1435d31d4..9e8f49894 100644 --- a/variants/thinknode_m3/ThinkNodeM3Board.h +++ b/variants/thinknode_m3/ThinkNodeM3Board.h @@ -18,12 +18,12 @@ public: void begin(); uint16_t getBattMilliVolts() override; -#if defined(P_LORA_TX_LED) +#ifdef P_LORA_TX_LED void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + digitalWrite(P_LORA_TX_LED, LED_STATE_ON); // turn TX LED on } void onAfterTransmit() override { - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off + digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); // turn TX LED off } #endif @@ -44,11 +44,11 @@ public: void powerOff() override { // turn off all leds, sd_power_system_off will not do this for us - #ifdef P_LORA_TX_LED - digitalWrite(P_LORA_TX_LED, LOW); - #endif + digitalWrite(PIN_LED_BLUE, !LED_STATE_ON); + digitalWrite(PIN_LED_GREEN, !LED_STATE_ON); + digitalWrite(PIN_LED_RED, !LED_STATE_ON); // power off board - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m3/platformio.ini b/variants/thinknode_m3/platformio.ini index 0a3d4eda9..ce1e7e59e 100644 --- a/variants/thinknode_m3/platformio.ini +++ b/variants/thinknode_m3/platformio.ini @@ -24,10 +24,9 @@ build_flags = ${nrf52_base.build_flags} -D P_LORA_MOSI=46 -D P_LORA_RESET=42 -D P_LORA_TX_LED=PIN_LED_BLUE - -D P_LORA_TX_LED_ON=LOW -D LR11X0_DIO_AS_RF_SWITCH=true -D LR11X0_DIO3_TCXO_VOLTAGE=3.3 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 -D ENV_INCLUDE_GPS=1 build_src_filter = ${nrf52_base.build_src_filter} + @@ -84,8 +83,10 @@ build_flags = ${ThinkNode_M3.build_flags} -D DISPLAY_CLASS=NullDisplayDriver -D PIN_BUZZER=23 -D PIN_BUZZER_EN=36 + -D ENABLE_USB_INTERFACE build_src_filter = ${ThinkNode_M3.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${ThinkNode_M3.lib_deps} @@ -105,7 +106,6 @@ build_flags = ${ThinkNode_M3.build_flags} -D BLE_TX_POWER=0 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 - -D GPS_NMEA_DEBUG -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=NullDisplayDriver -D PIN_BUZZER=23 @@ -113,6 +113,7 @@ build_flags = ${ThinkNode_M3.build_flags} build_src_filter = ${ThinkNode_M3.build_src_filter} + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${ThinkNode_M3.lib_deps} diff --git a/variants/thinknode_m3/variant.cpp b/variants/thinknode_m3/variant.cpp index dad0f3f55..b47b83543 100644 --- a/variants/thinknode_m3/variant.cpp +++ b/variants/thinknode_m3/variant.cpp @@ -80,16 +80,18 @@ void initVariant() digitalWrite(LED_POWER, HIGH); pinMode(PIN_LED_BLUE, OUTPUT); + digitalWrite(PIN_LED_BLUE, !LED_STATE_ON); pinMode(PIN_LED_GREEN, OUTPUT); + digitalWrite(PIN_LED_GREEN, !LED_STATE_ON); pinMode(PIN_LED_RED, OUTPUT); + digitalWrite(PIN_LED_RED, !LED_STATE_ON); pinMode(BUTTON_PIN, INPUT_PULLUP); pinMode(PIN_GPS_POWER, OUTPUT); pinMode(PIN_GPS_EN, OUTPUT); - pinMode(PIN_GPS_RESET, OUTPUT); // Power on gps but in standby - digitalWrite(PIN_GPS_EN, LOW); - digitalWrite(PIN_GPS_POWER, HIGH); + digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE); + digitalWrite(PIN_GPS_POWER, GPS_POWER_ACTIVE); } diff --git a/variants/thinknode_m3/variant.h b/variants/thinknode_m3/variant.h index 02ed78a84..78dfab85d 100644 --- a/variants/thinknode_m3/variant.h +++ b/variants/thinknode_m3/variant.h @@ -32,7 +32,7 @@ #define EXT_CHRG_DETECT (32) // P1.3 #define EXT_PWR_DETECT (31) // P0.5 -#define PIN_VBAT_READ (5) +#define PIN_VBAT_READ (5) #define AREF_VOLTAGE (2.4f) #define ADC_MULTIPLIER (2.0) //(1.75f) // 2.0 gives more coherent value, 4.2V when charged, needs tweaking @@ -92,18 +92,19 @@ // GPS #define HAS_GPS 1 -#define PIN_GPS_RX (22) +#define PIN_GPS_RX (22) #define PIN_GPS_TX (20) #define PIN_GPS_POWER (14) #define PIN_GPS_EN (21) // STANDBY #define PIN_GPS_RESET (25) // REINIT -#define GPS_RESET_ACTIVE LOW +#define GPS_POWER_ACTIVE HIGH #define GPS_EN_ACTIVE HIGH +#define GPS_RESET (-1) #define GPS_BAUDRATE 9600 //////////////////////////////////////////////////////////////////////////////// // Buzzer #define BUZZER_EN (37) // P1.5 -#define BUZZER_PIN (25) // P0.25 \ No newline at end of file +#define BUZZER_PIN (25) // P0.25 diff --git a/variants/thinknode_m5/ThinknodeM5Board.cpp b/variants/thinknode_m5/ThinknodeM5Board.cpp index c4de538c5..2cb138e63 100644 --- a/variants/thinknode_m5/ThinknodeM5Board.cpp +++ b/variants/thinknode_m5/ThinknodeM5Board.cpp @@ -19,14 +19,6 @@ void ThinknodeM5Board::begin() { ESP32Board::begin(); } - void ThinknodeM5Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_deep_sleep_start(); - } - - void ThinknodeM5Board::powerOff() { - enterDeepSleep(0); - } - uint16_t ThinknodeM5Board::getBattMilliVolts() { analogReadResolution(12); analogSetPinAttenuation(PIN_VBAT_READ, ADC_11db); diff --git a/variants/thinknode_m5/ThinknodeM5Board.h b/variants/thinknode_m5/ThinknodeM5Board.h index 3c120027b..57ff0d00e 100644 --- a/variants/thinknode_m5/ThinknodeM5Board.h +++ b/variants/thinknode_m5/ThinknodeM5Board.h @@ -3,7 +3,6 @@ #include #include #include -#include #include extern PCA9557 expander; @@ -13,8 +12,6 @@ class ThinknodeM5Board : public ESP32Board { public: void begin(); - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/thinknode_m5/platformio.ini b/variants/thinknode_m5/platformio.ini index 8572d1ebe..5e85b6498 100644 --- a/variants/thinknode_m5/platformio.ini +++ b/variants/thinknode_m5/platformio.ini @@ -37,7 +37,7 @@ build_flags = ${esp32_base.build_flags} -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 -D SX126X_RX_BOOSTED_GAIN=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 -D ENV_INCLUDE_GPS=1 -D PERSISTANT_GPS=1 -D ENV_SKIP_GPS_DETECT=1 @@ -173,6 +173,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${ThinkNode_M5.build_src_filter} + + diff --git a/variants/thinknode_m6/ThinkNodeM6Board.h b/variants/thinknode_m6/ThinkNodeM6Board.h index 32baa2a0a..78815e2c9 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.h +++ b/variants/thinknode_m6/ThinkNodeM6Board.h @@ -44,6 +44,6 @@ public: #endif // power off board - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m6/platformio.ini b/variants/thinknode_m6/platformio.ini index 6fe904366..ad7e6902c 100644 --- a/variants/thinknode_m6/platformio.ini +++ b/variants/thinknode_m6/platformio.ini @@ -48,8 +48,8 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 ; -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 - -D GPS_NMEA_DEBUG=1 +; -D MESH_DEBUG=1 +; -D GPS_NMEA_DEBUG=1 build_src_filter = ${ThinkNode_M6.build_src_filter} +<../examples/simple_repeater/*.cpp> lib_deps = @@ -63,6 +63,7 @@ build_flags = -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ThinkNode_M6.build_src_filter} @@ -84,7 +85,6 @@ build_flags = -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 - -D QSPIFLASH=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ThinkNode_M6.build_src_filter} @@ -106,9 +106,9 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 - -D QSPIFLASH=1 -D OFFLINE_QUEUE_SIZE=256 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 + -D ENABLE_USB_INTERFACE build_src_filter = ${ThinkNode_M6.build_src_filter} + + diff --git a/variants/thinknode_m6/variant.cpp b/variants/thinknode_m6/variant.cpp index c88f387db..79930e2d0 100644 --- a/variants/thinknode_m6/variant.cpp +++ b/variants/thinknode_m6/variant.cpp @@ -30,6 +30,8 @@ void initVariant() { digitalWrite(PIN_GPS_STANDBY, HIGH); pinMode(PIN_GPS_EN, OUTPUT); digitalWrite(PIN_GPS_EN, HIGH); - pinMode(PIN_GPS_RESET, OUTPUT); - digitalWrite(PIN_GPS_RESET, HIGH); + // PIN_GPS_RESET (pin 29 / REINIT) is intentionally left floating (input). + // Driving it HIGH holds the L76K silent, so it never streams NMEA and the + // firmware reports "no GPS". Letting it float lets the module run, matching + // the Meshtastic M6 variant. See GPS_RESET (-1) in variant.h. } diff --git a/variants/thinknode_m6/variant.h b/variants/thinknode_m6/variant.h index 70fd65062..0a4025f3b 100644 --- a/variants/thinknode_m6/variant.h +++ b/variants/thinknode_m6/variant.h @@ -102,7 +102,12 @@ #define PIN_GPS_RX (2) #define PIN_GPS_TX (3) #define PIN_GPS_EN (6) // EN -#define PIN_GPS_RESET (29) +#define PIN_GPS_RESET (29) // REINIT - must FLOAT; driving it (esp. HIGH) silences the L76K +// The M6's L76K streams NMEA on its own and must not have its REINIT pin driven. +// Tell the location provider there is no reset pin so it never touches pin 29 +// (driving it HIGH holds the module silent). Matches Meshtastic, which leaves +// this pin as an input. See variant.cpp (pin 29 is intentionally not configured). +#define GPS_RESET (-1) #define PIN_GPS_STANDBY (30) // STANDBY #define PIN_GPS_PPS (31) #define GPS_BAUD_RATE 9600 diff --git a/variants/thinknode_m7/ThinkNodeM7Board.cpp b/variants/thinknode_m7/ThinkNodeM7Board.cpp new file mode 100644 index 000000000..6b1bf8602 --- /dev/null +++ b/variants/thinknode_m7/ThinkNodeM7Board.cpp @@ -0,0 +1,17 @@ +#include "ThinkNodeM7Board.h" + +void ThinkNodeM7Board::begin() { + ESP32Board::begin(); +} + +void ThinkNodeM7Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { + esp_deep_sleep_start(); +} + +void ThinkNodeM7Board::powerOff() { + enterDeepSleep(0); +} + +const char* ThinkNodeM7Board::getManufacturerName() const { + return "Elecrow ThinkNode M7"; +} diff --git a/variants/thinknode_m7/ThinkNodeM7Board.h b/variants/thinknode_m7/ThinkNodeM7Board.h new file mode 100644 index 000000000..f591b5acc --- /dev/null +++ b/variants/thinknode_m7/ThinkNodeM7Board.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include "variant.h" +#include "NullDisplayDriver.h" +#include "MomentaryButton.h" + +class ThinkNodeM7Board : public ESP32Board { + +public: + void begin(); + void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); + void powerOff() override; + const char* getManufacturerName() const override; + void onBeforeTransmit() override { + digitalWrite(P_LORA_TX_LED, LOW); + } + void onAfterTransmit() override { + digitalWrite(P_LORA_TX_LED, HIGH); + } +}; diff --git a/variants/thinknode_m7/pins_arduino.h b/variants/thinknode_m7/pins_arduino.h new file mode 100644 index 000000000..845a8db1d --- /dev/null +++ b/variants/thinknode_m7/pins_arduino.h @@ -0,0 +1,19 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// The default Wire will be mapped to PMU and RTC +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +// Default SPI is the LR1110 radio bus +static const uint8_t SS = 12; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 9; +static const uint8_t SCK = 11; + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini new file mode 100644 index 000000000..7da141120 --- /dev/null +++ b/variants/thinknode_m7/platformio.ini @@ -0,0 +1,273 @@ +[ThinkNode_M7] +extends = esp32_base +board = thinknode_m7 +board_upload.flash_size = 8MB +board_build.partitions = default_8MB.csv +build_flags = ${esp32_base.build_flags} + -I src/helpers/esp32 + -I variants/thinknode_m7 + -I src/helpers/sensors + -I src/helpers/ui + -D THINKNODE_M7 + -D PIN_USER_BTN_ANA=4 + -D PIN_STATUS_LED=3 ; green + -D LED_STATE_ON=LOW + -D RADIO_CLASS=CustomLR1110 + -D WRAPPER_CLASS=CustomLR1110Wrapper + -D USE_LR1110 + -D LORA_TX_POWER=22 + -D RF_SWITCH_TABLE + -D RX_BOOSTED_GAIN=true + -D P_LORA_BUSY=13 + -D P_LORA_SCLK=11 + -D P_LORA_NSS=12 + -D P_LORA_DIO_1=38 + -D P_LORA_MISO=9 + -D P_LORA_MOSI=10 + -D P_LORA_RESET=39 + -D P_LORA_TX_LED=46 ; blue + -D LR11X0_DIO_AS_RF_SWITCH=true + -D LR11X0_DIO3_TCXO_VOLTAGE=1.8 +; No blanket + here: it would undo arduino_base's exclusion of the +; MQTT-only sources, which need Timezone/NTPClient that only observer envs pull in. +build_src_filter = ${esp32_base.build_src_filter} + + + + + +<../variants/thinknode_m7> +lib_deps = ${esp32_base.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 + +[ThinkNode_M7_ethernet] +build_flags = + -D ETHERNET_ENABLED + -D ETHERNET_USE_CH390 + -D ETHERNET_CLASS=CH390EthernetInterface + -D ETH_MISO_PIN=14 + -D ETH_MOSI_PIN=48 + -D ETH_SCLK_PIN=47 + -D ETH_CS_PIN=21 + -D ETH_INT_PIN=45 + -D ETHERNET_DEBUG_LOGGING=1 +build_src_filter = + + + + +lib_deps = + https://github.com/liamcottle/ESP32-CH390.git#47b401f1de546118b03c18b5689dedec45871f2d + +[env:ThinkNode_M7_repeater] +extends = ThinkNode_M7 +build_src_filter = ${ThinkNode_M7.build_src_filter} + +<../examples/simple_repeater/*.cpp> +build_flags = + ${ThinkNode_M7.build_flags} + -D ADVERT_NAME='"ThinkNode M7 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = + ${ThinkNode_M7.lib_deps} + ${esp32_ota.lib_deps} + +[env:ThinkNode_M7_room_server] +extends = ThinkNode_M7 +build_src_filter = ${ThinkNode_M7.build_src_filter} + +<../examples/simple_room_server> +build_flags = + ${ThinkNode_M7.build_flags} + -D ADVERT_NAME='"ThinkNode M7 Room Server"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = + ${ThinkNode_M7.lib_deps} + ${esp32_ota.lib_deps} + +[env:ThinkNode_M7_companion_radio_ble] +extends = ThinkNode_M7 +build_flags = + ${ThinkNode_M7.build_flags} + ${ThinkNode_M7_ethernet.build_flags} + -I examples/companion_radio/ui-orig + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 + ; -D BLE_DEBUG_LOGGING=1 + ; -D MESH_PACKET_LOGGING=1 +build_src_filter = ${ThinkNode_M7.build_src_filter} + ${ThinkNode_M7_ethernet.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = + ${ThinkNode_M7.lib_deps} + ${ThinkNode_M7_ethernet.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:ThinkNode_M7_companion_radio_usb] +extends = ThinkNode_M7 +build_flags = + ${ThinkNode_M7.build_flags} + -I examples/companion_radio/ui-orig + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE +build_src_filter = ${ThinkNode_M7.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = + ${ThinkNode_M7.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:ThinkNode_M7_companion_radio_wifi] +extends = ThinkNode_M7 +build_flags = + ${ThinkNode_M7.build_flags} + -I examples/companion_radio/ui-orig + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=NullDisplayDriver + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +build_src_filter = ${ThinkNode_M7.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = + ${ThinkNode_M7.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:ThinkNode_M7_companion_radio_ethernet] +extends = ThinkNode_M7 +build_flags = + ${ThinkNode_M7.build_flags} + ${ThinkNode_M7_ethernet.build_flags} + -I examples/companion_radio/ui-orig + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=NullDisplayDriver + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${ThinkNode_M7.build_src_filter} + ${ThinkNode_M7_ethernet.build_src_filter} + + + + + + ; defines UIColor::window_bkg for DISPLAY_CLASS + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = ${ThinkNode_M7.lib_deps} + ${ThinkNode_M7_ethernet.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:ThinkNode_M7_kiss_modem] +extends = ThinkNode_M7 +build_src_filter = ${ThinkNode_M7.build_src_filter} + +<../examples/kiss_modem/> + +; MQTT observer envs are WiFi-only: the bridge's link management is bound to the +; WiFi station API, so the onboard CH390 cannot carry MQTT yet. The board has +; PSRAM, so MAX_NEIGHBOURS enables WITH_MQTT_NEIGHBORS (see MQTTBridge.h). +[env:ThinkNode_M7_repeater_observer_mqtt] +extends = ThinkNode_M7 +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${ThinkNode_M7.build_flags} + -D ADVERT_NAME='"MQTT Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_BRIDGE=1 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 +# -D MESH_PACKET_LOGGING=1 +# -D MESH_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D ESP32_CPU_FREQ=160 + -D WITH_SNMP=1 +# -D WIFI_SSID='"ssid"' +# -D WIFI_PWD='"password"' +# -D MQTT_SERVER='"your-mqtt-broker.com"' +# -D MQTT_PORT=1883 +# -D MQTT_USERNAME='"your-username"' +# -D MQTT_PASSWORD='"your-password"' +build_src_filter = ${ThinkNode_M7.build_src_filter} + + + + + + + + + +<../examples/simple_repeater> +lib_deps = + ${ThinkNode_M7.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent + +[env:ThinkNode_M7_room_server_observer_mqtt] +extends = ThinkNode_M7 +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${ThinkNode_M7.build_flags} + -D ADVERT_NAME='"ThinkNode M7 Room Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_BRIDGE=1 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 +# -D MESH_PACKET_LOGGING=1 +# -D MESH_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D ESP32_CPU_FREQ=160 + -D WITH_SNMP=1 +# -D WIFI_SSID='"ssid"' +# -D WIFI_PWD='"password"' +# -D MQTT_SERVER='"your-mqtt-broker.com"' +# -D MQTT_PORT=1883 +# -D MQTT_USERNAME='"your-username"' +# -D MQTT_PASSWORD='"your-password"' +build_src_filter = ${ThinkNode_M7.build_src_filter} + + + + + + + + + +<../examples/simple_room_server> +lib_deps = + ${ThinkNode_M7.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent diff --git a/variants/thinknode_m7/target.cpp b/variants/thinknode_m7/target.cpp new file mode 100644 index 000000000..f4b898d8e --- /dev/null +++ b/variants/thinknode_m7/target.cpp @@ -0,0 +1,84 @@ +#include +#include "target.h" +#include + +ThinkNodeM7Board board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#ifdef ENV_INCLUDE_GPS +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); +EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else +EnvironmentSensorManager sensors = EnvironmentSensorManager(); +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; +#endif + +#ifdef RF_SWITCH_TABLE +static const uint32_t rfswitch_dios[Module::RFSWITCH_MAX_PINS] = { + RADIOLIB_LR11X0_DIO5, + RADIOLIB_LR11X0_DIO6, + RADIOLIB_NC, + RADIOLIB_NC, + RADIOLIB_NC +}; + +static const Module::RfSwitchMode_t rfswitch_table[] = { + // mode DIO5 DIO6 + {LR11x0::MODE_STBY, {LOW, LOW}}, {LR11x0::MODE_RX, {HIGH, LOW}}, + {LR11x0::MODE_TX, {HIGH, HIGH}}, {LR11x0::MODE_TX_HP, {LOW, HIGH}}, + {LR11x0::MODE_TX_HF, {LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW}}, + {LR11x0::MODE_WIFI, {LOW, LOW}}, END_OF_MODE_TABLE, + END_OF_MODE_TABLE, +}; +#endif + +#ifndef LORA_CR + #define LORA_CR 5 +#endif + +bool radio_init() { + rtc_clock.begin(Wire); + +#ifdef LR11X0_DIO3_TCXO_VOLTAGE + float tcxo = LR11X0_DIO3_TCXO_VOLTAGE; +#else + float tcxo = 1.6f; +#endif + + spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS); + + int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(status); + return false; // fail + } + + radio.setCRC(2); + radio.explicitHeader(); + +#ifdef RF_SWITCH_TABLE + radio.setRfSwitchTable(rfswitch_dios, rfswitch_table); +#endif +#ifdef RX_BOOSTED_GAIN + radio.setRxBoostedGainMode(RX_BOOSTED_GAIN); +#endif + + return true; // success +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + + diff --git a/variants/thinknode_m7/target.h b/variants/thinknode_m7/target.h new file mode 100644 index 000000000..34213f57c --- /dev/null +++ b/variants/thinknode_m7/target.h @@ -0,0 +1,29 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #include "NullDisplayDriver.h" +#endif + +extern ThinkNodeM7Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern NullDisplayDriver display; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + + + \ No newline at end of file diff --git a/variants/thinknode_m7/variant.cpp b/variants/thinknode_m7/variant.cpp new file mode 100644 index 000000000..e056ab647 --- /dev/null +++ b/variants/thinknode_m7/variant.cpp @@ -0,0 +1,8 @@ +#include "variant.h" +#include "Arduino.h" + +void initVariant() +{ + pinMode(P_LORA_TX_LED, OUTPUT); + digitalWrite(P_LORA_TX_LED, HIGH); +} \ No newline at end of file diff --git a/variants/thinknode_m7/variant.h b/variants/thinknode_m7/variant.h new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/variants/thinknode_m7/variant.h @@ -0,0 +1 @@ + diff --git a/variants/thinknode_m9/ThinkNodeM9Board.cpp b/variants/thinknode_m9/ThinkNodeM9Board.cpp new file mode 100644 index 000000000..bf558639d --- /dev/null +++ b/variants/thinknode_m9/ThinkNodeM9Board.cpp @@ -0,0 +1,23 @@ +#include "ThinkNodeM9Board.h" + +void ThinkNodeM9Board::begin() { + + // power on screen + pinMode(VEXT_ENABLE, OUTPUT); + digitalWrite(VEXT_ENABLE, VEXT_ON_VALUE); + + ESP32Board::begin(); + +} + +void ThinkNodeM9Board::powerOff() { + enterDeepSleep(0); +} + +uint32_t ThinkNodeM9Board::getIRQGpio() { + return LORA_DIO0; +} + +const char* ThinkNodeM9Board::getManufacturerName() const { + return "Elecrow ThinkNode M9"; +} diff --git a/variants/thinknode_m9/ThinkNodeM9Board.h b/variants/thinknode_m9/ThinkNodeM9Board.h new file mode 100644 index 000000000..d5f68e055 --- /dev/null +++ b/variants/thinknode_m9/ThinkNodeM9Board.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +class ThinkNodeM9Board : public ESP32Board { + +public: + void begin(); + void powerOff() override; + const char* getManufacturerName() const override; + uint32_t getIRQGpio() override; +}; diff --git a/variants/thinknode_m9/pins_arduino.h b/variants/thinknode_m9/pins_arduino.h new file mode 100755 index 000000000..1886eea03 --- /dev/null +++ b/variants/thinknode_m9/pins_arduino.h @@ -0,0 +1,64 @@ +// Need this file for ESP32-S3 +// No need to modify this file, changes to pins imported from variant.h +// Most is similar to https://github.com/espressif/arduino-esp32/blob/master/variants/esp32s3/pins_arduino.h + +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// Serial +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +// Default SPI will be mapped to Radio +static const uint8_t SS = LORA_CS; +static const uint8_t SCK = SPI_SCK; +static const uint8_t MOSI = SPI_MOSI; +static const uint8_t MISO = SPI_MISO; + +// The default Wire will be mapped to PMU and RTC +static const uint8_t SCL = I2C_SCL; +static const uint8_t SDA = I2C_SDA; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif /* Pins_Arduino_h */ diff --git a/variants/thinknode_m9/platformio.ini b/variants/thinknode_m9/platformio.ini new file mode 100755 index 000000000..09b1391d6 --- /dev/null +++ b/variants/thinknode_m9/platformio.ini @@ -0,0 +1,161 @@ +[ThinkNode_M9] +extends = esp32_base +board = thinknode_m9 +board_check = true +board_build.partitions = default_16MB.csv +upload_protocol = esptool +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/thinknode_m9 + -I src/helpers/esp32 + -I src/helpers/sensors + -D THINKNODE_M9 + -D PIN_BUZZER=9 + -D PIN_BOARD_SCL=6 + -D PIN_BOARD_SDA=7 + -D P_LORA_NSS=39 + -D P_LORA_RESET=45 + -D P_LORA_BUSY=41 + -D P_LORA_SCLK=40 + -D P_LORA_MISO=38 + -D P_LORA_MOSI=47 + -D P_LORA_DIO_1=42 + -D USE_LR1110 + -D RF_SWITCH_TABLE + -D RADIO_CLASS=CustomLR1110 + -D WRAPPER_CLASS=CustomLR1110Wrapper + -D LR11X0_DIO_AS_RF_SWITCH=true + -D LR11X0_DIO3_TCXO_VOLTAGE=3.3 + -D LORA_TX_POWER=22 + -D DISPLAY_CLASS=ST7789Display + -D DISPLAY_FLIP_VERTICALLY=1 + -D DISPLAY_SCALE_X=2.5f ; 320 / 128 + -D DISPLAY_SCALE_Y=3.75f ; 240 / 64 + -D ST7789 + -D PIN_TFT_VDD_CTL=-1 + -D PIN_TFT_LEDA_CTL=17 + -D PIN_TFT_RST=14 + -D PIN_GPS_RX=3 + -D PIN_GPS_TX=2 + -D PIN_GPS_EN=11 + -D PIN_GPS_EN_ACTIVE=LOW + -D PIN_GPS_RESET=5 + -D PIN_GPS_RESET_ACTIVE=HIGH + -D GPS_BAUD_RATE=115200 + -D ENV_INCLUDE_GPS=1 + -D PIN_VBAT_READ=13 +build_src_filter = ${esp32_base.build_src_filter} + + + + + + + + + + + +<../variants/thinknode_m9> +lib_deps = ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + adafruit/Adafruit GFX Library @ ^1.12.1 + +[env:ThinkNode_M9_repeater_] +extends = ThinkNode_M9 +build_flags = + ${ThinkNode_M9.build_flags} + -D ADVERT_NAME='"ThinkNode M9 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + ;-D MESH_PACKET_LOGGING=1 + ;-D MESH_DEBUG=1 +build_src_filter = ${ThinkNode_M9.build_src_filter} + +<../examples/simple_repeater/*.cpp> +lib_deps = + ${ThinkNode_M9.lib_deps} + ${esp32_ota.lib_deps} + +[env:ThinkNode_M9_room_server_] +extends = ThinkNode_M9 +build_src_filter = ${ThinkNode_M9.build_src_filter} + +<../examples/simple_room_server> +build_flags = + ${ThinkNode_M9.build_flags} + -D ADVERT_NAME='"ThinkNode M9 Room Server"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = + ${ThinkNode_M9.lib_deps} + ${esp32_ota.lib_deps} + +[env:ThinkNode_M9_companion_radio_ble_] +extends = ThinkNode_M9 +build_flags = + ${ThinkNode_M9.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 + ; -D BLE_DEBUG_LOGGING=1 + ; -D MESH_PACKET_LOGGING=1 +build_src_filter = ${ThinkNode_M9.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${ThinkNode_M9.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:ThinkNode_M9_companion_radio_usb_] +extends = ThinkNode_M9 +build_flags = + ${ThinkNode_M9.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE +build_src_filter = ${ThinkNode_M9.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${ThinkNode_M9.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:ThinkNode_M9_companion_radio_wifi_] +extends = ThinkNode_M9 +build_flags = + ${ThinkNode_M9.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 + ; -D MESH_PACKET_LOGGING=1 +build_src_filter = ${ThinkNode_M9.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${ThinkNode_M9.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:ThinkNode_M9_kiss_modem] +extends = ThinkNode_M9 +build_src_filter = ${ThinkNode_M9.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/thinknode_m9/target.cpp b/variants/thinknode_m9/target.cpp new file mode 100644 index 000000000..3cbd7d758 --- /dev/null +++ b/variants/thinknode_m9/target.cpp @@ -0,0 +1,78 @@ +#include +#include "target.h" + +ThinkNodeM9Board board; + +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#ifdef ENV_INCLUDE_GPS +#include +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); +EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else +EnvironmentSensorManager sensors = EnvironmentSensorManager(); +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; +#endif + +#ifdef RF_SWITCH_TABLE +static const uint32_t rfswitch_dios[Module::RFSWITCH_MAX_PINS] = { + RADIOLIB_LR11X0_DIO5, + RADIOLIB_LR11X0_DIO6, + RADIOLIB_NC, + RADIOLIB_NC, + RADIOLIB_NC +}; + +static const Module::RfSwitchMode_t rfswitch_table[] = { + // mode DIO5 DIO6 + {LR11x0::MODE_STBY, {LOW, LOW}}, {LR11x0::MODE_RX, {HIGH, LOW}}, + {LR11x0::MODE_TX, {HIGH, HIGH}}, {LR11x0::MODE_TX_HP, {LOW, HIGH}}, + {LR11x0::MODE_TX_HF, {LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW}}, + {LR11x0::MODE_WIFI, {LOW, LOW}}, END_OF_MODE_TABLE, +}; +#endif + +#ifndef LORA_CR + #define LORA_CR 5 +#endif + +bool radio_init() { + rtc_clock.begin(Wire); + +#ifdef LR11X0_DIO3_TCXO_VOLTAGE + float tcxo = LR11X0_DIO3_TCXO_VOLTAGE; +#else + float tcxo = 1.6f; +#endif + + int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(status); + return false; // fail + } + + radio.setCRC(2); + radio.explicitHeader(); + +#ifdef RF_SWITCH_TABLE + radio.setRfSwitchTable(rfswitch_dios, rfswitch_table); +#endif +#ifdef RX_BOOSTED_GAIN + radio.setRxBoostedGainMode(RX_BOOSTED_GAIN); +#endif + + return true; // success +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} \ No newline at end of file diff --git a/variants/thinknode_m9/target.h b/variants/thinknode_m9/target.h new file mode 100644 index 000000000..f083a9f1b --- /dev/null +++ b/variants/thinknode_m9/target.h @@ -0,0 +1,29 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #include + #include +#endif + +extern ThinkNodeM9Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + diff --git a/variants/thinknode_m9/variant.cpp b/variants/thinknode_m9/variant.cpp new file mode 100644 index 000000000..4ac6a7827 --- /dev/null +++ b/variants/thinknode_m9/variant.cpp @@ -0,0 +1,7 @@ +#include "variant.h" +#include + + +void initVariant() { + +} diff --git a/variants/thinknode_m9/variant.h b/variants/thinknode_m9/variant.h new file mode 100755 index 000000000..37f8b0763 --- /dev/null +++ b/variants/thinknode_m9/variant.h @@ -0,0 +1,114 @@ +/*Power*/ +#define VEXT_ENABLE 18 +#define VEXT_ON_VALUE LOW +#define PIN_GPS_EN 11 +#define GPS_EN_ACTIVE LOW + +/*Wire Interface*/ +#define WIRE_INTERFACES_COUNT 2 +// I2C keybuttons +#define I2C_SCL1 21 +#define I2C_SDA1 20 +#define KB_INT 12 +// I2C peripheral` +#define I2C_SCL 6 +#define I2C_SDA 7 + +/*LED*/ +#define PIN_LED 13 +#define KB_LED 46 + +/*BUZZER*/ +#define PIN_BUZZER 9 + +/*CHARGE_CHECK*/ +#define DONE 8 +#define EXT_PWR_DETECT 1 +#define EXT_CHRG_DETECT 1 +#define EXT_CHRG_DETECT_VALUE LOW +/*GPS*/ +#define GPS_L76K +#define GPS_BAUDRATE 9600 +#define PIN_GPS_RESET 5 +#define PIN_GPS_PPS 4 +#define PIN_GPS_STANDBY 10 // An output to wake GPS, low means allow sleep, high means force wake +#define GPS_TX_PIN 3 +#define GPS_RX_PIN 2 +#define GPS_THREAD_INTERVAL 50 + +/*SPI*/ +#define SPI_MOSI 47 +#define SPI_SCK 40 +#define SPI_MISO 38 + +/*SD Card*/ +#define SDCARD_CS 48 +#define SD_SPI_FREQUENCY 80000000U + +/*Screen*/ +// #define USE_ST7789 1 +#define ST7789_CS 16 +#define ST7789_RS 15 +#define ST7789_TE 19 +#define ST7789_SDA SPI_MOSI // MOSI +#define ST7789_SCK SPI_SCK +#define ST7789_RESET 14 +#define ST7789_MISO SPI_MISO +#define ST7789_BUSY -1 +#define ST7789_BL 17 +#define ST7789_SPI_HOST SPI2_HOST +#define SPI_FREQUENCY 80000000 +#define SPI_READ_FREQUENCY 16000000 + +#define USE_TFTDISPLAY 1 +#define TFT_CS ST7789_CS +#define TFT_BL ST7789_BL +#define TFT_HEIGHT 320 +#define TFT_WIDTH 240 +#define TFT_OFFSET_X 0 +#define TFT_OFFSET_Y 0 +#define TFT_OFFSET_ROTATION 0 +#define TFT_PWM_FREQ 44000 +#define TFT_PWM_CHANNEL 7 +#define TFT_INVERT_LIGHT true +#define TFT_BACKLIGHT_ON LOW +#define SCREEN_ROTATE +#define SCREEN_TRANSITION_FRAMERATE 5 +#define BRIGHTNESS_DEFAULT 128 + +#define LGFX_PANEL ST7789 +#define DISPLAY_SIZE 320x240 +#define LGFX_ROTATION 0 +#define LGFX_CFG_HOST SPI2_HOST +#define LGFX_PIN_SCK ST7789_SCK +#define LGFX_PIN_MOSI ST7789_SDA +#define LGFX_PIN_DC ST7789_RS +#define LGFX_PIN_CS ST7789_CS +#define LGFX_PIN_BL ST7789_BL +#define LGFX_SCREEN_WIDTH TFT_WIDTH +#define LGFX_SCREEN_HEIGHT TFT_HEIGHT +#define LGFX_INVERT_LIGHT true +#define LGFX_PWM_FREQ 44000 +#define LGFX_PWM_CHANNEL 7 + +/*Lora radio*/ +#define LORA_SCK SPI_SCK +#define LORA_MISO SPI_MISO +#define LORA_MOSI SPI_MOSI +#define LORA_CS 39 +#define LORA_RESET 45 +#define LORA_DIO0 41 + +#define USE_LR1110 +#define LR1110_IRQ_PIN 42 +#define LR1110_NRESET_PIN LORA_RESET +#define LR1110_BUSY_PIN LORA_DIO0 +#define LR1110_SPI_NSS_PIN LORA_CS +#define LR1110_SPI_SCK_PIN LORA_SCK +#define LR1110_SPI_MOSI_PIN LORA_MOSI +#define LR1110_SPI_MISO_PIN LORA_MISO +#define LR11X0_DIO3_TCXO_VOLTAGE 3.3 +#define LR11X0_DIO_AS_RF_SWITCH + +/*RTC*/ +#define PCF8563_RTC 0x51 diff --git a/variants/tiny_relay/platformio.ini b/variants/tiny_relay/platformio.ini index 82cb251fd..13c2ec1cb 100644 --- a/variants/tiny_relay/platformio.ini +++ b/variants/tiny_relay/platformio.ini @@ -44,6 +44,7 @@ build_flags = ${Tiny_Relay.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 -D MAX_LORA_TX_POWER=22 + -D ENABLE_USB_INTERFACE build_src_filter = ${Tiny_Relay.build_src_filter} +<../examples/companion_radio/*.cpp> lib_deps = ${Tiny_Relay.lib_deps} diff --git a/variants/waveshare_rp2040_lora/platformio.ini b/variants/waveshare_rp2040_lora/platformio.ini index 7dfe14012..db1ec6038 100644 --- a/variants/waveshare_rp2040_lora/platformio.ini +++ b/variants/waveshare_rp2040_lora/platformio.ini @@ -84,6 +84,7 @@ extends = waveshare_rp2040_lora build_flags = ${waveshare_rp2040_lora.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${waveshare_rp2040_lora.build_src_filter} diff --git a/variants/wio-e5-dev/platformio.ini b/variants/wio-e5-dev/platformio.ini index 22bdc3c83..82b3781e3 100644 --- a/variants/wio-e5-dev/platformio.ini +++ b/variants/wio-e5-dev/platformio.ini @@ -46,6 +46,7 @@ build_flags = ${lora_e5.build_flags} -D LORA_TX_POWER=22 -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE build_src_filter = ${lora_e5.build_src_filter} +<../examples/companion_radio/*.cpp> lib_deps = ${lora_e5.lib_deps} diff --git a/variants/wio-e5-mini/NullDisplayDriver.h b/variants/wio-e5-mini/NullDisplayDriver.h deleted file mode 100644 index 2a9670bd2..000000000 --- a/variants/wio-e5-mini/NullDisplayDriver.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include - -class NullDisplayDriver : public DisplayDriver { -public: - NullDisplayDriver() : DisplayDriver(128, 64) { } - bool begin() { return false; } // not present - - bool isOn() override { return false; } - void turnOn() override { } - void turnOff() override { } - void clear() override { } - void startFrame(Color bkg = DARK) override { } - void setTextSize(int sz) override { } - void setColor(Color c) override { } - void setCursor(int x, int y) override { } - void print(const char* str) override { } - void fillRect(int x, int y, int w, int h) override { } - void drawRect(int x, int y, int w, int h) override { } - void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { } - uint16_t getTextWidth(const char* str) override { return 0; } - void endFrame() { } -}; diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 82f01331b..021c7db20 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -44,7 +44,9 @@ build_flags = ${lora_e5_mini.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 -D DISPLAY_CLASS=NullDisplayDriver + -D ENABLE_USB_INTERFACE build_src_filter = ${lora_e5_mini.build_src_filter} + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${lora_e5_mini.lib_deps} diff --git a/variants/wio-e5-mini/target.h b/variants/wio-e5-mini/target.h index 4807e0f79..f4d6a0884 100644 --- a/variants/wio-e5-mini/target.h +++ b/variants/wio-e5-mini/target.h @@ -8,7 +8,7 @@ #include #include #ifdef DISPLAY_CLASS - #include "NullDisplayDriver.h" + #include #endif #include diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index 052238e66..e376e0666 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -35,6 +35,6 @@ public: } void powerOff() override { - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 7bb175bb9..fc958ea2d 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -68,6 +68,7 @@ build_flags = ${WioTrackerL1.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=12 -D QSPIFLASH=1 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${WioTrackerL1.build_src_filter} diff --git a/variants/xiao_c3/XiaoC3Board.h b/variants/xiao_c3/XiaoC3Board.h index 6ea1c15ff..c5700c688 100644 --- a/variants/xiao_c3/XiaoC3Board.h +++ b/variants/xiao_c3/XiaoC3Board.h @@ -3,7 +3,6 @@ #include #include -#include #include class XiaoC3Board : public ESP32Board { @@ -40,37 +39,6 @@ public: #endif } - void enterDeepSleep(uint32_t secs, int8_t wake_pin = -1) { - gpio_set_direction(gpio_num_t(P_LORA_DIO_1), GPIO_MODE_INPUT); - if (wake_pin >= 0) { - gpio_set_direction((gpio_num_t)wake_pin, GPIO_MODE_INPUT); - } - - //hold disable, isolate and power domain config functions may be unnecessary - //gpio_deep_sleep_hold_dis(); - //esp_sleep_config_gpio_isolate(); - gpio_deep_sleep_hold_en(); - -#if defined(LORA_TX_BOOST_PIN) - gpio_hold_en((gpio_num_t) LORA_TX_BOOST_PIN); - gpio_deep_sleep_hold_en(); -#endif - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - if (wake_pin >= 0) { - esp_deep_sleep_enable_gpio_wakeup((1 << P_LORA_DIO_1) | (1 << wake_pin), ESP_GPIO_WAKEUP_GPIO_HIGH); - } else { - esp_deep_sleep_enable_gpio_wakeup(1 << P_LORA_DIO_1, ESP_GPIO_WAKEUP_GPIO_HIGH); - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - #if defined(LORA_TX_BOOST_PIN) || defined(P_LORA_TX_LED) void onBeforeTransmit() override { #if defined(P_LORA_TX_LED) diff --git a/variants/xiao_c3/platformio.ini b/variants/xiao_c3/platformio.ini index c0e8458d0..587c5c273 100644 --- a/variants/xiao_c3/platformio.ini +++ b/variants/xiao_c3/platformio.ini @@ -100,6 +100,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 diff --git a/variants/xiao_c6/platformio.ini b/variants/xiao_c6/platformio.ini index 9f504b8ed..0d8c2a79d 100644 --- a/variants/xiao_c6/platformio.ini +++ b/variants/xiao_c6/platformio.ini @@ -1,6 +1,7 @@ [Xiao_C6] extends = esp32c6_base board = esp32-c6-devkitm-1 +board_build.flash_mode = dio board_build.partitions = min_spiffs.csv ; get around 4mb flash limit build_flags = ${esp32c6_base.build_flags} diff --git a/variants/xiao_nrf52/XiaoNrf52Board.h b/variants/xiao_nrf52/XiaoNrf52Board.h index 2790dbad9..e71e7f651 100644 --- a/variants/xiao_nrf52/XiaoNrf52Board.h +++ b/variants/xiao_nrf52/XiaoNrf52Board.h @@ -47,10 +47,10 @@ public: #ifdef PIN_USER_BTN // configure button press to wake up when in powered off state - nrf_gpio_cfg_sense_input(digitalPinToInterrupt(g_ADigitalPinMap[PIN_USER_BTN]), NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_LOW); + nrf_gpio_cfg_sense_input(digitalPinToInterrupt(g_ADigitalPinMap[PIN_USER_BTN]), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini index a08543368..1076bd8de 100644 --- a/variants/xiao_nrf52/platformio.ini +++ b/variants/xiao_nrf52/platformio.ini @@ -53,7 +53,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 -D QSPIFLASH=1 build_src_filter = ${Xiao_nrf52.build_src_filter} + @@ -74,6 +74,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 -D QSPIFLASH=1 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Xiao_nrf52.build_src_filter} diff --git a/variants/xiao_nrf52/target.cpp b/variants/xiao_nrf52/target.cpp index ab6fe279a..def1dbbea 100644 --- a/variants/xiao_nrf52/target.cpp +++ b/variants/xiao_nrf52/target.cpp @@ -4,6 +4,7 @@ #ifdef DISPLAY_CLASS DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); #endif XiaoNrf52Board board; diff --git a/variants/xiao_nrf52/target.h b/variants/xiao_nrf52/target.h index bb3d2a81f..20093904a 100644 --- a/variants/xiao_nrf52/target.h +++ b/variants/xiao_nrf52/target.h @@ -11,7 +11,9 @@ #ifdef DISPLAY_CLASS #include + #include extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; #endif extern XiaoNrf52Board board; diff --git a/variants/xiao_rp2040/platformio.ini b/variants/xiao_rp2040/platformio.ini index ca00e38b5..cbc50a51d 100644 --- a/variants/xiao_rp2040/platformio.ini +++ b/variants/xiao_rp2040/platformio.ini @@ -38,8 +38,8 @@ build_flags = ${Xiao_rp2040.build_flags} -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 build_src_filter = ${Xiao_rp2040.build_src_filter} +<../examples/simple_repeater> @@ -61,6 +61,7 @@ extends = Xiao_rp2040 build_flags = ${Xiao_rp2040.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Xiao_rp2040.build_src_filter} diff --git a/variants/xiao_s3/platformio.ini b/variants/xiao_s3/platformio.ini index 22464e7d8..1632d1120 100644 --- a/variants/xiao_s3/platformio.ini +++ b/variants/xiao_s3/platformio.ini @@ -123,6 +123,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index ca631ca27..78c582d9b 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -111,8 +111,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D MESH_PACKET_LOGGING=1 @@ -135,7 +133,7 @@ lib_deps = ${Xiao_S3_WIO.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -156,8 +154,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -173,7 +169,7 @@ lib_deps = ${Xiao_S3_WIO.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -218,6 +214,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Xiao_S3_WIO.build_src_filter} diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 000000000..ab7b43d6a --- /dev/null +++ b/webui/index.html @@ -0,0 +1,2105 @@ + + + + + +MeshCore Config + + + +
+ + + + +
+

MeshCore

+
connecting…
+
+ + + +
+
+ + +
+
+

Admin login

+

Enter this node's admin password (the same one used for remote CLI admin).

+
+
+
+ +
+
+
+
+ + +
+
In the WiFi sign-in popup? It closes if your phone sleeps. + Open in your browser to keep your progress.
+
+ +
+
+

Step 1 · WiFi

+

Connect this node to your WiFi network so it can reach the MQTT servers.

+
+
+ + +
+
+ +
Leave blank for an open network. 2.4 GHz networks only.
+
+ +
Shown on the mesh and in MQTT status messages.
+
+ +
+ +
+
+

Step 2 · Radio

+

Nodes only hear each other on identical radio settings. Pick the preset your local mesh uses.

+
+ +
+
+ +
Maximum legal power varies by region — check local rules.
+
RepeatForward mesh traffic. Off = listen-only (still observes and publishes). +
+
Need settings that aren't listed? Choose Keep current settings, finish setup, then fine-tune in the Advanced editor.
+
+
+

Admin password

+

Replaces the factory password. Used to log in to this page later and to administer the node remotely over the mesh.

+
+ +
Maximum 15 characters.
+
+
+
+
+ + +
+
+ +
+
+

Step 3 · MQTT

+
+ +
How this observer identifies itself in published status/packet messages. Informational only — not used for topics or authentication. Prefilled with the node name.
+
+ +
Nearest airport code, e.g. DEN. Used in topic paths.
+
+ +
Optional — 64-char hex public key of your companion node. Included in auth JWTs so services that support it can let you claim this node.
+
+ +
Optional — also included in auth JWTs for claiming this node on some services.
+

Servers

+
+
+
+ + +
+
+ +
+
+

Step 4 · Review & save

+
+
+
Saving reboots the node into normal operation. To change settings later, run start webconfig from the serial console.
+
+ + +
+
+
+ +

Advanced editor

+
+ + +
+
+ + + + + +
+ +
+
+

Node

+
+
+
+
+
+
+
+

Admin password

+
Signs in here and authenticates remote admin commands over the mesh. Leave blank to keep the current password. A change takes effect immediately — this session stays signed in, but the next login needs the new password. During first-time setup a new password is required before the node can reboot into normal operation.
+
+ +
Maximum 15 characters.
+
+
+
+
+

LoRa radio reboot to apply

+
Frequency, bandwidth, SF and CR must match your mesh exactly — a wrong value takes this node off the air until fixed over serial.
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Advanced

+
+
+
+
+
+
+
CAD (listen before transmit)Channel activity detection +
+
RX boosted gainSX126x receivers only +
+
RepeatForward mesh traffic. Off = listen-only (still observes and publishes). +
+
+
+
0 = off
+
+
0 = off, else 3–168
+
+
+
+

Flooding & loops

+
Hop limits for flood traffic this node forwards. Lower values contain noisy neighbours; too low takes parts of the mesh out of reach.
+
+
+
0–64. Default 64.
+
+
0–64. Default 8.
+
+
+
Applies to packets with no region scope. 0–64.
+
+ +
Drops flood packets that already carry this node's hash. Stricter = fewer repeats tolerated before dropping.
+
+
+ +
+
+

Identity

+
+
How this observer identifies itself in published messages; informational only. Blank = node name.
+
+
+
64-char hex public key of the owner's companion node (optional).
+
+
+
+

Publishing

+
This node only ever uploads to MQTT — nothing from a broker is injected into the mesh.
+
Node statusPeriodic health/stats to the status topic +
+
Publish packetsMaster switch for all packet traffic below +
+
Add raw framesAlso send the full hex frame to the raw topic +
+
Received packetsReport packets heard over the air +
+
Publish neighborsPeriodic neighbor table + scopes (PSRAM boards only) +
+
+
+ +
Report packets this node sends.
+
+
+
+
+
How often to publish the neighbor table (12-336, default 24).
+
+
+
+

Servers

+
+
+
+

Time & SNMP

+
+
Enter none to clear.
+
+
+
+
+
SNMP agentRestart required +
+
+
+
+ +
+
+

WiFi connection

+
Changing WiFi restarts the connection — this page will drop and the node reappears on the new network. Find its new IP from your router or the serial console.
+
+
+ + +
+
+
+
+
+
+

Maintenance

+
+ + +
+
+
+ +
+
+

Device

+
+
+
+

Free heap (KB)

+

Noise floor (dBm)

+
+
+

MQTT servers

+
No data yet.
+
+
+ +
+
+
+
+ + + + +
+
+
+
+ > + + +
+
+
+ Tab complete↑↓ history + Esc dismisspaste multiple lines to run a sequence +
+
+
+
+
+ + +
+ + + +
+ + +
+
Nearby networks + +
+
+
+ +
+ + +
+
+

Continue in your browser

+
    +
  1. Tap Done (or Cancel) in the corner of this window.
    + If asked, choose Use Without Internet / Stay connected.
  2. +
  3. Open Safari or Chrome on this device.
  4. +
  5. Go to http://192.168.4.1
  6. +
+
Stay connected to the MeshCore-Setup WiFi network. Your progress here isn't carried over — the setup restarts in the browser, where it survives the screen locking.
+

Go back

+
+
+ + +
+
+ +

Rebooting…

+

+

+
+
+ + + + diff --git a/wokwi.toml b/wokwi.toml new file mode 100644 index 000000000..eaa90c612 --- /dev/null +++ b/wokwi.toml @@ -0,0 +1,16 @@ +# Wokwi config for the ESP32-S3 observer simulation (no hardware, no flashing). +# +# Build the sim firmware first (produces the merged flash image Wokwi needs): +# pio run -e Heltec_v3_repeater_observer_mqtt_sim -t mergebin +# +# Then start the simulation either way: +# - VS Code: install the "Wokwi Simulator" extension, open diagram.json, press play +# - CLI: wokwi-cli . (needs a free WOKWI_CLI_TOKEN) +# +# The firmware is seeded to auto-join the "Wokwi-GUEST" network, so it boots +# straight into WiFi + MQTT. The LoRa radio is stubbed (SimRadio); mesh RX/TX is +# not simulated. Drive the CLI over the serial monitor (get/set/start webconfig). +[wokwi] +version = 1 +firmware = ".pio/build/Heltec_v3_repeater_observer_mqtt_sim/firmware-merged.bin" +elf = ".pio/build/Heltec_v3_repeater_observer_mqtt_sim/firmware.elf"