diff --git a/.github/workflows/build-observer-firmwares-beta.yml b/.github/workflows/build-observer-firmwares-beta.yml new file mode 100644 index 00000000..1646db11 --- /dev/null +++ b/.github/workflows/build-observer-firmwares-beta.yml @@ -0,0 +1,349 @@ +name: Build MQTT Observer Firmwares (BETA channel) + +permissions: + contents: write + +# Push-triggered on the dev line, mirroring build-observer-firmwares.yml. +# +# This is NOT the original design -- dispatch-only was, so that publishing to real +# nodes stayed an explicit act. That does not work in this repo: this fork's +# default branch is `dev` (an upstream mirror that carries none of the observer +# workflows), and GitHub only surfaces `workflow_dispatch` for workflows present +# on the DEFAULT branch. A dispatch-only workflow here would never appear in the +# Actions UI. Adding fork-specific workflows to `dev` would pollute the upstream +# mirror and conflict on every upstream sync, so the push trigger is the correct +# mechanism -- the same one production already relies on. +# +# workflow_dispatch is kept as well: harmless now, and it starts working if the +# default branch ever changes. +# +# Consequence to be aware of: every push to `observer-firmware-dev` publishes a +# dev-channel build. That is defensible for a channel users opt into, but if you +# want staging commits without publishing, work on a side branch and fast-forward +# `observer-firmware-dev` when you intend to release. +on: + workflow_dispatch: + push: + branches: + - observer-firmware-dev + # Same rationale as production: docs/CI-only changes do not alter binaries. + paths-ignore: + - '**.md' + - 'docs/**' + - 'scripts/gen_changelog.py' + - '.github/**' + - '.gitignore' + - '.gitattributes' + - '.editorconfig' + - 'LICENSE' + - '.vscode/**' + - '.claude/**' + +# Shared with build-observer-firmwares.yml and sync-flasher-content.yml so the +# workflows never push to the flasher repo at the same time. +concurrency: + group: flasher-publish + cancel-in-progress: false + +env: + # MUST stay equal to the production channel's FIRMWARE_VERSION. The observer's + # OTA comparison treats a different base version as "always an update", so a + # distinct base here would make every beta node think it is permanently behind. + # Channels are separated by manifest URL, not by base version. + FIRMWARE_VERSION: v1.16.0 + + # Beta-only rolling release. A separate tag is required, not cosmetic: the + # publish step prunes all but the KEEP_BUILDS most recent build hashes within + # its tag, so sharing production's tag would make each channel delete the + # other's assets. + RELEASE_TAG: observer-mqtt-beta-latest + + # The channel itself. Firmware fetches /.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 }})" + git push diff --git a/.github/workflows/check-mqtt-preset-parity.yml b/.github/workflows/check-mqtt-preset-parity.yml new file mode 100644 index 00000000..e1039a40 --- /dev/null +++ b/.github/workflows/check-mqtt-preset-parity.yml @@ -0,0 +1,90 @@ +name: Check MQTT Preset Name Parity + +# Ensures observer-firmware and observer-firmware-dev share the same built-in +# MQTT preset *names* (config details may differ). See scripts/check_mqtt_preset_parity.py. + +permissions: + contents: read + +on: + workflow_dispatch: + pull_request: + branches: + - observer-firmware + - observer-firmware-dev + paths: + - 'src/helpers/MQTTPresets.h' + - 'scripts/check_mqtt_preset_parity.py' + - '.github/workflows/check-mqtt-preset-parity.yml' + push: + branches: + - observer-firmware + - observer-firmware-dev + paths: + - 'src/helpers/MQTTPresets.h' + - 'scripts/check_mqtt_preset_parity.py' + - '.github/workflows/check-mqtt-preset-parity.yml' + +jobs: + parity: + runs-on: ubuntu-latest + steps: + - name: Clone Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch channel branches + run: | + git fetch --no-tags origin observer-firmware observer-firmware-dev + + - name: Materialize presets from both channels + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + PRESET_PATH=src/helpers/MQTTPresets.h + mkdir -p /tmp/preset-parity + + if [ "$EVENT_NAME" = "pull_request" ]; then + # Ensure the PR head commit is reachable (merge checkout may not keep it). + git fetch --no-tags origin "$PR_HEAD_SHA" + # Proposed state of the PR's base channel vs current tip of the sibling. + git show "${PR_HEAD_SHA}:${PRESET_PATH}" > /tmp/preset-parity/pr-head.h + case "$PR_BASE" in + observer-firmware-dev) + cp /tmp/preset-parity/pr-head.h /tmp/preset-parity/dev.h + git show "origin/observer-firmware:${PRESET_PATH}" > /tmp/preset-parity/prod.h + ;; + observer-firmware) + git show "origin/observer-firmware-dev:${PRESET_PATH}" > /tmp/preset-parity/dev.h + cp /tmp/preset-parity/pr-head.h /tmp/preset-parity/prod.h + ;; + *) + echo "::error::unexpected PR base branch: $PR_BASE" >&2 + exit 1 + ;; + esac + else + # push / workflow_dispatch: compare current channel tips. + git show "origin/observer-firmware:${PRESET_PATH}" > /tmp/preset-parity/prod.h + git show "origin/observer-firmware-dev:${PRESET_PATH}" > /tmp/preset-parity/dev.h + fi + + echo "=== production (observer-firmware) head preview ===" + head -n 5 /tmp/preset-parity/prod.h + echo "=== dev (observer-firmware-dev) head preview ===" + head -n 5 /tmp/preset-parity/dev.h + + - name: Self-test checker + run: python3 scripts/check_mqtt_preset_parity.py --self-test + + - name: Compare preset names + run: | + python3 scripts/check_mqtt_preset_parity.py \ + /tmp/preset-parity/prod.h \ + /tmp/preset-parity/dev.h \ + --label-a observer-firmware \ + --label-b observer-firmware-dev diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index cebf0cfe..788390c5 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,6 +47,9 @@ jobs: - Heltec_v3_companion_radio_ble - Heltec_v3_repeater - Heltec_v3_room_server + # MQTT observer smoke builds: internal RAM and PSRAM coverage. + - Heltec_v3_repeater_observer_mqtt + - T_Beam_S3_Supreme_SX1262_repeater_observer_mqtt # nRF52 - RAK_4631_companion_radio_ble - RAK_4631_companion_radio_ethernet diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c6..826c4cb3 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -19,6 +19,11 @@ jobs: - name: Setup Build Environment uses: ./.github/actions/setup-build-environment + - name: Verify ArduinoJson pin + run: | + python3 -B scripts/check_arduinojson_pin.py --self-test + python3 -B scripts/check_arduinojson_pin.py + - name: Run Unit Tests run: pio test -e native -e native_kiss_modem -vv diff --git a/.gitignore b/.gitignore index 1c88d194..59afd205 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .direnv .pio +.build-wt-* .vscode/.browse.c_cpp.db* .vscode/c_cpp_properties.json .vscode/launch.json @@ -21,6 +22,7 @@ compile_commands.json venv/ # Script-generated cert bundles (see scripts/generate_cert_bundle.py) src/certs/x509_crt_bundle.bin +src/helpers/esp32/WebConfigHtml.h ssl_certs/cacert.pem platformio.local.ini .cursor/* diff --git a/ALERTS.md b/ALERTS.md index 92b0974a..383abf31 100644 --- a/ALERTS.md +++ b/ALERTS.md @@ -31,6 +31,16 @@ Alert floods ride the **repeater's default scope** by default (the same Transpor A "recovered" message is sent once when the underlying connection comes back. After firing, a fault is rate-limited by `alert.interval` (default 60 minutes) before it can re-fire - this prevents flapping links from spamming the channel. +## OTA milestone alerts + +Key `ota update` milestones are also broadcast on the alert channel (in addition to the Serial log), so an operator who triggered the update via remote management still gets feedback: the real OTA work runs ~2.5 s after the command and reboots on success, both **outside** the command's reply window. + +- **Start** - `OTA update starting`, sent when the update is confirmed and scheduled (during the reply window, so it transmits before the flash blocks the loop). +- **Fail/abort** - `OTA aborted: MQTT stop unclean, bridge resumed` (the teardown barrier withheld flashing) or `OTA aborted: ` (preflight/download error). The bridge is resumed either way. +- **Success** has no dedicated message: a successful flash reboots into the new image, so the node reappearing on the new version is the success signal. + +These share the alert channel, scope (`alert.region`), and the `alert` master switch with fault alerts - if `alert` is `off` or no channel is configured, OTA milestones are silent. Unlike fault alerts they are **not** rate-limited (OTA is operator-initiated and rare), and they are limited to these start/fail milestones - routine MQTT slot connect/disconnect never triggers an OTA alert. + ## Defaults | Setting | Default | Notes | @@ -67,7 +77,7 @@ Set: - `set alert.interval ` (60-10080; 60-minute floor to protect mesh airtime) Action: -- `alert test` - send a one-off `[test] alert channel ok` immediately on the configured channel; ignores `alert on/off` so operators can verify the channel before enabling fault firing. Returns an error if no channel is configured. +- `alert test` - send a one-off `[test] alert channel ok` immediately on the configured channel; ignores `alert on/off` so operators can verify the channel before enabling fault firing. Returns an error if no channel is configured. If the send succeeds but the `alert` master switch is still `off`, the reply says so (`OK - test sent, but automatic alerts are OFF`) - a working test alone does **not** mean automatic WiFi/MQTT/OTA alerts will fire. - `alert test ` - send a custom test message: `[test] `. ## Example: dedicated hashtag channel (recommended for operator groups) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index ff4c3302..e8de5f44 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -202,6 +202,12 @@ pio run -e Heltec_v3_repeater_observer_mqtt # Heltec V4 pio run -e heltec_v4_repeater_observer_mqtt +# Heltec Wireless Tracker v1.1 / v2 +pio run -e heltec_tracker_v1_1_repeater_observer_mqtt +pio run -e heltec_tracker_v1_1_room_server_observer_mqtt +pio run -e heltec_tracker_v2_repeater_observer_mqtt +pio run -e heltec_tracker_v2_room_server_observer_mqtt + # Station G2 pio run -e Station_G2_repeater_observer_mqtt @@ -434,6 +440,8 @@ These settings apply across all MQTT slots: - `get mqtt.rx` - Get RX packet uplinking setting (on/off) - `get mqtt.tx` - Get TX packet uplinking setting (on/off/advert) - `get mqtt.interval` - Get status publish interval +- `get mqtt.neighbors` - Get periodic neighbors publishing setting (on/off; PSRAM only) +- `get mqtt.neighbors.interval` - Get neighbors publish interval in hours (PSRAM only) - `get mqtt.ntp` - Get effective NTP server hostname - `get mqtt.ntp.diag` - Probe every configured NTP server for connectivity (does not change the clock; serial console shows each server's reported time, LoRa shows a compact ` ok|fail` list) - `get mqtt.owner` - Get owner public key (serial console only) @@ -451,6 +459,8 @@ These settings apply across all MQTT slots: - `advert` - Uplink only this node's own advert packets (self-originated) - `off` - Disable TX packet uplinking - `set mqtt.interval ` - Set status publish interval (1-60 minutes) +- `set mqtt.neighbors on|off` - Enable/disable periodic neighbors publishing (PSRAM only; read live, no restart) +- `set mqtt.neighbors.interval ` - Set neighbors publish interval (12-336 hours, default 24; PSRAM only) - `set mqtt.ntp ` - Set custom NTP server (validated with immediate sync); `none` reverts to default - `set mqtt.owner <64-hex-char-public-key>` - Set owner public key - `set mqtt.email ` - Set owner email address @@ -526,6 +536,86 @@ These are standard MeshCore commands, not MQTT-specific, but important for obser See [MQTT_SNMP.md](MQTT_SNMP.md) for full SNMP documentation. +### Web Configuration Portal + +The observer builds include a browser-based configuration portal so a node can +be provisioned and managed without the serial CLI. It is started from the CLI +(serial or remote admin) and is never on by default on a configured node. + +#### CLI commands +- `start webconfig` -- start the portal. If WiFi is already configured and + connected, it binds to the node's **LAN** IP and requires the admin password + to log in. If WiFi is **not** configured (`wifi.ssid` empty), it raises the + setup AP instead (same as first boot). +- `start webconfig ap` -- force the **setup AP** even when WiFi is configured. + The MQTT bridge must be stopped first (`set bridge off`); the AP owns the + radio. Used for re-provisioning in the field. +- `stop webconfig` -- stop the portal and free its resources. LAN mode runs until + this is issued; the setup AP also auto-stops after an idle timeout (default 10 + minutes with no station associated). + +#### First-boot / setup-AP behavior +On a node with no WiFi configured, the portal comes up automatically as an open +SoftAP named `MeshCore-Setup-XXXX` (last two bytes of the public key), with a +captive-portal redirect. The device display shows the AP name and portal URL +(`http://192.168.4.1/`). Walk through the wizard (WiFi -> radio -> MQTT -> review), +then **Save & reboot**; the node reboots and joins the configured network. + +Optionally set a WPA2 password for the setup AP at build time with +`-D WEBCONFIG_AP_PASSWORD='"yourpassword"'`. + +#### Modes and authentication +- **Setup AP**: unauthenticated. Trust is based on physical proximity to the + open/PSK AP. Only the SoftAP interface serves the API -- on `start webconfig + ap` the STA is explicitly disassociated so the API is **not** exposed on the + LAN the node was attached to. +- **LAN**: requires the admin password (same one used for remote CLI admin). + Sessions use a cookie with a sliding idle expiry (default 20 minutes); five + failed logins trigger a 30-second lockout. + +> **Security note:** the open setup AP transports WiFi/MQTT credentials over +> plain HTTP. Provision on a trusted, non-public frequency/location, set +> `WEBCONFIG_AP_PASSWORD` where feasible, and prefer LAN mode for ongoing +> management. The setup AP is intended for initial provisioning, not +> long-running operation. + +#### Applying changes +- **Radio** (freq/BW/SF/CR): persisted but applied only on reboot; the UI shows + a "reboot to apply" hint. +- **WiFi SSID/password**: changing these in LAN mode saves and reboots so the + node reconnects on the new network (the page will drop; find the new IP on + your router). In the setup wizard, saving always reboots. +- **MQTT publishing toggles / slot config**: applied live to the running bridge + (no reboot needed). +- **NTP server**: saved immediately; the time sync runs in the background -- + verify with `get mqtt.ntp.diag`. + +#### Recovery +If provisioning fails or you're locked out of the portal, connect over USB +serial and use the CLI directly (e.g. `set wifi.ssid ...`, `set wifi.pwd ...`, +`get wifi.status`, `stop webconfig`). Serial access always works regardless of +the portal state. + +### Local testing without hardware + +Two ways to iterate on observer/WiFi functionality without flashing a device: + +- **Portal UI** -- run the mock backend and open the real portal in a browser: + `python3 scripts/webconfig_mock_server.py` (add `--setup` for the first-boot + wizard), then browse to `http://localhost:8080/`. It serves `webui/index.html` + and mirrors the firmware's `/api/*` contract (reqid handshake, reboot gating, + validation, secret masking), so the portal JS runs against realistic + responses. Stdlib only; no account. +- **Boot / WiFi / MQTT / CLI / OLED** -- the Wokwi ESP32-S3 sim. Build + `pio run -e Heltec_v3_repeater_observer_mqtt_sim -t mergebin` (LoRa radio + stubbed via `SimRadio`, WiFi pre-seeded to `Wokwi-GUEST`), then run the sim + from `wokwi.toml`/`diagram.json` (VS Code Wokwi extension or `wokwi-cli`). + Outbound MQTT works on the free gateway; incoming (browser -> on-device portal) + needs Wokwi's paid Private Gateway -- use the mock backend above for portal UI. + +Backend handler logic is covered by host unit tests under `test/` (`pio test -e +native`); see [test/README.md](test/README.md) for the suites and how to run them. + ## Command Architecture The CLI commands are organized into two levels: @@ -556,6 +646,12 @@ Full packet data with RF characteristics and metadata. ### Raw Topic: `meshcore/{IATA}/{DEVICE_PUBLIC_KEY}/raw` Minimal raw packet data for map integration. +### Neighbors Topic: `meshcore/{IATA}/{DEVICE_PUBLIC_KEY}/neighbors` +Periodic snapshot of this node's zero-hop neighbor table plus each neighbor's +region scopes (PSRAM boards only; disabled by default). Published non-retained at +QoS 1 on the interval set by `mqtt.neighbors.interval` (12-336 h, default 24 h). +Like status/raw, this topic is **not** sent to MeshRank slots (packets-only contract). + **Note**: `{DEVICE_PUBLIC_KEY}` is the device's public key in hexadecimal format (64 characters). ## JSON Message Formats @@ -634,6 +730,28 @@ Minimal raw packet data for map integration. } ``` +### Neighbors Message +```json +{ + "timestamp": "2024-01-01T12:00:00.000000+00:00", + "origin": "MeshCore-HOWL", + "origin_id": "A1B2C3D4E5F67890...", + "self": { "scopes": "DEN,APRS" }, + "neighbors": [ + { + "pubkey": "0011223344556677...", + "snr": 9.75, + "heard_secs_ago": 42, + "scopes": "DEN,APRS", + "status": "responded" + } + ] +} +``` +Entries are ordered most- to least-useful (most recently heard, then stronger +SNR); the tail is dropped if the payload would exceed the 10 KB publish buffer. +`status` is `responded`, `timeout`, or `send_failed` per neighbor. + ## Key Features ### Slot-Based Preset System diff --git a/MQTT_INTERNALS.md b/MQTT_INTERNALS.md index a0390344..dc248411 100644 --- a/MQTT_INTERNALS.md +++ b/MQTT_INTERNALS.md @@ -57,6 +57,37 @@ scheduled time are expired at dequeue, so under throttle the queue holds only fr traffic and admin responses reach the trickle of TX budget. Non-observer builds keep the upstream pool behavior. +### Neighbors publication path (PSRAM only) + +Periodic neighbors publishing is gated on `WITH_MQTT_NEIGHBORS` +(`defined(BOARD_HAS_PSRAM) && defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0`, +defined in `MQTTBridge.h`). It spans two subsystems and two cores: + +- **Mesh side (Core 1), `MyMesh`**: the `loop()` runs a two-stage refresh driven by + `mqtt_neighbors_interval`. Stage 1 sends a zero-hop `sendNodeDiscoverReq()` and waits + out its 60 s collection window to refresh `neighbours[]`. Stage 2 + (`startNeighborDiscover`) fires one anon-regions scope query per heard neighbor, + overlaying them onto the peer-index space at `NEIGHBOR_DISCOVER_PEER_BASE` so their + `PAYLOAD_TYPE_RESPONSE` packets decrypt via `searchPeersByHash`/`getPeerSharedSecret`/ + `onPeerDataRecv` even when the neighbor is not an ACL client. After all responses land + or a 30 s window expires, `finishNeighborDiscover()` builds the JSON with + `MQTTMessageBuilder::buildNeighborsMessage` into a transient PSRAM buffer and hands it + to the bridge. +- **Bridge side, handoff**: `requestPublishNeighbors(json, len)` (Core 1) memcpys into a + persistent ~10 KB PSRAM buffer (`NEIGHBORS_JSON_BUFFER_SIZE`) and sets + `_neighbors_publish_pending` with a release store; the MQTT task (`mqttTaskLoop`, Core 0) + consumes it with an acquire load, calls `publishNeighbors()`, and clears the flag. A + second snapshot is dropped while one is in flight. `publishNeighbors()` sends QoS 1, + retain = `preset->allow_retain` (custom slots non-retained). MeshRank slots are skipped + (the topic router rejects non-packets for MeshRank). +- **Status reporting**: `MyMesh` reports the schedule each loop via + `setNeighborsSchedule(phase, secs)`; `formatMqttStatusReply` renders it as the trailing + `nbr: /` field in `get mqtt.status` while the feature is enabled. + +The JSON builder lives in the pure, host-tested `MQTTPayloadBuilder` +(`test/test_mqtt_payload_builder`); the topic type in `MQTTTopicRouter` +(`test/test_mqtt_topic_router`). The mesh<->bridge orchestration above is on-target only. + ### `/mqtt_prefs` file format `/mqtt_prefs` is written with an 8-byte `MQTTPrefsHeader` (`magic`, `version`, @@ -81,7 +112,8 @@ default; a newer, longer one is truncated harmlessly. - **`/mqtt_prefs`** - if the file has the version header it is read directly. Otherwise it is a legacy headerless file and its layout is detected by size: pre-slot (`OldMQTTPrefs`), 3-slot (`ThreeSlotMQTTPrefs`), or the 6-slot layout shipped on - `mqtt-bridge-implementation-flex` (`Legacy6SlotMQTTPrefs`). Each is field-copied into + `observer-firmware` back when it was named `mqtt-bridge-implementation-flex` + (`Legacy6SlotMQTTPrefs`). Each is field-copied into the current compact `MQTTPrefs` and re-saved with the version header - which also drops the vestigial `_legacy_*` fields the flex layout carried mid-struct. This is a one-time rewrite; every deployed device performs it on its first boot of versioned diff --git a/MQTT_OWNERSHIP.md b/MQTT_OWNERSHIP.md new file mode 100644 index 00000000..d3009e0e --- /dev/null +++ b/MQTT_OWNERSHIP.md @@ -0,0 +1,185 @@ +# MQTT Bridge Cross-Core Ownership Model + +This document is the Phase 4 deliverable from `STABILITY_TESTABILITY_HANDOFF.md`: +it records **one owner for each mutable runtime domain**, maps every place a +non-owner reads owned state across cores today, and states the target primitive +for each. It is paired with the fork-owned lifecycle test seam +(`src/helpers/MQTTLifecycle.h`, `test/test_mqtt_lifecycle/`). + +**Status:** ownership model documented and the lifecycle/teardown test seam +landed (Phase 4). **Phase 5 (branch `phase5/cooperative-mqtt-shutdown`) has now +implemented the cooperative shutdown and the OTA barrier -- hazard Section 4 below.** +Still **deferred** (carried to Phase 5b / Phase 6): publishing a plain-data +snapshot and repointing the Section 1/Section 2 consumers, and replacing the Section 3 `volatile` +handshakes with a command channel. This document is the plan; Section 1-Section 3 still +describe current behavior, while Section 4 is now resolved (see its note). Line +references are against the tree at the time of writing and should be re-verified +before editing. + +## Execution contexts + +- **MQTT task -- Core 0** (`"MQTTBridge"`, `xTaskCreatePinnedToCore(..., MQTT_TASK_CORE=0)`, + entry `mqttTask` -> `mqttTaskLoop`, `MQTTBridge.cpp:923`, `:991`). Owns all + WiFi / MQTT / NTP I/O and every mutation of `_slots[]`, connection state, and + NTP state. +- **Loop task -- Core 1** (`MyMesh::loop()`). Runs the CLI, WebConfig `tick()`, + and `AlertReporter::onLoop()`. `examples/simple_repeater/MyMesh.cpp:1529` + notes the bridge loop is *not* called here -- it lives on Core 0. +- **Producer / radio context.** Stages raw radio bytes before queue handoff + (`storeRawRadioData` -> `_staged_*`, consumed by `queuePacket`; both Core 1, in + guaranteed sequence, `MQTTBridge.h:232-239`). +- **Async TCP context.** WebConfig request parsing and immutable response + handoff only. + +## Ownership model (one owner per mutable domain) + +| Domain | Owner | Notes | +|--------|-------|-------| +| MQTT clients, slot connection state, publish counters, packet drain, NTP I/O | **MQTT task (Core 0)** | `_slots[]`, `PsychicMqttClient`s, `_ntp_client`, `_last_raw_*` | +| CLI execution, preference persistence, WebConfig batch draining, bridge lifecycle requests | **Loop task (Core 1)** | issues start/stop/reconfigure, drains the WebConfig batch | +| Packet staging before queue handoff | **Producer / radio (Core 1)** | `_staged_*`, no lock needed (sequential) | +| Request parse + immutable response | **Async TCP** | must not touch mutable bridge state | + +The rule that follows: **loop/WebConfig/CLI/AlertReporter code must not directly +inspect mutable MQTT slot objects or client counters.** The MQTT task must +publish a plain-data snapshot they can read instead. + +## Current cross-core hazards (to resolve in Phase 5) + +All of the following run on **Core 1** and read state mutated by **Core 0** +without a lock or a published snapshot. + +### 1. Diagnostic reads of live `_slots[]` via the singleton + +Four `static` accessors reach the live object through the file-scope +`s_mqtt_bridge_instance` (`MQTTBridge.cpp:201`, set at `begin()` end `:849`, +nulled first in `end()` `:858` -- a plain, non-atomic pointer): + +- `getSlotStatusSnapshot` (`:299`) -- despite its name, built on demand from + live `_slots[slot_index]`. **Refined premise:** the returned `name`/`state` + `const char*`s point at static rodata (string literals / `MQTT_PRESETS[]`), + so they are *not* dangling. The real hazards are (a) reading the + `slot.preset` **pointer value**, which Core 0 can null/reassign mid-read + (`applySlotPreset`, `teardownSlot`), and (b) `slot.client->getPublishOk()`, + a live client pointer Core 0 can `delete` during teardown (UAF window). +- `formatMqttStatusReply` (`:207`), `formatMqttStatsReply` (`:263`), + `formatSlotDiagReply` (`:397`) -- same singleton + live `_slots[]` reads. + +Consumers: CLI (`CommonCLI_Observer.cpp:726, :728, :791`) and the app-layer +stats JSON (`examples/simple_repeater/MyMesh.cpp:1423`, mirrored in +`examples/simple_room_server/MyMesh.cpp:1050`). **Refined premise:** +`WebConfigServer.cpp` itself reads only a compile-time constant +(`getMaxActiveSlots`, `:479`); the live-bridge web reads are in `buildStatsJson` +in the `MyMesh.cpp` app layer. + +### 2. Instance reads that survive `end()` + +`AlertReporter` (Core 1, `onLoop`, `AlertReporter.cpp:208`) reads live `_slots[]` +via instance methods it reaches through its own `_bridge` pointer +(`AlertReporter.cpp:281` `isSlotEnabledAndAttempted`, `:285` +`getSlotCurrentOutageStartMs`, `:296`/`:311` `getSlotPresetName`). The stats +JSON also calls `bridge->getQueueSize()` (`MyMesh.cpp:1419`). + +**Refined premise (teardown hazard):** `end()` nulls only +`s_mqtt_bridge_instance`. The app's `bridge` pointer and `AlertReporter::_bridge` +are **not** cleared by `end()`, so these instance reads can touch a torn-down +bridge. (`getConnectedBrokers()` at `MQTTBridge.cpp:3676` is defined but has +zero consumers.) + +### 3. `volatile` cross-core handshakes + +Plain `volatile` flags (no atomics/barriers), Core 1 sets the request, Core 0 +clears/processes and writes a "done" flag last, Core 1 spins: + +- `_slot_reconfigure_pending[]` -- set `MQTTBridge.cpp:2057` (Core 1), read/clear + `:1118` (Core 0). +- `_ntp_force_{requested,done,result}` -- request `:3298`, process `:1063-1068`, + spin-wait `:3308-3315`. +- `_ntp_diag_{requested,done}` (+ non-volatile `_ntp_diag_results[]`) -- request + `:3344`, process `:1073-1076`, spin-wait `:3347`. + +The two blocking waiters (`requestForcedNtpSync` `:3298`, `ntpDiag` `:3344`) spin +on Core 1 while `end()` could tear down the singleton/task concurrently. + +### 4. Abrupt teardown, no start guard -- RESOLVED in Phase 5 + +Original hazard (retained for context): `end()` used +`vTaskDelete(_mqtt_task_handle)` to kill the task wherever it was (possibly +mid-`_slots[]` mutation or inside mbedTLS), then ran slot/client cleanup *after* +deletion on the caller's context -- the OTA teardown heap-panic path. `begin()` +had no double-call guard, and lifecycle state was a single `_initialized` bool. + +**Phase 5 resolution** (branch `phase5/cooperative-mqtt-shutdown`): + +- `end()` now requests a cooperative stop through `MQTTLifecycle::Coordinator`. + The MQTT task (Core 0) tears down its own clients where the mbedTLS contexts + live, acknowledges via `_stop_acked`, and self-terminates; `end()` waits for + the ack before freeing the queue/buffers. The blind `vTaskDelete` survives + only as the bounded-timeout fallback, which sets a dirty latch that withholds + OTA flashing (`canFlashAfterStop()`). +- `begin()` has an idempotent double-call guard and drives the Coordinator to + `Running`; the lifecycle state now lives in the tested state machine, not a + bare bool. +- The OTA barrier gates `simple_repeater`'s deferred flash on a clean stop. + +Not hardware-validated yet (Phase 7); `MQTT_STOP_TIMEOUT_MS` is a Phase-0 +placeholder. Note the residual Section 1/Section 2 instance-pointer reads remain deferred, so +consumers can still (as before) touch a torn-down bridge -- that is unchanged by +Phase 5 and tracked above. + +## Target primitives (Phase 5) + +- **Task notifications or a command queue** for one-way lifecycle / reconfigure + / NTP requests -- replacing every `volatile` flag in Section 3. +- **Immutable published snapshots** for WebConfig, CLI diagnostics, and alerting + -- the MQTT task publishes a plain-data `SlotStatusSnapshot` (owned char + buffers + scalars, no live pointers) that Section 1/Section 2 consumers read. This also + removes the "instance pointer survives `end()`" hazard because consumers stop + dereferencing the live bridge. +- **Atomics** only for truly independent scalar state. +- **A mutex** only where ownership transfer or snapshot publication cannot + express the operation cleanly. + +## Lifecycle contract (the test seam) + +`src/helpers/MQTTLifecycle.h` encodes the cooperative lifecycle Phase 5 must +implement, as a pure state machine plus a narrow injected `Ops` seam +(clock / task control / resource owner / OTA barrier). The invariants proven by +`test/test_mqtt_lifecycle/` -- and that Phase 5's production wiring must preserve: + +- `Stopped -> Starting -> Running -> StopRequested -> Stopping -> Stopped`. +- Idempotent start and stop; safe restart only from `Stopped` (`mayRestart`). +- New connects/publishes/retries/reconfigurations cease once a stop is requested + (`acceptsNewWork`). +- Resources are released **only** after a stop acknowledgment (or the reviewed + timeout fallback) -- never mid-run. +- A late/stale callback consults `mayTouchOwnedState()` and is a no-op once the + owner has released resources. +- Bounded stop timeout -> reviewed fallback (models replacing the abrupt + `vTaskDelete`). +- **OTA barrier:** `mayBeginFlash()` is true only after a **clean** stop + acknowledgment; a timed-out stop leaves flashing blocked so OTA aborts rather + than writing under uncertain ownership. + +### Phase 0 / hardware-pending items + +Per the "derive from code + flag" discipline, these are **not** encoded as +constants and must be characterized on hardware before Phase 5 ships: + +- The concrete stop timeout (injected as `Coordinator`'s `stop_timeout_ms`; + must be measured against mbedTLS teardown over `wss` with a down broker). +- Exact callback timing/ordering under a real TLS disconnect. +- Heap / largest-block / task-stack high-water and task/client counts across + start/stop/restart (Phase 7 gates). + +## Deferred to Phase 5 (not done here) + +- Publishing the plain-data snapshot and repointing the Section 1/Section 2 consumers at it. +- Replacing the Section 3 `volatile` handshakes with a command queue / task + notifications. +- The cooperative shutdown state machine in `MQTTBridge` (`end()` rewrite, the + `begin()` double-call guard) and the OTA teardown barrier. + +`MQTTBridge.cpp` was intentionally left untouched in Phase 4 to keep the +merge-sensitive file (~3.7k lines) free of churn until the Phase 5 change lands +as a single reviewable unit. diff --git a/STABILITY_TESTABILITY_HANDOFF.md b/STABILITY_TESTABILITY_HANDOFF.md new file mode 100644 index 00000000..d81c77b3 --- /dev/null +++ b/STABILITY_TESTABILITY_HANDOFF.md @@ -0,0 +1,891 @@ +# Stability, Testability, and Upstream-Merge Handoff + +## Purpose + +This document is the plan of record for refining the fork-owned WebConfig and +MQTT observer code after the initial policy extraction and host-test work. The +goal is to improve runtime stability, long-uptime confidence, and serviceability +without broad rewrites of upstream-heavy files or creating unnecessary merge +conflicts. + +The order is deliberate: cheap CI and persistence guardrails land first, then +tests and ownership boundaries needed to make lifecycle work safe, and only +then the cooperative-shutdown refactor. Hardware soak testing validates the +result; it is not the first line of defense for the riskiest change. + +## Roadmap Status + +The guardrail phases have already landed on this branch; the remaining work is +the lifecycle refactor and its safety net. Phases are intentionally not +renumbered so cross-references and the completed acceptance criteria stay stable; +each phase below carries an explicit status line, and this table is the quick +index. + +| Phase | Scope | Status | +|-------|-------|--------| +| 1 | PR CI smoke builds + ArduinoJson pin enforcement | Done (build-size gate and ASan/UBSan still pending) | +| 2 | PSRAM restart resource symmetry | Done | +| 3 | MQTT preference migration fixtures | Done (filesystem adapter still lives in `CommonCLI`) | +| 0 | Pre-change lifecycle characterization | Hardware run 2026-07-19 (see "Hardware Characterization Results"): teardown timing measured on V3+V4. Finding: flat `MQTT_STOP_TIMEOUT_MS=8000` too small -> **FIXED** with slot-scaled timeout (`5s + 8sxslots`), hardware-verified (2-slot stop now clean) | +| 4 | Ownership and teardown test seams | Seams + ownership doc + teardown tests done; production rewiring deferred to Phase 5 | +| 5 | Cooperative MQTT shutdown | Minimal cooperative `end()` + `begin()` guard + OTA barrier implemented on branch `phase5/cooperative-mqtt-shutdown` (native green, firmware smoke build green); NOT hardware-validated. Volatile-handshake replacement + snapshot-consumer repointing deferred | +| -- | OTA teardown barrier | Implemented -- flash gated on a clean MQTT stop in `simple_repeater`; not hardware-validated | +| 6 | Request/queue/connection/publication integration tests | Partial: WiFi-backoff + publish-outcome + enum-alignment gaps extracted and host-tested; WebConfig batch/reboot/stop spec (`WebConfigBatch.h`) **now wired into `WebConfigServer.cpp`** (2026-07-19) so the host tests cover production; queue-orchestration coverage still open, and the wired path is not yet exercised over real HTTP | +| 7 | Uptime, memory, and fault-injection gates | Representative HW matrix run 2026-07-19: V3 non-PSRAM + V4 PSRAM done (no leak/crash; forced-path OTA-withhold + ~15-27 s loop stall observed). Multi-day soak + stack-HWM build pending | +| -- | Upstream merge | `upstream/dev` merged 2026-07-19 on `observer-firmware-dev` (191 commits, 14 conflicted files). See "Upstream Merge Record". Not yet promoted to `webconfig` | +| -- | Dev release channel | `observer-firmware-dev` publishes the dev/beta firmware channel (see "Release Channels"). Manual dispatch; separate from production | + +Phases 0, 4, and 5 (with the OTA teardown barrier) are landed and +hardware-validated. **Remaining work, in execution order:** + +1. Exercise the wired WebConfig batch machine over real HTTP (Phase 6) -- the + only untested part of a change that is already in the branch. +2. Drive the live `ota update` deferred-flash path on a bench node (OTA barrier + action; the latch input is already hardware-verified). +3. Close the remaining Phase 6 queue-orchestration coverage. +4. Phase 7 multi-day soak + task-stack-HWM build. +5. Promote `observer-firmware-dev` into `webconfig` once the items above are + met, then keep merging upstream after each phase rather than batching (see + "Upstream Merge Record"). + +Do not reopen a "Done" phase without a deliberate reason (see +"Change-Control Discipline"). + +## Hardware Characterization Results (Phase 0 & Phase 7) -- 2026-07-19 + +Hardware run of the outstanding Phase 0 (pre-change/cooperative teardown timing) +and Phase 7 (uptime/memory/fault-injection) items against two live observer +nodes. **Not yet committed as a plan change -- this section records measured +results and a release-gating recommendation for review.** + +### Setup + +- **V3** -- Heltec WiFi LoRa 32 **V3**, non-PSRAM (max 2 active slots), env + `Heltec_v3_repeater_observer_mqtt`, 1 configured slot (`meshmapper`, wss). +- **V4** -- Heltec WiFi LoRa 32 **V4**, **2 MB PSRAM** (max 5 active slots), env + `heltec_v4_repeater_observer_mqtt`, 3 configured slots (`analyzer-us`, + `cascadiamesh`, `waev`, all wss). mbedTLS + JSON/raw buffers allocate in PSRAM. +- Both flashed with this branch (`phase5/cooperative-mqtt-shutdown`) via + `pio run -t upload` (app-slot flash only; `nvs`+`spiffs` preserved, no erase). + Note: `FIRMWARE_VERSION`/`FIRMWARE_BUILD_DATE` are hardcoded in `MyMesh.h` and + were not bumped, so `ver` reads "v1.16.0 (6 Jun 2026)" on both old and new + builds -- confirm the flash by behavior (the `(clean)` / cooperative-stop log + lines), not by `ver`. +- Driven over the serial CLI (`set bridge.enabled off/on` = `end()`/`begin()`; + `set mqttN.*` = slot reconfigure; `get mqtt.stats` = `Free`/`Max`(largest + block)/queue/outbox/per-slot counters). Host-side timestamped log parsing. + +### PRIMARY FINDING -- `MQTT_STOP_TIMEOUT_MS = 8000` is too small (release-gating) + +Per-wss-slot teardown costs **~5-6 s**, applied **sequentially** +(`destroySlotClients()`: `disconnect()` -> 50 ms -> `esp_mqtt_client_destroy()`; +the ~5 s is the esp-mqtt task/network close, not the 50 ms settle). This is +unchanged from the pre-change path (same teardown code), so the cooperative +`end()` ack time scales with the number of connected slots: + +| Config | Slots | Measured teardown | vs 8 s timeout | Result | +|--------|-------|-------------------|----------------|--------| +| V3 non-PSRAM | 1 wss | ack ~5.3-6.2 s (avg 5.8) | under | **clean** PASS | +| V3 non-PSRAM (design max) | 2 wss | ~11-12 s (cut off at 8 s) | **over** | **forced/dirty -> OTA withheld** FAIL | +| V4 PSRAM (normal config) | 3 wss | ~16 s (cut off at 8 s; per-slot disc @2.2/7.8/10.6 s) | **over** | **forced/dirty -> OTA withheld** FAIL | +| V4 PSRAM (design max) | 5 wss | ~27-30 s projected | **far over** | forced/dirty -> OTA withheld FAIL | + +Pre-change (v1.16.0) reference teardown (same nodes, blocking `end()`): 1 slot +~6.1 s (V3), 3 slots ~16.4 s (V4) -- consistent with the above. + +**Consequence:** at the non-PSRAM board's *designed maximum* of 2 wss slots, and +on a *normal healthy* 3-slot PSRAM node, an ordinary cooperative shutdown exceeds +8 s -> the coordinator fires `StopTimedOut` -> sets the dirty latch -> force-kills +the MQTT task (the exact mbedTLS-mid-teardown path Phase 5 exists to avoid) and +`canFlashAfterStop()` returns false. Because the OTA barrier only flashes after a +**clean** stop, this means **any multi-slot device would have `ota update` +permanently withheld** with the current timeout -- the barrier misclassifies +healthy stops as dirty. This inverts the barrier's intent and must be fixed +before Phase 5 ships. + +**FIX APPLIED (slot-count-aware timeout) -- verified on hardware.** + +The flat `MQTT_STOP_TIMEOUT_MS = 8000` is replaced by a slot-scaled budget +computed per stop in `MQTTBridge::end()`: + +``` +timeout = MQTT_STOP_TIMEOUT_BASE_MS (5000) + MQTT_STOP_TIMEOUT_PER_SLOT_MS (8000) x enabled_slots +``` + +-> 1 slot 13 s, 2 slots 21 s, 3 slots 29 s, 5 slots 45 s -- ~1.5-2x headroom over +the measured ~5-6 s/slot teardown. `end()` counts enabled slots and calls +`Coordinator::setStopTimeoutMs()` before `requestStop()`. Headroom is nearly free +because `end()` returns as soon as the task acks (`_stop_acked` is checked before +the timeout ticks), so a larger bound does not slow a healthy stop -- it only +lengthens the wait before force-killing a genuinely wedged task. (Files: +`MQTTBridge.cpp` constants + `end()`; `MQTTLifecycle.h` `setStopTimeoutMs()`.) + +**Hardware verification (both memory paths):** the same configs that force-timed- +out at 8 s pre-fix now ack cleanly -- V3 non-PSRAM 2-slot logs `timeout 21000 ms` +and acks in ~11.7 s; V4 PSRAM 3-slot logs `timeout 29000 ms` and acks in ~16.4 s +-> **`MQTT Bridge stopped (clean)`** (OTA no longer withheld). Native suite + both +firmware builds green. Complementary future option (not done): shorten the +per-slot `esp_mqtt_client_destroy()` wait to reduce absolute teardown time. + +### Phase 0 -- cooperative lifecycle (V3, non-PSRAM, this branch) + +- 6x and 10x `begin->connect->end->restart` cycles: **all clean**, ack 5.3-6.2 s, + end() loop-task block <=6.9 s, restart->connect ~5.4 s. +- **No leak across full stop/start cycles:** free heap flat (~137-142 k, varies + only with the meshmapper outbox), largest free block stable (~115-129 k). This + satisfies Phase 5's "no downward heap/largest-block trend across start/stop + cycles" acceptance criterion on the non-PSRAM path. + +### OTA teardown barrier + +- Barrier **input** validated on hardware in both states: clean stops -> + `canFlashAfterStop()` true; forced/timeout stops log `OTA blocked` / + `OTA flashing withheld` (= `canFlashAfterStop()` false). Confirmed + `mayBeginFlash() == (Stopped && !_stop_timed_out)`; a fresh start clears the + latch; a dirty stop still permits restart. +- Barrier **action** (`simple_repeater` aborts+resumes vs. proceeds) is a + one-line gate on that latch (`MyMesh.cpp` deferred-OTA site) -- validated by + code + the Phase 4 host lifecycle tests. +- **Not exercised end-to-end on hardware:** the live `ota update` deferred-flash + path. A plain `pio run` build reports `ERR: OTA not configured (build via + build.sh)`, so `otaFromManifest` bails before scheduling; driving it needs a + `build.sh` firmware + a controlled manifest, and was not improvised on working + fleet nodes (risk of flashing a real fleet build). Recommend a dedicated + bench run for this. + +### Phase 7 -- fault-injection matrix (V3 non-PSRAM; representative, not the soak) + +Matrix: 10 clean stop/start + 5 forced 2-slot teardowns + 8 slot-reconfigure + +4 down-broker (`wss://192.0.2.1`) flaps. Time series of free/largest-block/ +queue/outbox recorded per step; reboot/crash detection on. + +- **No crash across the entire matrix** (incl. 5 repeated force-kill fallbacks) -- + the reviewed dirty-stop fallback is robust on non-PSRAM. +- **Forced teardown reproducible:** every 2-slot stop timed out (acks 7.4-8.2 s), + reinforcing the primary finding. +- **Bounded fragmentation, fully recoverable:** slot-level reconfigure/flap + churn (which keeps a disabled slot's persistent client until a *full* bridge + restart) dropped the largest free block from ~125 k to ~72 k and held ~17 k of + free heap, but it **plateaued** (stable across 12 churn iterations, not an + unbounded leak) and a reboot fully restored ~141 k free / ~125 k largest block. + This is pre-existing bridge behavior (TLS-context lifecycle), not a Phase 5 + regression. + +### Phase 7 -- forced-teardown stress (V4 PSRAM, 3 wss slots, this branch) + +8x `end()`/`begin()` cycles at the node's normal 3-slot config (every stop +exceeds 8 s -> forced/dirty): + +- **All 8 forced, no leak, no crash.** Largest free block **rock-stable at + 139 252 across all 8 cycles** (mbedTLS is PSRAM-allocated, so internal-heap + fragmentation is absent on the PSRAM path); free internal heap flat. The + force-kill fallback is robust on PSRAM too. +- **Loop-task stall is severe on the forced path:** `end()` blocked the loop + task **~15-27 s** per stop, because the forced path waits the full 8 s timeout + and *then* runs a complete redundant teardown on Core 1 (~ 8 s + ~16 s). During + that window the repeater's mesh/CLI/radio servicing is stalled. Sizing the + timeout so the cooperative (Core-0) teardown completes cleanly removes both the + OTA-withhold and this double-teardown stall. +- Both nodes left restored to their original config, bridge on, heap healthy. + +### Limitations / not covered + +- Task stack high-water mark and explicit task/client counts are not exposed by + the CLI; a dedicated `MQTT_MEMORY_DEBUG` build is needed for those (heap + stability was used as a proxy leak signal, and it held). +- The 72 h / 7-day soaks were not run (bounded representative cycles per agreed + scope); WiFi loss/recovery, TLS-handshake failure, queue saturation, JWT/NTP + failure, and `millis()`-rollover scenarios need external network control / + time injection and were not driven over serial. +- Both devices left restored to their original slot config with the bridge on. + +## Current Baseline + +The current branch has: + +- Native tests (GoogleTest, `[env:native]`) for MQTT presets, validation, topic + templates and routing, connection policy, packet-queue policy, payload + construction, WebConfig keys, the WebConfig batch state machine, the MQTT + lifecycle/teardown seam, the `/mqtt_prefs` codec, the atomic prefs store, the + runtime-buffer lifecycle, and upstream `Utils::toHex` and mesh-table behavior. + 15 suites as of the 2026-07-19 upstream merge. +- ArduinoJson pinned to 7.4.3 across the native and all firmware environments, + enforced in CI by `scripts/check_arduinojson_pin.py`. +- PR CI (`.github/workflows/`) that runs the native suite and compiles both + representative MQTT observer smoke builds: + `Heltec_v3_repeater_observer_mqtt` (non-PSRAM) and + `T_Beam_S3_Supreme_SX1262_repeater_observer_mqtt` (PSRAM). +- Symmetric PSRAM runtime buffers: `begin()` allocates through + `allocateRuntimeBuffers()` and `end()` frees and nulls through + `releaseRuntimeBuffers()`, so raw-data caching and the PSRAM JSON buffers are + restored after a restart (`MQTTRuntimeBufferLifecycle.h`). +- A versioned `/mqtt_prefs` loader extracted into fork-owned, host-tested seams + (`MQTTPrefsCodec.h`, `MQTTPrefsStorage.h`, `MQTTPrefsAtomicStore.h`) that + classifies every deployed layout, preserves an unknown or newer file without + overwriting it, and rejects corrupt input without out-of-bounds reads. +- WebConfig request/result correlation, failed-batch reboot gating, and a + process-lifetime HTTP listener that avoids deleting an object still referenced + by an asynchronous request. +- Pure MQTT policy helpers that reduce decision duplication while leaving the + production bridge as the integration point. +- A **wired** WebConfig batch/reboot/stop state machine: `WebConfigServer.cpp` + calls `WebConfigBatch.h` directly for POST classification, drain pacing and + all-ok accumulation, reboot scheduling/firing, result classification, + confirm-reboot arming, and stop gating; `MAX_BATCH`/`STOP_WARN_MS` alias the + spec constants. The host tests therefore cover production, not a parallel copy. + +This is a solid guardrail and unit-test foundation, but it does not yet validate +cross-core state ownership beyond the Phase 5 stop path, the WebConfig batch +machine over real HTTP, or long-running heap behavior. Those are the remaining +phases. + +## Constraints + +1. MQTT preference-file layouts are fleet-critical. Unknown newer formats must + not be overwritten, and every supported old layout must remain recoverable. +2. Upstream MeshCore changes are merged regularly. Prefer additive fork-owned + helpers and small adapters over reorganizing upstream-heavy files. +3. The non-PSRAM profile is the memory-pressure baseline; the PSRAM profile is + a separate allocation/lifecycle path and must also be tested. +4. Risky lifecycle changes require fast deterministic tests before hardware + soak testing. +5. Preserve observable behavior unless a behavior change is explicitly named, + reviewed, and tested. + +## Change-Control Discipline (Stop-and-Ask) + +This roadmap is executed incrementally, often by an agent working one phase at a +time. Each unit of work is scoped to its assigned phase. When work uncovers +something outside that scope, the correct action is to **stop and ask, not to fix +forward.** Silent scope expansion is the primary way a targeted stability change +turns into an unreviewed refactor or a needless merge-conflict surface. This +section is binding on anyone -- human or agent -- executing the plan. + +### Stop and ask before writing code when any of these is true + +- A refactor larger than the current phase authorizes appears necessary -- for + example touching task lifecycle, client lifetime, cross-core ownership, or the + queue backends when the phase did not name them. +- A bug is discovered that is not described in the plan, especially one + affecting persistence, teardown, OTA, or heap behavior. +- A phase premise turns out to be false or already implemented (for example, a + described defect that is already fixed). Report the discrepancy and get the + plan re-baselined before writing code against a stale assumption. +- A change would alter observable behavior the phase did not explicitly name, + review, and test (Constraint 5). +- Work would touch upstream-heavy files (`MQTTBridge.cpp`, `CommonCLI`, the + role-specific `MyMesh` files) beyond a small, additive adapter (Constraint 2). +- A change touches fleet-critical persistence layouts, or could overwrite or + discard an unknown or newer `/mqtt_prefs` file (Constraint 1). +- Two phases would be combined in one change, or a "Explicitly Deferred Debt" + item would be pulled forward. +- A fix spans multiple files, changes a public seam, or would surprise a + reviewer expecting only the phase's stated work. + +### What "stop and ask" means in practice + +- Do not implement the out-of-scope change in the same pass. Record it. +- Report it with evidence: file and line references, a concrete reproduction or + failure scenario, and the specific invariant or constraint at risk. +- Present options with tradeoffs and a recommendation, then wait for an explicit + decision on scope before proceeding. +- File a newly found bug as its own item. Do not fold an opportunistic fix into + an unrelated phase's commit -- single-purpose commits are required, and an + unexpected fix deserves its own review. + +### What may proceed without asking + +- Work squarely inside the assigned phase and its acceptance criteria. +- Small, behavior-preserving fixes fully contained in fork-owned helper files + with matching host tests, where a reviewer would expect them as part of the + phase. + +When uncertain whether a change is in scope, treat it as out of scope and ask. +The cost of a question is a round trip; the cost of an unreviewed lifecycle or +persistence change across a fleet of thousands of devices is not. + +## Sequenced Work + +### Phase 0: Record the pre-change lifecycle characterization + +**Status: Not started -- the next actionable step, and prerequisite for Phases 4 +and 5.** + +Capture current behavior before changing shutdown mechanics. This provides a +reference for the lifecycle fakes and lets the later state-machine refactor +prove equivalence rather than relying on memory or a multi-hour soak. + +Characterize at least: + +- Normal `begin() -> connect -> end()` ordering. +- `end()` while disconnected, connecting, connected, publishing, retrying, and + applying a slot reconfiguration. +- Which callbacks can arrive during and after `end()`. +- Queue disposition and connection/status counters across stop and restart. +- Heap, largest allocatable internal block, task stack high-water mark, and + client/task counts before start, after start, after stop, and after restart. +- Partial initialization failures: queue allocation, task creation, client + allocation, and PSRAM-buffer allocation. + +Use instrumented hardware logging where necessary, but encode every behavior +that can be represented deterministically into the lifecycle fake tests in +Phase 4. Store representative logs as CI or test artifacts rather than enabling +permanent high-volume production logging. + +### Phase 1: Put MQTT/WebConfig firmware smoke builds in PR CI + +**Status: Complete on this branch.** PR CI already compiles both smoke builds and +runs the native suite and the ArduinoJson pin check. Still pending against the +acceptance criteria: build-size artifact/threshold reporting (criterion 4) and +the optional ASan/UBSan native job. The description below is retained as the +record of intent. + +The PR build matrix historically did not compile an MQTT observer target; it now +compiles two required smoke builds for changes touching firmware, variants, +PlatformIO configuration, MQTT/WebConfig helpers, or their tests: + +- `Heltec_v3_repeater_observer_mqtt` for the constrained non-PSRAM path. +- `T_Beam_S3_Supreme_SX1262_repeater_observer_mqtt` for the PSRAM path. + +Keep the native suite required. Add ASan/UBSan to a separate native job if the +PlatformIO native toolchain supports it reliably. Record static RAM and flash +usage and fail only on reviewed limits with enough headroom to avoid noisy +one-byte regressions. + +Acceptance criteria: + +- Pull requests cannot merge when native tests or either representative MQTT + build fails. +- The build log shows ArduinoJson 7.4.3 for both firmware profiles. +- CI checks that every ArduinoJson declaration remains pinned to 7.4.3. +- Build-size output is retained as an artifact or job summary. + +### Phase 2: Fix PSRAM restart resource symmetry + +**Status: Complete on this branch.** The asymmetry described in the original plan +has been fixed; this section now documents the intended design and the tests that +guard it, not outstanding work. + +PSRAM-backed raw-data and JSON buffers are allocated in `begin()` through +`allocateRuntimeBuffers()` (idempotent, allocate-if-missing), freed and nulled by +`end()` through `releaseRuntimeBuffers()`, and reallocated by a later `begin()`. +Raw-data caching and the PSRAM JSON buffers therefore survive a restart; the +task-stack buffer is used only when a live allocation actually fails. The pure +helpers live in `MQTTRuntimeBufferLifecycle.h`, covered by +`test_mqtt_runtime_buffer_lifecycle`. + +The design keeps symmetric runtime-resource operations: + +- `allocateRuntimeBuffers()` called by `begin()` or a shared initialization + path. +- `releaseRuntimeBuffers()` called by `end()` and initialization rollback. + +The operations must be idempotent, handle partial allocation, and preserve the +existing graceful fallback when PSRAM allocation fails. Avoid changing client +or task shutdown behavior in this phase. + +Tests and validation: + +- Allocation success, partial failure, repeated allocation, and repeated + release using a narrow allocator fake. +- Multiple `begin()/end()` cycles do not lose the raw-data buffer or permanently + move JSON serialization onto the task stack. +- Initialization failure releases only resources owned by that attempt. +- Representative PSRAM and non-PSRAM firmware builds pass. +- A short hardware restart loop shows stable free heap and largest free block. + +### Phase 3: Add binary MQTT preference migration fixtures + +**Status: Complete on this branch.** The versioned loader, the frozen-layout +`static_assert`s, and the migration/fixture coverage described below are +implemented in `MQTTPrefsCodec.h`, `MQTTPrefsStorage.h`, and +`MQTTPrefsAtomicStore.h`, and host-tested by `test_mqtt_prefs_codec` and +`test_mqtt_prefs_atomic_store`. Unknown-newer files are preserved and corrupt +input is rejected without overwrite. Residual: the concrete filesystem adapter +and the `load/saveMQTTPrefs()` orchestration still live in `CommonCLI.cpp` +(platform-coupled by design); keep that adapter small. The description below is +retained as the record of intent. + +Build deterministic tests around the versioned `/mqtt_prefs` loader before +upstream merges change `CommonCLI`, filesystem behavior, or preference structs. +Use checked-in, non-secret binary fixtures or byte arrays representing each +deployed layout. + +Required cases: + +- Pre-slot layout. +- Three-slot layout. +- Legacy headerless six-slot layout. +- Shorter version-1 payload with newer fields defaulted. +- Current version round trip. +- Truncated header and truncated payload. +- Invalid magic and implausible payload length. +- Unsupported newer version: run with safe defaults and preserve the original + file without overwriting it. +- Migration failure or interrupted save leaves a recoverable source file. +- Existing credentials, slot ordering, and publish flags survive migration. + +Prefer extracting a fork-owned serializer/decoder seam over host-compiling all +of `CommonCLI`. Keep the production adapter small and retain the frozen-layout +`static_assert`s. + +Acceptance criteria: + +- All known deployed layouts have a fixture and field-by-field expected result. +- Corrupt input cannot cause an out-of-bounds read or silent overwrite. +- A future `MQTTPrefs` layout change fails tests until its migration and fixture + are added deliberately. + +### Phase 4: Establish ownership and teardown test seams + +**Status: Seams, ownership doc, and teardown tests landed; production rewiring +deferred to Phase 5 by explicit decision (scope: "seams + tests only").** The +fork-owned pure lifecycle state machine and narrow dependency seam +(`src/helpers/MQTTLifecycle.h`), the teardown-focused test matrix +(`test/test_mqtt_lifecycle/`), and the ownership model (`MQTT_OWNERSHIP.md`) are +in place. `MQTTBridge.cpp` was intentionally left untouched to keep the +merge-sensitive file free of churn until the Phase 5 change lands as one +reviewable unit. The invasive production work -- publishing the plain-data +snapshot and repointing consumers at it, replacing the `volatile` handshakes +with a command queue / task notifications, and the cooperative-shutdown `end()` +rewrite with a `begin()` double-call guard -- is carried into Phase 5. + +Verified premise (with Phase 4 refinements): the loop task, WebConfig, CLI, and +`AlertReporter` currently read the MQTT task's live, mutable slot objects and +client counters cross-core without a lock or a published snapshot. +`getSlotStatusSnapshot()` is built on demand from live state despite its name -- +the refactor must actually publish a plain-data snapshot, not assume one exists. +Refinements found while mapping the code (see `MQTT_OWNERSHIP.md` for +file:line references): + +- The snapshot's `name`/`state` `const char*`s point at static rodata, so they + are not dangling; the real hazards are reading the mutable `slot.preset` + pointer value (Core 0 can null/reassign it) and `slot.client->getPublishOk()` + (a client pointer Core 0 can `delete` during teardown). +- The live-bridge web reads are in the `MyMesh.cpp` app layer (`buildStatsJson`), + not `WebConfigServer.cpp`, which reads only a compile-time constant. +- `end()` clears only the `s_mqtt_bridge_instance` singleton; the app's `bridge` + pointer and `AlertReporter::_bridge` are not cleared, so instance reads can + touch a torn-down bridge. + +This phase is the safety net for cooperative shutdown. It must land before the +shutdown state machine. + +#### Ownership model + +Document one owner for each mutable runtime domain: + +- MQTT task: clients, slot connection state, NTP client operations, publish + counters, and the packet drain path. +- Loop task: CLI execution, preference persistence, WebConfig batch draining, + and bridge lifecycle requests. +- Producer/radio context: packet staging before queue handoff. +- Async TCP context: request parsing and immutable response handoff only. + +Replace cross-core `volatile` handshakes with an appropriate primitive: + +- Task notifications or a command queue for one-way lifecycle/reconfigure/NTP + requests. +- Atomics only for truly independent scalar state. +- Immutable published snapshots for WebConfig, CLI diagnostics, and alerting. +- A mutex only where ownership transfer or snapshot publication cannot express + the operation cleanly. + +In particular, loop/WebConfig code must not directly inspect mutable MQTT slot +objects or client counters. The MQTT task should publish a plain-data status +snapshot. + +#### Narrow lifecycle fakes + +Introduce interfaces or callbacks for only the dependencies needed to drive the +lifecycle deterministically: + +- Clock/timer. +- Task start, stop request, acknowledgment, and timeout. +- MQTT client connect/disconnect and delayed callbacks. +- Queue depth/pop/requeue behavior. +- Runtime allocator and heap measurements. +- OTA coordinator/barrier. + +Required teardown-focused tests: + +- Stop while connecting, connected, publishing, retrying, renewing a token, + running NTP, and applying a slot change. +- Callback delivered before stop, during stop, after disconnect, and after the + stop acknowledgment. +- Duplicate stop, stop before full initialization, and restart after stop. +- Timeout/fallback behavior when the MQTT task or client does not acknowledge. +- No client, queue, buffer, or task access after its owner releases it. +- Current queue and diagnostic behavior is preserved unless a change is + explicitly approved. + +### OTA Teardown Barrier: release-critical scenario + +**Status: Not started. This is the fix for a known shipping crash, not a +hypothetical hardening target.** With a broker down over `wss`, the abrupt +`vTaskDelete` in `end()` can kill the MQTT task inside mbedTLS; `destroySlotClients()` +then frees client buffers on a possibly-corrupted heap and OTA begins flashing +with no barrier -- the observed teardown heap panic. There is no coordination +today between MQTT shutdown and flash writing beyond straight-line ordering on +the loop task. + +Treat OTA teardown as a first-class test target throughout Phases 0, 2, 4, 5, +and 7. It is not merely another restart case. + +The required invariant is: firmware erase/write must not begin until MQTT +shutdown has reached a safe acknowledgment point, and the bridge must not be +restarted while flash writing is active. + +Required scenarios: + +- OTA requested while MQTT is connecting, publishing, draining retries, or + handling a callback. +- MQTT stop succeeds and OTA begins only after the teardown barrier. +- MQTT stop times out: OTA aborts safely rather than writing under uncertain + ownership. +- OTA preflight or download aborts before flashing: the bridge restarts once and + returns to the prior configured behavior. +- Successful OTA: no bridge restart is attempted before the device reboots. +- Power loss/reset at the platform-supported OTA boundaries retains a bootable + partition; this portion requires hardware/platform validation. +- Repeated failed OTA attempts do not leak heap, duplicate WiFi callbacks, or + leave the bridge permanently stopped. + +Retain the prior teardown/heap-panic reproduction as a regression artifact if +available. OTA-related lifecycle tests are release gates for any future change +to bridge teardown, OTA sequencing, MQTT client lifetime, or task ownership. + +### Phase 5: Implement cooperative MQTT shutdown + +**Status: Minimal cooperative shutdown implemented on branch +`phase5/cooperative-mqtt-shutdown` (scope: "the smallest change that fixes the +OTA teardown panic as one reviewable unit"). Native suite green; the non-PSRAM +observer firmware smoke build compiles. NOT yet hardware-validated -- that is the +Phase 7 gate -- and the stop timeout is a Phase-0 placeholder (see below).** + +What landed (wiring the Phase 4 `MQTTLifecycle` state machine into the bridge): + +- `MQTTBridge` owns a `MQTTLifecycle::Coordinator` driven **only** by the loop + task (Core 1) from `begin()`/`end()`. A nested `LifecycleOps` binds the pure, + host-tested `Ops` spec to FreeRTOS/PsychicMqttClient. +- `end()` no longer blind-`vTaskDelete`s. It requests a cooperative stop; the + MQTT task (Core 0) sees a new `volatile _stop_requested`, tears down its own + clients **on Core 0 where the mbedTLS contexts live**, sets `_stop_acked` + last, and self-terminates. `end()` waits (bounded) for the ack, then frees the + queue/buffers. This removes the "kill the task mid-mbedTLS, then free client + buffers on a corrupted heap" teardown path. +- Bounded stop timeout -> reviewed fallback: on timeout the task is force-killed + and torn down on Core 1 (the old behavior), but a **dirty latch** is set so + `canFlashAfterStop()` is false and OTA flashing is withheld. +- `begin()` has a double-call guard and syncs the Coordinator to `Running`. +- OTA teardown barrier: `simple_repeater`'s deferred-OTA fire site aborts and + resumes the bridge unless the preceding `end()` reported a clean stop. + +Deferred (kept out of this reviewable unit; carried to a Phase 5b / Phase 6): +replacing the `volatile` NTP/reconfigure handshakes with a command channel, and +publishing a plain-data status snapshot to repoint the Section 1/Section 2 consumers in +`MQTT_OWNERSHIP.md` (the `AlertReporter`/`buildStatsJson` instance-pointer reads +that can still touch a torn-down bridge). Those are not required to fix the OTA +panic and would enlarge the merge-sensitive diff. + +**Phase 0 dependency -- CHARACTERIZED + FIXED 2026-07-19:** the flat +`MQTT_STOP_TIMEOUT_MS` = 8 s placeholder was **too small** (measured per-wss-slot +teardown ~5-6 s sequential -> ~11-12 s at 2 slots, ~16 s at 3, ~27-30 s at 5; at +8 s healthy multi-slot stops tripped the dirty fallback and the OTA barrier +withheld flashing). Replaced with a **slot-scaled timeout** set per stop in +`end()`: `5 s + 8 s x enabled_slots` (`MQTT_STOP_TIMEOUT_BASE_MS` / +`MQTT_STOP_TIMEOUT_PER_SLOT_MS`, applied via `Coordinator::setStopTimeoutMs()`). +Hardware-verified: a 2-slot stop that force-timed-out pre-fix now acks clean in +~11.7 s within the 21 s budget. See "Hardware Characterization Results (Phase 0 & +Phase 7)". + +The original plan of record follows. Replace direct task deletion with an +explicit lifecycle such as: + +`Stopped -> Starting -> Running -> StopRequested -> Stopping -> Stopped` + +The exact representation can differ, but it must provide: + +- Idempotent start and stop requests. +- A stop request delivered through the established ownership channel. +- Cessation of new connects, publishes, retries, and reconfigurations. +- Ordered client/service shutdown on the MQTT task. +- A completion acknowledgment before the loop task releases queues, buffers, or + other shared resources. +- A bounded timeout with clear diagnostics and a deliberately reviewed fallback. +- Safe restart after a completed stop. +- An OTA barrier that consumes the same completion acknowledgment. + +Do not combine this phase with queue-loop deduplication, broad bridge cleanup, +or unrelated feature changes. + +Acceptance criteria: + +- All Phase 4 lifecycle tests pass, including delayed/stale callbacks. +- Characterized behavior from Phase 0 is preserved or differences are explicitly + documented and approved. +- OTA teardown-barrier tests pass. +- Repeated hardware start/stop cycles show no downward heap or largest-block + trend and no task/client-count growth. + +### Phase 6: Expand request, queue, connection, and publication integration tests + +**Status: Partial -- branch `phase6/integration-tests` (draft PR, base `phase5`).** +The remaining *inline* decision points that were host-testable have been extracted +into the pure policy seams and covered: + +- WiFi STA reconnect backoff moved out of `handleWiFiConnection()` into + `MQTTConnectionPolicy::{wifiReconnectBackoffMs,wifiReconnectDue, + nextWifiBackoffAttempt}` (behavior-preserving; adversarially reviewed for + rollover/boundary equivalence) with `test_mqtt_connection_policy` cases. +- The (packet, raw) publication-outcome pairing named as + `MQTTPacketQueuePolicy::queuedPacketPublished()` and wired at both queue-drain + sites, with `test_mqtt_packet_queue_policy` cases (partial success = completed). +- `MQTTPublicationType` values frozen in `test_mqtt_topic_router`; the + bridge-side `MQTTMessageType` alignment was already a compile-time `static_assert`. + +The **WebConfig POST/result/reboot/stop state machine** (the largest gap) has a +pure, host-tested spec -- `src/helpers/WebConfigBatch.h` + +`test/test_webconfig_batch/` -- and as of 2026-07-19 it is **wired**: +`WebConfigServer.cpp` calls it at every decision point, so the spec is +load-bearing and cannot drift from production. `MAX_BATCH` and `STOP_WARN_MS` +alias `kMaxBatch`/`kStopWarnMs` for the same reason. + +Two asymmetries between spec and caller are deliberate, and are documented in +the header so a future reader does not "simplify" them back: + +1. `finishRebootAt()` returns 0 for "no reboot scheduled", but the caller only + ASSIGNS `_reboot_at` when the result is non-zero. `_reboot_at` is not solely + batch-owned -- the manual `/api/reboot` route arms it from the async_tcp task, + possibly while a batch is still draining -- so an unconditional assign would + silently cancel a manual reboot. This was caught during the wiring, not by a + test; the host suite does not model the two owners of `_reboot_at`, which is a + coverage gap worth closing. +2. `classifyPost()` is consulted in two phases, because the change count is only + known after the `set` map is parsed, and parsing must not precede the + Replay/Busy answer (a replayed POST carrying a bad key must still get its 202). + +**Verification status of the wiring:** native suite and both smoke builds green; +V3 hardware shows a clean WebConfig AP start/stop (the rewired `stopStep` path +takes the Finalize branch with no handler-wait warning). **Not verified: the +POST -> drain -> result -> reboot sequence over real HTTP.** LAN mode needs an +admin-password login and the setup AP needs a client associated to the device's +SoftAP; neither was driven. Given that this server was originally tuned against +real iOS captive-portal behavior, HTTP caching, and route ordering, treat an +end-to-end portal save as a required check before this ships. + +Still open (each a good follow-up PR): the end-to-end HTTP exercise above, and +the **queue-orchestration** behaviors (FIFO ordering, evict/requeue-failure +interplay, the two adapters' drop-vs-keep-head divergence), which need a +fake-queue harness. The original scope list follows. + +After lifecycle ownership is stable, broaden deterministic integration coverage: + +- WebConfig POST/result/reboot/stop behavior, including lost responses, + duplicate request IDs, concurrent clients, partial command failure, and stop + with an active handler. +- MQTT queue behavior for FreeRTOS and circular-buffer adapters: overflow, + delayed retry, requeue failure, stale flush, queue ordering, and + `millis()` rollover. +- Packet succeeds/raw fails and raw succeeds/packet fails. +- Connection backoff, stable reset, breaker probe, WiFi recovery, token renewal, + and slot reconfiguration callback ordering. +- Topic and payload contracts at the bridge boundary, so enum/cast/adaptation + mistakes are covered in addition to the pure helper tests. + +Once both queue backends are covered by the same behavioral contract, consider +extracting a shared `processQueuedPacket()` that returns an outcome while each +backend retains pop/requeue/dequeue ownership. Do not make queue deduplication a +prerequisite for the stability work. + +### Phase 7: Establish uptime, memory, and fault-injection gates + +**Status: Not started.** The final validation gate; runs after the lifecycle and +OTA-barrier work is in place. + +Use hardware soak tests to validate the already-tested design, not to discover +basic lifecycle errors for the first time. + +Run at least one constrained non-PSRAM board and one PSRAM board through: + +- Stable broker operation. +- Broker unavailable, rejecting authentication, and flapping. +- WiFi loss/recovery and credential changes. +- TLS handshake failures and repeated reconnect backoff. +- Queue saturation and slow broker behavior. +- Repeated slot reconfiguration and full bridge stop/start. +- WebConfig start/save/stop cycles. +- JWT renewal and NTP failure/recovery. +- OTA success, preflight abort, download failure, teardown timeout, and resume. +- At least one test crossing the 32-bit `millis()` rollover boundary, accelerated + where hardware time injection is available. + +Record and threshold: + +- Minimum free internal heap. +- Largest free internal block. +- MQTT task stack high-water mark. +- PSRAM free/largest block where available. +- Queue and outbox high-water marks. +- Connect/disconnect/retry/publish counters. +- Watchdog and reset reason. +- Task/client counts across restart cycles. + +Recommended gates are a 72-hour fault-injection run followed by a seven-day +stable run. Store machine-readable time series and a concise summary artifact. +Avoid enabling high-volume diagnostic logging in production builds. + +## Branch and Release Channels + +`observer-firmware-dev` is the standing development line, not a one-off merge +branch. It is where upstream merges land and where the dev/beta firmware channel +is built from; it is promoted into `webconfig` (and onward to the flex mainline) +when its contents are ready to ship. Future upstream merges land ON this branch +rather than creating a new dated branch each time. + +Two firmware channels are published, fully separated so a node cannot cross +between them by accident: + +| | Production | Dev/Beta | +|---|---|---| +| Source branch | flex mainline | `observer-firmware-dev` | +| Workflow | `build-observer-firmwares.yml` (push-triggered) | `build-observer-firmwares-beta.yml` (manual dispatch) | +| Release tag | `observer-mqtt-latest` | `observer-mqtt-beta-latest` | +| OTA manifest base | `observer.gessaman.com/v` | `observer.gessaman.com/beta/v` | +| Download host | `observer-fw.gessaman.com` | `observer-fw-beta.gessaman.com` | +| Flasher config | `config.json` (default) | `config-beta.json` (`?config=config-beta`) | +| Embedded version | `v1.16.0.N-observer-` | `v1.16.0.N-observer-beta-dev-` | + +The channel is baked into each binary as `OTA_MANIFEST_BASE` (injected by +`build.sh`, overridable via `OTA_MANIFEST_BASE_URL`), so a node only ever +receives OTA updates from the channel it was flashed from. Both channels +deliberately share `FIRMWARE_VERSION`: the OTA logic treats a differing base +version as "always an update", so channels must separate by manifest URL, never +by base version. + +## Upstream Merge Record -- 2026-07-19 (`observer-firmware-dev`) + +First `upstream/dev` merge since the v1.16.0 base (`8c0d5c5b`, 2026-06-06): +**191 upstream commits, 14 conflicted files, ~18 hunks.** The fork was 349 +commits ahead. Recorded here because the next merge starts from these +resolutions (all captured in `rerere`). + +### The finding that matters most: `/com_prefs` is safe to reorder + +Upstream moved `rx_boosted_gain` and `path_hash_mode` to the tail of +`struct NodePrefs`, while the fork holds them mid-struct. This *looks* like a +fleet-critical layout divergence (Constraint 1) and was analyzed as one before +resolving. It is not: + +**`/com_prefs` is serialized field-by-field at explicit byte offsets, not as a +struct dump.** `NodePrefs` member order is in-memory only and has no effect on +the file. The fork's extracted `writeCommonPrefsImage()` was verified +byte-identical to upstream's inline writer at every offset -- 79 (pad), 121, 122, +and 290-294 -- so either struct order produces the same image. No migration was +needed, and none should be invented for this in future merges. + +Corrected while here: a comment claiming `rx_boosted_gain` lives at +`/com_prefs` offset 79. Offset 79 is a pad; the field is written at 290. The +comment would have misled exactly the analysis above. + +Note the asymmetry this creates: `/mqtt_prefs` (fork-owned) IS layout-critical +and versioned; `/com_prefs` is offset-addressed and tolerant of struct +reordering. Do not generalize one file's rules to the other. + +### Resolutions + +- `CommonCLI.h` -- kept the fork's `NodePrefs` (a superset) and adopted upstream's + **`setRxBoostedGain(bool)` -> `bool`** signature change, which upstream's + `CommonCLI.cpp` now uses to report "unsupported". A real semantic API change, + exactly the kind the discipline list warns can hide behind a clean merge. +- `CommonCLI.cpp` -- kept the fork's legacy `/com_prefs` migration block and the + `writeCommonPrefsImage()` call. +- `UITask.cpp` -- genuine three-way merge: upstream's `drawTextCentered` and + powering-off screen, plus the fork's `WITH_WEBCONFIG` portal/reboot screens. +- `ESP32Board.cpp`, `MeshCore.h`, `platformio.ini` -- keep-both (fork OTA additions + alongside upstream `powerOff`/`enterDeepSleep` and `Packet.cpp`). +- `MicroNMEALocationProvider.h` -- took upstream's `claim()`/`release()` and added + the `_claims` member they depend on. +- `MyMesh.cpp`/`.h` (repeater + room server) -- kept the fork's superset defaults. +- Removed duplicate declarations that auto-merge produced without conflicting: + `RadioLibWrapper::_cad_enabled` and `MyMesh::getCADEnabled()`. **These compiled + only after being caught by the build, not by Git** -- a reminder that a + conflict-free merge is not a correct merge. + +### Verification + +Native 15/15 (including upstream's new `test_mesh_tables`), both MQTT smoke +builds, ArduinoJson pin check. On V3 hardware (non-PSRAM, 1 wss slot): clean +boot with `/com_prefs` values intact across the flash (the end-to-end proof of +the `NodePrefs` resolution), WiFi -> NTP -> MQTT1 connect -> status published, +`Free=137612 Max=124916` matching the pre-merge healthy baseline, and a clean +cooperative teardown -- `1 enabled slot(s), timeout 13000 ms` -> `cooperative +stop` -> `stop acknowledged (clean)` -> `Bridge stopped (clean)`. Phase 5 and the +slot-scaled timeout both survive the merge. + +Not verified: the V4/PSRAM path, and the WebConfig HTTP batch machine (see +Phase 6). + +### Cost signal for scheduling the next merge + +Upstream's deltas to the hot files were small (`CommonCLI.cpp` 165 lines, +`MyMesh.cpp` 70, `CommonCLI.h` 26) against 107 and 61 fork commits on those same +files. **The fork is the churn source, not upstream** -- so merge cost scales +with how much fork work accumulates between merges, not with upstream velocity. +Six weeks of drift cost roughly a half-day. Merge after each phase lands rather +than batching. + +## Continuous Upstream-Merge Discipline + +Apply these practices throughout every phase: + +- Keep fork-owned logic in additive helper files and keep adapters in + upstream-heavy files small. +- Avoid drive-by formatting, renames, and unrelated cleanup in + `MQTTBridge.cpp`, `CommonCLI`, and the role-specific `MyMesh` files. +- Use single-purpose commits with messages that describe their full scope. +- Merge upstream frequently enough that conflicts remain attributable. +- Before each upstream merge, record the native-test, representative-build, + firmware-size, and persistence-fixture baseline. +- After the merge, run native tests, both MQTT smoke builds, preference fixtures, + and the relevant lifecycle/OTA tests before resolving the merge as complete. +- Review semantic behavior at adapters even when Git reports no textual + conflict; upstream signature, lifetime, and task-context changes can invalidate + fork assumptions silently. +- Reuse resolutions only after revalidating them against the new upstream code. +- A conflict-free merge is not a correct merge: the 2026-07-19 merge produced two + duplicate declarations that Git resolved silently and only the compiler caught. + Always build both smoke targets before treating a merge as done. +- Extract repeater/room-server WebConfig ownership into a shared fork helper when + that integration next requires material change; do not perform a standalone + broad move solely for aesthetics. + +## Explicitly Deferred Debt + +These items are recognized but are not prerequisites for the sequenced +stability work: + +- Shared packet-drain processing between FreeRTOS and circular-buffer backends. +- Repeater/room-server WebConfig integration duplication. +- Moving the WebConfig HTML generator out of the common ESP32 build path. +- The secret-placeholder edge case for a credential exactly equal to the UI + sentinel. +- Secure setup-AP authentication beyond the currently documented open/optional + PSK threat model. + +Revisit an item when its code is already being changed, when operational data +raises its priority, or when tests make the refactor substantially safer. + +## Completion Definition + +This roadmap is complete when: + +- PR CI protects native logic and both representative firmware memory paths. +- Every deployed MQTT preference layout has a passing migration fixture. +- Runtime resources survive repeated start/stop cycles symmetrically. +- Cross-core ownership is explicit and diagnostics consume immutable snapshots. +- Cooperative shutdown passes deterministic lifecycle and OTA-barrier tests. +- Hardware fault-injection and stable soaks meet reviewed memory, stack, task, + and reliability thresholds. +- Upstream merges use the same automated gate and do not require broad rewrites + of fork-owned behavior. diff --git a/WEB_CONFIG_REVIEW.md b/WEB_CONFIG_REVIEW.md new file mode 100644 index 00000000..be7bf5bb --- /dev/null +++ b/WEB_CONFIG_REVIEW.md @@ -0,0 +1,516 @@ +# WebConfig Branch Review + +## Scope + +This review covers the fork-owned WebConfig and Heltec Tracker additions on the `webconfig` branch, principally commits `d7a7e1b6`, `dfee21a0`, and `639c07a4`, plus the fork-owned MQTT/CLI paths they invoke. Issues inherited unchanged from `meshcore-dev/MeshCore` are intentionally excluded. + +No implementation changes are included in this document. + +## Executive Summary + +The portal builds successfully and has a sound high-level design: HTTP handlers avoid directly running CLI/radio operations, configuration writes are marshalled to the loop task, secrets are represented by placeholders, and the UI is self-contained for offline provisioning. + +Before deployment, the most important work is: + +1. Secure setup/forced-AP reachability. +2. Correlate each save with its own result. +3. Prevent reboot after partially failed wizard saves. +4. Make persisted Wi-Fi and MQTT settings match live runtime behavior. +5. Remove cross-task preference/statistics races and long loop-task blocking. + +## Priority 1: Security and Data Integrity + +### 1. Forced AP mode exposes the unauthenticated setup API on the existing LAN + +**Severity:** High + +**Locations:** + +- `src/helpers/esp32/WebConfigServer.cpp:125-134` +- `src/helpers/esp32/WebConfigServer.cpp:346-349` +- `src/helpers/bridges/MQTTBridge.cpp:801-866` +- `src/helpers/bridges/MQTTBridge.cpp:919-926` +- `examples/simple_repeater/MyMesh.cpp:1329-1339` +- `examples/simple_room_server/MyMesh.cpp:974-984` + +**Problem:** + +`MQTTBridge::end()` deliberately leaves the STA association connected. Forced setup then selects `WIFI_AP_STA`, starts the server on port 80, and disables authentication for all requests while `MODE_SETUP` is active. The server is therefore reachable through both the setup AP and the existing LAN connection. + +The comment that setup mode implies physical proximity is not valid in forced-AP mode. Any host on the existing LAN can read or change configuration and reboot the node without the admin password. + +**Suggested fix:** + +Choose one of these approaches: + +- Disconnect and disable STA before entering unauthenticated setup mode, leaving only the SoftAP interface active. If scanning requires STA mode, keep the interface enabled but explicitly disconnect it and prevent auto-reconnect. +- Bind the setup listener only to the SoftAP interface if the ESPAsyncWebServer/network stack supports reliable interface binding. +- Keep authentication enabled for setup-mode requests arriving through STA, while allowing unauthenticated requests only through the SoftAP interface. + +Add a hardware test that starts from an associated STA connection, stops the bridge, enters forced AP mode, and confirms that another LAN host cannot access `/api/config` or `/api/reboot` without authentication. + +### 2. Default provisioning sends credentials over an open HTTP network + +**Severity:** High + +**Locations:** + +- `src/helpers/esp32/WebConfigServer.cpp:130-134` +- `src/helpers/esp32/WebConfigServer.cpp:346-349` +- `src/helpers/esp32/WebConfigServer.cpp:530-598` + +**Problem:** + +No reviewed observer environment defines `WEBCONFIG_AP_PASSWORD`, so setup creates an open AP. Setup requests are unauthenticated and use plain HTTP. Wi-Fi passwords, MQTT passwords, and access tokens can be captured by another nearby station. A nearby party can also provision the device before the intended operator. + +**Suggested fix:** + +- Generate a unique per-device setup password from secure random data during first boot and show it on the display or a physical label. +- Alternatively require a short-lived setup PIN displayed on-device and validated by the API before secrets can be submitted. +- Add an explicit provisioning-session expiry and invalidate the setup credential after successful setup. +- If an intentionally open AP remains supported, document the threat model prominently and avoid describing proximity as authentication. + +### 3. Save results are not correlated with the submitted batch + +**Severity:** High + +**Locations:** + +- `webui/index.html:630-680` +- `src/helpers/esp32/WebConfigServer.cpp:547-550` +- `src/helpers/esp32/WebConfigServer.cpp:601-641` + +**Problem:** + +The server keeps the last completed result readable. The frontend polls `/api/config/result` after nearly every POST failure because the POST may have reached the device even if its response was lost. There is no request or batch identity. + +A rejected, lost, or concurrent save can therefore consume a previous or different tab's result, report success, clear `st.dirty`, and overwrite unsaved values with the device configuration. + +**Suggested fix:** + +- Have the browser generate a random request ID and include it in the POST body. +- Store that ID with the batch and include it in the 202 response and every result response. +- Require the frontend to accept `pending` or `done` only when the ID matches. +- Treat definite HTTP responses such as 400, 409, and 413 as final rejection; only poll after an ambiguous network failure. +- Return the active request ID with 409 so the browser can distinguish its own retry from another client's batch. + +Add regression tests for stale `DONE`, two tabs, 409, lost 202, lost result response, and a POST that never reached the server. + +### 4. Failed wizard commands still cause partial application and reboot + +**Severity:** High + +**Locations:** + +- `src/helpers/esp32/WebConfigServer.cpp:265-297` +- `src/helpers/esp32/WebConfigServer.cpp:624-637` +- `webui/index.html:950-957` + +**Problem:** + +Commands are persisted independently. Error replies are recorded, but `_batch_reboot` still schedules a fallback reboot, and reading the result arms the three-second reboot without checking aggregate success. The wizard can display rejected settings while the device reboots with a partially applied configuration. + +**Suggested fix:** + +- Track aggregate batch success while draining commands. +- Arm automatic reboot only if every required command succeeded. +- If partial persistence cannot be rolled back, return an explicit `partial` state listing applied and rejected keys. +- Keep the portal active after partial failure and present deliberate choices: correct and retry, or reboot with the partial configuration. +- Longer term, validate all values before executing any setter, or stage a complete preference snapshot and commit it atomically. + +## Priority 2: Runtime Correctness + +### 5. LAN Wi-Fi edits do not perform the reconnect promised by the UI + +**Severity:** High + +**Locations:** + +- `webui/index.html:339-350` +- `webui/index.html:630-641` +- `src/helpers/CommonCLI_Observer.cpp:278-285` + +**Problem:** + +The Wi-Fi tab says the connection will restart and the page will drop. Normal editor saves do not request reboot, and the underlying setters only persist the SSID/password. The active connection remains on the old network, while subsequent config reads show the new persisted values. + +**Suggested fix:** + +Use an explicit `Save and reconnect` or `Save and reboot` flow for SSID/password changes. Deliver the result first, then schedule the reconnect/reboot. Do not replace the displayed active network with the persisted network until the transition has begun successfully. + +### 6. MQTT publishing controls report success without changing live behavior + +**Severity:** High + +**Locations:** + +- `webui/index.html:303-319` +- `src/helpers/CommonCLI_Observer.cpp:215-238` +- `src/helpers/bridges/MQTTBridge.cpp:637-645` + +**Problem:** + +Status, packet, raw, RX, and TX options are copied into cached bridge fields during initialization. Their CLI setters persist preferences but do not refresh those fields or restart the bridge. The UI reports success although the running bridge continues using old values. + +**Suggested fix:** + +- Add one task-safe bridge method that reloads the publishing flags from preferences on the MQTT task, or +- coalesce these changes into one full bridge restart after the batch. + +If live application is not desirable, label these controls as restart-required and provide a reboot action instead of claiming immediate success. + +### 7. Custom MQTT endpoint edits do not reliably refresh the live slot + +**Severity:** High + +**Locations:** + +- `src/helpers/CommonCLI_Observer.cpp:385-425` +- `examples/simple_repeater/MyMesh.cpp:1354-1367` +- `examples/simple_room_server/MyMesh.cpp:999-1012` +- `src/helpers/bridges/MQTTBridge.cpp:1066-1072` +- `src/helpers/bridges/MQTTBridge.cpp:2076-2095` + +**Problem:** + +Custom server and port setters persist without requesting slot reconfiguration. Credential changes request reconfiguration, but the custom branch of `applySlotPreset()` reuses the existing slot fields instead of copying the current host, port, username, and password from preferences. + +**Suggested fix:** + +Create a single `reloadSlotFromPrefs(slot)` operation executed on the MQTT task. It should tear down the slot, copy every custom field and preset-dependent credential from `MQTTPrefs`, validate the complete endpoint, and then reconnect. Every slot-affecting CLI setter should queue that same operation rather than implementing partial restart behavior. + +### 8. Wizard review and validation mishandle intentionally cleared values + +**Severity:** Medium + +**Locations:** + +- `webui/index.html:907-929` +- `webui/index.html:931-933` + +**Problem:** + +Review calculations use `dirtyValue || originalValue`. An intentional empty string is treated as absent, so Review shows the original SSID/password/name/identity/slot while the submitted batch clears it. Final SSID validation can pass using the old SSID even though the effective new value is empty. + +**Suggested fix:** + +Resolve values by key presence, not truthiness. Add a helper such as `effectiveValue(key)` that returns `st.dirty[key]` when the key exists in `st.dirty`, including `""`, and otherwise returns `st.orig[key]`. Use it for review, validation, and reboot messaging. + +### 9. A credential equal to `********` cannot be configured + +**Severity:** Low + +**Locations:** + +- `webui/index.html:422` +- `webui/index.html:527-538` +- `src/helpers/esp32/WebConfigServer.cpp:564` + +**Problem:** + +The secret sentinel is also a valid possible password/token. The UI either considers it unchanged or the backend silently drops it. + +**Suggested fix:** + +Track secret-field edit state separately from the displayed placeholder. Prefer an empty password control with an adjacent "stored credential unchanged" indicator. If retaining the sentinel, reject that exact value with a clear validation message rather than silently ignoring it. + +## Priority 3: Concurrency, Responsiveness, and Lifecycle + +### 10. The preference mutex does not synchronize reads with writes + +**Severity:** Medium + +**Locations:** + +- `src/helpers/esp32/WebConfigServer.cpp:454-523` +- `src/helpers/esp32/WebConfigServer.cpp:265-280` +- `src/helpers/CommonCLI.cpp:1074-1216` +- `src/helpers/CommonCLI_Observer.cpp:198-486` + +**Problem:** + +`handleConfigGet()` takes `_mux` while reading preferences, but `drainBatch()` invokes CLI setters outside `_mux`. Those setters mutate the same strings and scalar fields. The lock therefore does not protect the data from concurrent async HTTP reads. + +**Suggested fix:** + +Prefer loop-task ownership: have the HTTP handler request a configuration snapshot, let `tick()` build it on the loop task, and return the immutable snapshot. If retaining direct reads, every writer must take the same mutex, with careful review to avoid holding it through flash writes or callbacks. + +### 11. Changing `mqtt.ntp` can block the main loop for up to 30 seconds + +**Severity:** Medium + +**Locations:** + +- `src/helpers/CommonCLI_Observer.cpp:249-272` +- `src/helpers/bridges/MQTTBridge.cpp:3230-3247` +- `src/helpers/esp32/WebConfigServer.cpp:275-280` + +**Problem:** + +Web batches run from `tick()` on the Arduino loop task. The NTP setter waits for the MQTT task in a polling loop that can last 30 seconds. During that period mesh/radio processing, portal DNS, further batch work, stats, and reboot timers stop progressing. + +**Suggested fix:** + +Persist and validate hostname syntax synchronously, then queue NTP synchronization without waiting. Represent NTP validation as a separate asynchronous operation/status result. The config batch should finish immediately and the UI can poll NTP validation independently. + +### 12. MQTT status snapshots read mutable cross-core state without synchronization + +**Severity:** Medium + +**Locations:** + +- `src/helpers/bridges/MQTTBridge.cpp:295-320` +- `examples/simple_repeater/MyMesh.cpp:1403-1408` +- `examples/simple_room_server/MyMesh.cpp:1048-1053` + +**Problem:** + +The loop task reads slot state and client counters while the MQTT task and callbacks mutate them. This can yield inconsistent snapshots and formal C++ data races. + +**Suggested fix:** + +Build a plain-data `SlotStatusSnapshot` array on the MQTT task and publish it atomically or under a shared lock. The web/loop side should read only the copied snapshot and should not call client methods cross-task. + +### 13. Fixed-delay server deletion does not prove connections have ended + +**Severity:** Medium, hardware/library stress-test required + +**Locations:** + +- `src/helpers/esp32/WebConfigServer.cpp:188-204` + +**Problem:** + +Stopping the listener and waiting two seconds does not establish that accepted slow or stalled clients have completed. Deleting the server and route lambdas while a request remains active risks use-after-free or crashes. + +**Suggested fix:** + +Use connection/request reference counting and finalize deletion only after all accepted requests disconnect, with an upper-bound recovery policy. If the library cannot expose lifecycle state safely, consider retaining one server instance for the firmware lifetime and enabling/disabling routes/listening without deleting captured handler state. + +### 14. Repeated starts permanently grow the global default-header list + +**Severity:** Medium + +**Location:** + +- `src/helpers/esp32/WebConfigServer.cpp:177-185` + +**Problem:** + +`DefaultHeaders::Instance().addHeader()` appends to a process-lifetime list on every server creation. Repeated start/stop cycles consume heap and add duplicate `Cache-Control` headers to every response. + +**Suggested fix:** + +Register the global header once through a static one-time guard, or avoid global headers and add `Cache-Control: no-store` to each WebConfig response. + +### 15. Stats polling can accumulate overlapping requests + +**Severity:** Low + +**Locations:** + +- `webui/index.html:717-747` + +**Problem:** + +A three-second `setInterval` starts a new request even if the previous fetch is still pending. Degraded Wi-Fi can accumulate requests and pressure both browser and ESP32 memory. + +**Suggested fix:** + +Schedule the next poll with `setTimeout` only after the current request settles. Add an in-flight guard and use the existing API timeout support. + +## Priority 4: Validation, Build, and Maintainability + +### 16. Malformed keys can cause out-of-bounds reads and invalid JSON errors + +**Severity:** Low + +**Locations:** + +- `src/helpers/esp32/WebConfigServer.cpp:41-59` +- `src/helpers/esp32/WebConfigServer.cpp:554-562` + +**Problem:** + +Short attacker-supplied keys are indexed at positions 4 and 5 without first establishing their length. Rejected keys are interpolated into hand-built JSON without escaping quotes or backslashes. + +**Suggested fix:** + +Check the key length before `memcmp` and indexed access. Construct error responses with ArduinoJson rather than string interpolation. + +### 17. Input lengths do not match fixed firmware buffers + +**Severity:** Low + +**Locations:** + +- `webui/index.html` configuration controls +- `src/helpers/CommonCLI.h:106-159` + +**Problem:** + +Several controls accept more text than the fixed `MQTTPrefs` fields retain. The CLI truncates values while the UI reports success. + +**Suggested fix:** + +Add `maxlength` values matching each destination buffer minus the NUL terminator, and validate lengths in the backend because client-side constraints are bypassable. Return a clear error instead of silently truncating. + +### 18. Generated HTML freshness relies only on timestamps + +**Severity:** Low + +**Location:** + +- `scripts/generate_webconfig_html.py:30-38` + +**Problem:** + +A generated header with a future timestamp can survive later source edits and embed stale UI code. + +**Suggested fix:** + +Use an explicit SCons source/target dependency or store the source hash in the generated header and regenerate whenever it differs. Deterministic gzip output can remain unchanged. + +### 19. Tracker v1.1 reports itself as Tracker V2 + +**Severity:** Medium + +**Locations:** + +- `boards/heltec_tracker_v1_1.json:19,28` +- `variants/heltec_tracker_v2/platformio.ini:61-109` +- `variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp:82-84` + +**Problem:** + +The v1.1 environment reuses the V2 board implementation, whose manufacturer name is hardcoded as `Heltec Tracker V2`. WebConfig and MQTT metadata therefore report the wrong board. + +**Suggested fix:** + +Return `Heltec Tracker V1.1` when `HELTEC_TRACKER_V1_1` is defined, and retain the V2 value otherwise. Add a build-time or host-side assertion for both target identities. + +### 20. WebConfig operation and security behavior are undocumented + +**Severity:** Documentation gap + +**Locations:** + +- Commands added in `src/helpers/CommonCLI_Observer.cpp:982-994` +- `MQTT_IMPLEMENTATION.md` + +**Problem:** + +The build targets are documented, but operators cannot discover `start webconfig`, `start webconfig ap`, `stop webconfig`, first-boot AP behavior, authentication, timeout, or the security implications of setup mode. + +**Suggested fix:** + +Add an operator section to `MQTT_IMPLEMENTATION.md` covering: + +- first-boot setup behavior; +- AP name and setup credential; +- LAN versus AP modes; +- exact CLI commands; +- authentication requirements; +- idle and absolute timeout behavior; +- how Wi-Fi changes are applied; +- how to recover through serial if provisioning fails. + +### 21. Repeater and room-server integration is duplicated + +**Severity:** Optimization + +**Locations:** + +- `examples/simple_repeater/MyMesh.*` +- `examples/simple_room_server/MyMesh.*` +- Corresponding `UITask.cpp` files + +**Problem:** + +Lifecycle, restart coalescing, stats construction, and display behavior are implemented twice. Fixes can easily land in only one role. + +**Suggested fix:** + +Extract shared WebConfig callbacks, lifecycle ownership, and JSON snapshot helpers into a common observer helper. Keep role-specific radio/stat sources as injected callbacks. + +### 22. The HTML generator runs for every ESP32 build + +**Severity:** Optimization + +**Locations:** + +- `platformio.ini:57-72` + +**Problem:** + +The generator runs from `esp32_base` even when WebConfig is not compiled into the target. + +**Suggested fix:** + +Move the pre-script to MQTT observer environments or have the script inspect build flags and return immediately unless `WITH_MQTT_BRIDGE`/WebConfig is enabled. + +### 23. New observer targets enable MQTT debug logging + +**Severity:** Optimization + +**Locations:** + +- `variants/heltec_tracker_v2/platformio.ini:190,226,262,298` + +**Problem:** + +`MQTT_DEBUG=1` increases serial activity and code/logging overhead in targets that otherwise appear intended for deployment. + +**Suggested fix:** + +Remove it from production environments or create explicit debug variants. Confirm that no diagnostic output includes credentials or tokens. + +## Test Recommendations + +No WebConfig-specific automated tests were found. Add focused tests for: + +1. Save request/result correlation, including stale and concurrent batches. +2. Partial command failures and reboot gating. +3. Effective-value handling when fields are intentionally cleared. +4. Secret placeholder/edit semantics. +5. CLI parsing and backend length validation. +6. Runtime application of Wi-Fi and cached MQTT settings. +7. Complete custom-slot reconfiguration. +8. NTP updates without loop-task blocking. +9. Config and MQTT status snapshots under concurrent updates. +10. Repeated server start/stop heap behavior and duplicate headers. +11. Forced AP reachability from both SoftAP and STA networks. +12. Setup AP absolute expiry with an idle associated station. +13. Correct board identity for Tracker v1.1 and V2 targets. + +## Verification Already Performed + +The review ran the following checks successfully: + +- `git diff --check` +- Extracted JavaScript with `node --check` +- PlatformIO builds: + - `heltec_tracker_v1_1_repeater_observer_mqtt` + - `heltec_tracker_v2_repeater_observer_mqtt` + - `heltec_tracker_v1_1_room_server_observer_mqtt` + - `heltec_tracker_v2_room_server_observer_mqtt` + +Observed static usage: + +- Repeater: 76,960 bytes RAM (23.5%), approximately 47.4% flash. +- Room server: 80,656 bytes RAM (24.6%), approximately 47.3% flash. + +Successful compilation does not validate the AP/STA security boundary, async teardown, concurrency, reconnection, or partial-save behavior; those require the targeted host/hardware tests above. + +## Suggested Implementation Order + +1. Fix forced-AP LAN exposure and define secure provisioning authentication. +2. Introduce request IDs for save/result correlation. +3. Gate reboot on aggregate success and represent partial application explicitly. +4. Correct effective empty-value handling in the wizard. +5. Align Wi-Fi, MQTT flags, and custom-slot runtime behavior with the UI. +6. Remove NTP blocking and marshal config/status snapshots to their owning tasks. +7. Fix server lifecycle/global-header accumulation. +8. Add input-length/backend validation and polling safeguards. +9. Fix Tracker v1.1 identity and add operator documentation. +10. Add automated tests, then run the full hardware matrix. diff --git a/boards/heltec_tracker_v1_1.json b/boards/heltec_tracker_v1_1.json new file mode 100644 index 00000000..a9ec5851 --- /dev/null +++ b/boards/heltec_tracker_v1_1.json @@ -0,0 +1,40 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_8MB.csv" + }, + "core": "esp32", + "extra_flags": [ + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=0", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "heltec_tracker_v2" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "Heltec Wireless Tracker v1.1", + "upload": { + "flash_size": "8MB", + "maximum_ram_size": 327680, + "maximum_size": 8388608, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/project/wireless-tracker/", + "vendor": "Heltec" +} diff --git a/diagram.json b/diagram.json new file mode 100644 index 00000000..046f6de8 --- /dev/null +++ b/diagram.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "author": "MeshCore", + "editor": "wokwi", + "parts": [ + { "type": "board-esp32-s3-devkitc-1", "id": "esp", "top": 0, "left": 0, "attrs": {} }, + { "type": "board-ssd1306", "id": "oled", "top": -110, "left": 90, + "attrs": { "i2cAddress": "0x3c" } } + ], + "connections": [ + [ "esp:3V3", "oled:VCC", "red", [] ], + [ "esp:GND.1", "oled:GND", "black", [] ], + [ "esp:17", "oled:SDA", "green", [] ], + [ "esp:18", "oled:SCL", "yellow", [] ], + [ "esp:TX", "$serialMonitor:RX", "", [] ], + [ "esp:RX", "$serialMonitor:TX", "", [] ] + ] +} diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 745eff6c..4dccfb46 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -102,8 +102,13 @@ arguments such as node names, passwords, and keys is left unchanged. ### Start or stop an Over-The-Air (OTA) firmware update **Usage:** - `start ota` +- `start ota ap` - `stop ota` +`start ota` serves the web upload page on the station IP when connected to WiFi; +otherwise it raises the `MeshCore-OTA` access point. `start ota ap` always raises +the access point, which is useful when the normal network uses client isolation. + On an ESP32 build with WebConfig, the manual OTA uploader and WebConfig both use HTTP port 80 and cannot run together. Stop WebConfig before `start ota`, or stop OTA before `start webconfig`. @@ -186,6 +191,20 @@ remain available. --- +### Discover neighbor scopes (MQTT observer, PSRAM only) + +Refreshes the zero-hop neighbor table, then queries each neighbor for its region +scopes and publishes the assembled table to the MQTT `neighbors` topic once. + +**Usage:** +- `discover.scopes` + +**Note:** Requires a PSRAM board with the MQTT bridge running. On non-PSRAM MQTT +builds it replies `Err - not supported (requires PSRAM)`. If a `discover.neighbors` +refresh is already in flight, the scope pass is queued behind it. + +--- + ## Statistics ### Clear Stats @@ -2408,6 +2427,39 @@ sleep, this command schedules a sync and wakes it; after `gps off`, it reports --- +#### View or change periodic neighbors publishing (MQTT observer, PSRAM only) +**Usage:** +- `get mqtt.neighbors` +- `set mqtt.neighbors ` + +**Parameters:** +- `on`: periodically discover neighbor scopes and publish the neighbor table to the `neighbors` topic +- `off`: disable periodic neighbors publishing + +**Default:** `off` + +> **Note:** Requires a PSRAM board. On non-PSRAM MQTT builds this replies +> `Err - not supported (requires PSRAM)`. The setting is read live by the mesh +> loop -- no restart required; enabling it triggers a discovery on the next pass. +> While enabled, `get mqtt.status` gains a trailing `nbr: /` field +> (time to next publish, and how the last publish went). + +--- + +#### View or change the neighbors publish interval (MQTT observer, PSRAM only) +**Usage:** +- `get mqtt.neighbors.interval` +- `set mqtt.neighbors.interval ` + +**Parameters:** +- `hours`: how often to publish the neighbor table (12-336, default 24) + +**Default:** `24` (hours) + +> **Note:** Out-of-range values are rejected (not clamped). Requires a PSRAM board. + +--- + #### View or change the NTP server (MQTT observer only) **Usage:** - `get mqtt.ntp` diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index abb43617..a4987aaa 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -87,6 +87,12 @@ #define CLI_REPLY_DELAY_MILLIS 600 +// Max time to flush the outbound queue (START alert + CLI reply) before the OTA +// teardown blocks the loop until reboot. Best-effort: exits early once the queue +// drains (immediate on a healthy node), caps the wait on a jammed/duty-limited +// channel so an update is never stalled indefinitely. +#define OTA_TX_DRAIN_TIMEOUT_MS 5000 + #define LAZY_CONTACTS_WRITE_DELAY 5000 #define FLOOD_CHANNEL_BLOCK_FILE "/flood_ch_block" @@ -2218,7 +2224,22 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m int MyMesh::searchPeersByHash(const uint8_t *hash) { int n = 0; - for (int i = 0; i < acl.getNumClients(); i++) { +#if defined(WITH_MQTT_NEIGHBORS) + // While a neighbor-scope discovery is active, overlay the heard neighbours + // that are NOT already ACL clients so their RESPONSE packets can be decoded. + // Overlay indices are offset by NEIGHBOR_DISCOVER_PEER_BASE to keep them + // distinct from real ACL indices. + if (neighbor_discover_active) { + for (int i = 0; i < neighbor_discover_count && n < MAX_CLIENTS; i++) { + auto& nb = neighbours[neighbor_discover[i].neighbour_idx]; + if (acl.getClient(nb.id.pub_key, PUB_KEY_SIZE) != nullptr) continue; + if (nb.heard_timestamp > 0 && nb.id.isHashMatch(hash)) { + matching_peer_indexes[n++] = NEIGHBOR_DISCOVER_PEER_BASE + i; + } + } + } +#endif + for (int i = 0; i < acl.getNumClients() && n < MAX_CLIENTS; i++) { if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) } @@ -2228,6 +2249,16 @@ int MyMesh::searchPeersByHash(const uint8_t *hash) { void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { int i = matching_peer_indexes[peer_idx]; +#if defined(WITH_MQTT_NEIGHBORS) + // Overlay entries have no precomputed shared secret; derive it on the fly. + if (neighbor_discover_active && i >= NEIGHBOR_DISCOVER_PEER_BASE) { + int oi = i - NEIGHBOR_DISCOVER_PEER_BASE; + if (oi >= 0 && oi < neighbor_discover_count) { + self_id.calcSharedSecret(dest_secret, neighbours[neighbor_discover[oi].neighbour_idx].id); + return; + } + } +#endif if (i >= 0 && i < acl.getNumClients()) { // lookup pre-calculated shared_secret memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); @@ -2279,11 +2310,34 @@ void MyMesh::onGroupPacketRecv(mesh::Packet* packet) { void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { int i = matching_peer_indexes[sender_idx]; +#if defined(WITH_MQTT_NEIGHBORS) + // Overlay response: a heard neighbour (not an ACL client) answering our + // anon-regions scope query. Consume it and stop -- it is not a client packet. + if (neighbor_discover_active && i >= NEIGHBOR_DISCOVER_PEER_BASE) { + int oi = i - NEIGHBOR_DISCOVER_PEER_BASE; + if (type == PAYLOAD_TYPE_RESPONSE && oi >= 0 && oi < neighbor_discover_count) { + handleNeighborDiscoverResponse(oi, data, len); + } + return; + } +#endif if (i < 0 || i >= acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context) MESH_DEBUG_PRINTLN("onPeerDataRecv: invalid peer idx: %d", i); return; } ClientInfo* client = acl.getClientByIdx(i); +#if defined(WITH_MQTT_NEIGHBORS) + // A neighbour that IS an ACL client resolves to a normal index above, so a + // scope-query response from it lands here -- match it against the overlay. + if (neighbor_discover_active && type == PAYLOAD_TYPE_RESPONSE) { + for (int oi = 0; oi < neighbor_discover_count; oi++) { + auto& nb = neighbours[neighbor_discover[oi].neighbour_idx]; + if (client->id.matches(nb.id) && handleNeighborDiscoverResponse(oi, data, len)) { + return; + } + } + } +#endif if (type == PAYLOAD_TYPE_REQ) { // request (from a Known admin client!) uint32_t timestamp; @@ -2683,6 +2737,16 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc pending_discover_tag = 0; pending_discover_until = 0; +#if defined(WITH_MQTT_NEIGHBORS) + neighbor_discover_count = 0; + neighbor_discover_active = false; + neighbor_table_refresh_active = false; + neighbor_table_refresh_periodic = false; + neighbor_discover_until = 0; + next_neighbors_publish = 0; + self_scopes_buf[0] = 0; +#endif + memset(default_scope.key, 0, sizeof(default_scope.key)); } @@ -2694,6 +2758,25 @@ void MyMesh::begin(FILESYSTEM *fs) { // load persisted prefs _cli.loadPrefs(_fs); +#ifdef SIM_WIFI_SSID + // Emulator builds (Wokwi) boot with fresh NVS every run. Seed WiFi so the + // observer auto-joins the simulator's network and brings the MQTT bridge up + // (WiFi is driven by the bridge task), instead of raising the setup AP that + // the emulator can't model. No-op for real firmware (flag never defined). + { + MQTTPrefs* obs = _cli.getObserverPrefs(); + if (obs->wifi_ssid[0] == 0) { + strncpy(obs->wifi_ssid, SIM_WIFI_SSID, sizeof(obs->wifi_ssid) - 1); + obs->wifi_ssid[sizeof(obs->wifi_ssid) - 1] = 0; + #ifdef SIM_WIFI_PWD + strncpy(obs->wifi_password, SIM_WIFI_PWD, sizeof(obs->wifi_password) - 1); + obs->wifi_password[sizeof(obs->wifi_password) - 1] = 0; + #endif + _prefs.bridge_enabled = 1; // WiFi comes up via the MQTT bridge task + } + } +#endif + acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); @@ -7174,6 +7257,36 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * sendNodeDiscoverReq(); strcpy(reply, "OK - Discover sent"); } +#if defined(WITH_MQTT_NEIGHBORS) + } else if (memcmp(command, "discover.scopes", 15) == 0) { + const char* sub = command + 15; + while (*sub == ' ') sub++; + if (*sub != 0) { + strcpy(reply, "Err - discover.scopes has no options"); + } else if (pending_discover_tag != 0 && + !millisHasNowPassed(pending_discover_until) && + !neighbor_discover_active) { + // A zero-hop table refresh is already collecting; queue the scope pass + // behind it (as a manual, non-periodic request) rather than starting a + // second refresh. + if (!neighborDiscoverReady(reply)) { + // reply already set by neighborDiscoverReady + } else { + neighbor_table_refresh_active = true; + neighbor_table_refresh_periodic = false; + long remaining_ms = (long)(pending_discover_until - futureMillis(0)); + unsigned remaining_secs = remaining_ms > 0 + ? (unsigned)(((unsigned long)remaining_ms + 999UL) / 1000UL) : 0; + sprintf(reply, "OK - scopes queued (%us discovery remaining)", remaining_secs); + MESH_DEBUG_PRINTLN("Neighbor scopes queued behind active discovery (%us remaining)", remaining_secs); + } + } else if (!startNeighborDiscover(reply)) { + // reply already set by startNeighborDiscover + } +#elif defined(WITH_MQTT_BRIDGE) + } else if (memcmp(command, "discover.scopes", 15) == 0) { + strcpy(reply, "Err - not supported (requires PSRAM)"); +#endif } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } @@ -7232,12 +7345,30 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { // (so this never returns); on any abort (already up to date, partition change, // download error) it returns and we resume the bridge. Serial.println("OTA: starting update"); + // Flush the START alert (and CLI reply) out the radio BEFORE teardown blocks + // the loop until reboot - otherwise a packet still queued here (busy / + // duty-limited channel) is lost when the flash spins the loop and reboots. + drainOutbound(OTA_TX_DRAIN_TIMEOUT_MS); setBridgeState(false); char ota_reply[160]; - if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) { + // OTA teardown barrier (Phase 5): only flash after a CLEAN MQTT shutdown. + // A timed-out/forced stop leaves mbedTLS/heap ownership uncertain - writing + // firmware then is the observed teardown heap-panic path - so abort and + // resume the bridge instead of flashing under uncertain ownership. + if (mqtt_bridge && !mqtt_bridge->canFlashAfterStop()) { + Serial.println("OTA: aborted, MQTT stop did not complete cleanly - resuming bridge"); + otaAlert("OTA aborted: MQTT stop unclean, bridge resumed"); + setBridgeState(true); + } else if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) { Serial.print("OTA: aborted, resuming bridge - "); Serial.println(ota_reply); + char ota_alert_msg[160]; + snprintf(ota_alert_msg, sizeof(ota_alert_msg), "OTA aborted: %s", ota_reply); + otaAlert(ota_alert_msg); setBridgeState(true); } + // Success path: otaFromManifest() flashes and reboots into the new image + // (never returns), so there is no in-boot "success" alert - the START alert + // plus the node returning on the new version is the success signal. } #endif @@ -7275,6 +7406,66 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { _alerter.onLoop(now); #endif +#if defined(WITH_MQTT_NEIGHBORS) + // Two-stage periodic neighbors publication: + // stage 1 - zero-hop node-discover refreshes the neighbour table (60s window) + // stage 2 - anon-regions scope query per neighbour (startNeighborDiscover) + // then the table JSON is published and the next cycle is rescheduled. + bool periodic_neighbors_enabled = _cli.getObserverPrefs()->mqtt_neighbors_enabled; + if (neighbor_discover_active) { + loopNeighborDiscover(); + } else if (neighbor_table_refresh_active) { + if (neighbor_table_refresh_periodic && !periodic_neighbors_enabled) { + // periodic switched off mid-refresh -> cancel (leave pending_discover_tag alone) + neighbor_table_refresh_active = false; + neighbor_table_refresh_periodic = false; + next_neighbors_publish = 0; + } else if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) { + // 60s zero-hop window done -> begin the per-neighbour scope queries + bool was_periodic = neighbor_table_refresh_periodic; + pending_discover_tag = 0; + neighbor_table_refresh_active = false; + neighbor_table_refresh_periodic = false; + char tmp_reply[80]; + const char* origin_str = was_periodic ? "periodic" : "manual"; + if (startNeighborDiscover(tmp_reply)) { + MESH_DEBUG_PRINTLN("MQTT %s %s", origin_str, tmp_reply); + } else { + if (periodic_neighbors_enabled) { + next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval); + } + MESH_DEBUG_PRINTLN("MQTT %s neighbor scope discovery failed: %s", origin_str, tmp_reply); + } + } + } else if (periodic_neighbors_enabled && mqtt_bridge && mqtt_bridge->isRunning()) { + if (next_neighbors_publish == 0 || + (next_neighbors_publish != 0 && millisHasNowPassed(next_neighbors_publish))) { + if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) { + pending_discover_tag = 0; + sendNodeDiscoverReq(); + MESH_DEBUG_PRINTLN("MQTT periodic neighbor table refresh started"); + } else { + MESH_DEBUG_PRINTLN("MQTT periodic refresh joined active neighbor discovery"); + } + neighbor_table_refresh_active = true; + neighbor_table_refresh_periodic = true; + } + } + + // Report the schedule state back to the bridge for `get mqtt.status`. + if (mqtt_bridge) { + if (neighbor_discover_active || neighbor_table_refresh_active) { + mqtt_bridge->setNeighborsSchedule(MQTTBridge::NBR_ACTIVE, 0); + } else if (next_neighbors_publish == 0 || millisHasNowPassed(next_neighbors_publish)) { + mqtt_bridge->setNeighborsSchedule(MQTTBridge::NBR_DUE, 0); + } else { + long remaining_ms = (long)(next_neighbors_publish - futureMillis(0)); + uint32_t remaining_secs = remaining_ms > 0 ? (uint32_t)(remaining_ms / 1000) : 0; + mqtt_bridge->setNeighborsSchedule(MQTTBridge::NBR_SCHEDULED, remaining_secs); + } + } +#endif + #ifdef WITH_SNMP // Push radio stats to SNMP agent every 2 seconds if (_snmp_agent.isRunning()) { @@ -7295,6 +7486,258 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { #endif } +#if defined(WITH_MQTT_NEIGHBORS) +#include "helpers/MQTTMessageBuilder.h" +#if defined(ESP_PLATFORM) +#include +#endif + +// This node's own non-flood scope names, same source the anon-regions server +// reply uses. Empty string when the node has no scoped regions. +void MyMesh::getLocalScopes(char* buf, size_t len) { + if (!buf || len == 0) return; + buf[0] = 0; + region_map.exportNamesTo(buf, (int)len, REGION_DENY_FLOOD); +} + +// Client side of the anon-regions request (the server side is handleAnonRegionsReq). +// Inner payload: {tag(4)}{ANON_REQ_TYPE_REGIONS}{0x00 = zero-hop reply path}. +bool MyMesh::sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag) { + uint8_t secret[PUB_KEY_SIZE]; + self_id.calcSharedSecret(secret, target); + + tag = getRTCClock()->getCurrentTimeUnique(); + uint8_t inner[6]; + memcpy(inner, &tag, 4); + inner[4] = ANON_REQ_TYPE_REGIONS; + inner[5] = 0x00; // request a zero-hop reply path + + mesh::Packet* pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, target, secret, inner, sizeof(inner)); + if (!pkt) return false; + sendDirect(pkt, NULL, 0, 0); + return true; +} + +// Match a RESPONSE against the pending overlay entry by tag; copy its scope +// string (payload after the 8-byte {tag}{clock} header) into the entry. +bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len) { + if (overlay_idx < 0 || overlay_idx >= neighbor_discover_count) return false; + NeighborDiscoverEntry& entry = neighbor_discover[overlay_idx]; + if (entry.status != ND_PENDING || len < 8) return false; + + uint32_t tag; + memcpy(&tag, data, 4); + if (tag != entry.tag) return false; + + size_t scope_len = len - 8; + if (scope_len >= sizeof(entry.scopes)) { + scope_len = sizeof(entry.scopes) - 1; + } + memcpy(entry.scopes, &data[8], scope_len); + entry.scopes[scope_len] = 0; + entry.status = ND_RESPONDED; + return true; +} + +// Publish-ordering: most recently heard first, then stronger SNR, then pubkey. +// The JSON builder drops the tail if the buffer fills, so the head must be the +// most useful entries. +static bool neighborPublishEntryComesBefore( + const MQTTMessageBuilder::NeighborsMessageEntry& lhs, + const MQTTMessageBuilder::NeighborsMessageEntry& rhs) { + if (lhs.heard_secs_ago != rhs.heard_secs_ago) { + return lhs.heard_secs_ago < rhs.heard_secs_ago; // newer first + } + if (lhs.snr != rhs.snr) { + return lhs.snr > rhs.snr; // stronger first when equally recent + } + return strcmp(lhs.pubkey_hex, rhs.pubkey_hex) < 0; +} + +#if defined(ESP_PLATFORM) +// ArduinoJson v7 JsonDocument has no real capacity cap (DynamicJsonDocument(N) +// is a no-op shim). Keep the pool off internal DRAM and soft-cap peak growth to +// the publish buffer size. used only rises on allocate -- conservative for this +// single-shot doc (overflow path removes+breaks, so no further growth after free). +struct NeighborsDocAllocator : ArduinoJson::Allocator { + size_t used = 0; + static const size_t kBudget = MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE; + + void* allocate(size_t size) override { + if (used >= kBudget || size > kBudget - used) return nullptr; + void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (p) used += size; + return p; + } + + void deallocate(void* ptr) override { + heap_caps_free(ptr); + } + + void* reallocate(void* ptr, size_t new_size) override { + size_t old_size = ptr ? heap_caps_get_allocated_size(ptr) : 0; + size_t next_used = (used >= old_size) ? (used - old_size) : 0; + if (next_used >= kBudget || new_size > kBudget - next_used) return nullptr; + void* p = heap_caps_realloc(ptr, new_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (p) used = next_used + new_size; + return p; + } +}; +#endif + +// Build the neighbors-table JSON and hand it to the bridge, then reschedule. +void MyMesh::finishNeighborDiscover() { + getLocalScopes(self_scopes_buf, sizeof(self_scopes_buf)); + + char self_pubkey_hex[65]; + mesh::Utils::toHex(self_pubkey_hex, self_id.pub_key, PUB_KEY_SIZE); + + char origin[32]; + MQTTBridge::getEffectiveMqttOrigin(_prefs.node_name, _cli.getObserverPrefs(), origin, sizeof(origin)); + + char timestamp[40]; + MQTTMessageBuilder::formatIsoTimestampForMqtt(getRTCClock()->getCurrentTime(), 0, nullptr, timestamp, sizeof(timestamp)); + + char pubkey_hex[MAX_NEIGHBOURS][65]; + MQTTMessageBuilder::NeighborsMessageEntry entries[MAX_NEIGHBOURS]; + uint32_t now_secs = getRTCClock()->getCurrentTime(); + + for (int i = 0; i < neighbor_discover_count; i++) { + auto& nb = neighbours[neighbor_discover[i].neighbour_idx]; + mesh::Utils::toHex(pubkey_hex[i], nb.id.pub_key, PUB_KEY_SIZE); + entries[i].pubkey_hex = pubkey_hex[i]; + entries[i].snr = nb.snr / 4.0f; + entries[i].heard_secs_ago = (nb.heard_timestamp > 0 && now_secs >= nb.heard_timestamp) + ? (now_secs - nb.heard_timestamp) : 0; + entries[i].scopes = neighbor_discover[i].scopes; + switch (neighbor_discover[i].status) { + case ND_RESPONDED: entries[i].status = "responded"; break; + case ND_SEND_FAILED: entries[i].status = "send_failed"; break; + default: entries[i].status = "timeout"; break; + } + } + + // insertion sort: most useful first (JSON builder drops the tail on overflow) + for (int i = 1; i < neighbor_discover_count; i++) { + MQTTMessageBuilder::NeighborsMessageEntry entry = entries[i]; + int j = i; + while (j > 0 && neighborPublishEntryComesBefore(entry, entries[j - 1])) { + entries[j] = entries[j - 1]; + j--; + } + entries[j] = entry; + } + +#if defined(ESP_PLATFORM) + char* json_buf = (char*)heap_caps_malloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE, MALLOC_CAP_SPIRAM); +#else + char* json_buf = (char*)malloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE); +#endif + if (!json_buf) { + neighbor_discover_active = false; + neighbor_discover_count = 0; + if (_cli.getObserverPrefs()->mqtt_neighbors_enabled) { + next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval); + } + return; + } + +#if defined(ESP_PLATFORM) + NeighborsDocAllocator doc_alloc; + JsonDocument doc(&doc_alloc); +#else + JsonDocument doc; +#endif + int json_len = MQTTMessageBuilder::buildNeighborsMessage( + doc, origin, self_pubkey_hex, timestamp, self_scopes_buf, + entries, neighbor_discover_count, + json_buf, MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE); + + if (json_len > 0 && mqtt_bridge) { + mqtt_bridge->requestPublishNeighbors(json_buf, (size_t)json_len); + } + +#if defined(ESP_PLATFORM) + heap_caps_free(json_buf); +#else + free(json_buf); +#endif + + neighbor_discover_active = false; + neighbor_discover_count = 0; + if (_cli.getObserverPrefs()->mqtt_neighbors_enabled) { + next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval); + } +} + +// Advance the scope-query phase; publish once all entries resolve or the window +// times out (stragglers marked ND_TIMEOUT). +void MyMesh::loopNeighborDiscover() { + if (!neighbor_discover_active) return; + + bool all_done = true; + for (int i = 0; i < neighbor_discover_count; i++) { + if (neighbor_discover[i].status == ND_PENDING) { all_done = false; break; } + } + if (!all_done && !millisHasNowPassed(neighbor_discover_until)) return; + if (!all_done) { + for (int i = 0; i < neighbor_discover_count; i++) { + if (neighbor_discover[i].status == ND_PENDING) neighbor_discover[i].status = ND_TIMEOUT; + } + } + finishNeighborDiscover(); +} + +// Shared precondition for starting a discovery: PSRAM present + bridge running. +bool MyMesh::neighborDiscoverReady(char* reply) { +#if defined(ESP_PLATFORM) + if (!psramFound()) { strcpy(reply, "Err - PSRAM not available"); return false; } +#endif + if (!mqtt_bridge || !mqtt_bridge->isRunning()) { strcpy(reply, "Err - MQTT bridge not running"); return false; } + return true; +} + +// Snapshot the neighbor table into the overlay and fire one anon-regions query +// per heard neighbour; arm the 30s scope-query window. +bool MyMesh::startNeighborDiscover(char* reply) { + if (neighbor_discover_active) { + strcpy(reply, "Err - neighbor discover already active"); + return false; + } + if (!neighborDiscoverReady(reply)) { + return false; // reply already set + } + + getLocalScopes(self_scopes_buf, sizeof(self_scopes_buf)); + neighbor_discover_count = 0; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0) { + neighbor_discover[neighbor_discover_count].neighbour_idx = (uint8_t)i; + neighbor_discover[neighbor_discover_count].scopes[0] = 0; + neighbor_discover[neighbor_discover_count].status = ND_PENDING; + uint32_t tag; + if (sendAnonRegionsReq(neighbours[i].id, tag)) { + neighbor_discover[neighbor_discover_count].tag = tag; + } else { + neighbor_discover[neighbor_discover_count].status = ND_SEND_FAILED; + } + neighbor_discover_count++; + } + } + + neighbor_discover_active = true; + neighbor_discover_until = futureMillis(NEIGHBOR_DISCOVER_TIMEOUT_MS); + + if (neighbor_discover_count == 0) { + finishNeighborDiscover(); + strcpy(reply, "OK - neighbor discover started (0 neighbors, self only)"); + } else { + sprintf(reply, "OK - neighbor discover started (%u neighbors)", (unsigned)neighbor_discover_count); + } + return true; +} +#endif // WITH_MQTT_NEIGHBORS + // To check if there is pending work bool MyMesh::hasPendingWork() const { if (deferred_cli_command.pending || pending_self_advert) return true; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 70a0ff06..ca7515f2 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -46,6 +46,7 @@ #ifdef WITH_MQTT_BRIDGE #include "helpers/bridges/MQTTBridge.h" #define WITH_BRIDGE +#include "helpers/esp32/WebConfigServer.h" // defines WITH_WEBCONFIG on ESP32 #endif #if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) @@ -376,6 +377,44 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint8_t _wc_slot_restart_mask = 0; #endif +#if defined(WITH_MQTT_NEIGHBORS) + // Neighbor-scope discovery: a snapshot of the neighbor table overlaid with an + // in-flight anon-regions query per neighbor, published to the MQTT neighbors + // topic once every neighbor has responded or the window times out. + enum NeighborDiscoverStatus : uint8_t { + ND_PENDING = 1, + ND_RESPONDED = 2, + ND_TIMEOUT = 3, + ND_SEND_FAILED = 4, + }; + struct NeighborDiscoverEntry { + uint8_t neighbour_idx; // index into neighbours[] + uint32_t tag; // anon-regions request tag we're waiting on + char scopes[96]; // scope names from the response + uint8_t status; // NeighborDiscoverStatus + }; + NeighborDiscoverEntry neighbor_discover[MAX_NEIGHBOURS]; + uint8_t neighbor_discover_count; + bool neighbor_discover_active; // scope-query phase in flight + bool neighbor_table_refresh_active; // zero-hop table refresh (stage 1) in flight + bool neighbor_table_refresh_periodic; // that refresh was kicked by the periodic timer + unsigned long neighbor_discover_until; // scope-query timeout deadline + unsigned long next_neighbors_publish; // periodic publish deadline (0 = fire ASAP) + char self_scopes_buf[96]; + + bool sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag); + bool neighborDiscoverReady(char* reply); + bool startNeighborDiscover(char* reply); + void loopNeighborDiscover(); + void finishNeighborDiscover(); + bool handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len); + void getLocalScopes(char* buf, size_t len); + // Overlay peer indices are offset by this base so onPeerDataRecv can tell a + // discovery response apart from a normal ACL-client index. + static const int NEIGHBOR_DISCOVER_PEER_BASE = 1000; + static const unsigned long NEIGHBOR_DISCOVER_TIMEOUT_MS = 30000; +#endif + bool extractDirectRetryPrefix(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const; int8_t getDirectRetryMinSNRX4() const; uint8_t getDirectRetryCodingRateForSNR(int8_t snr_x4) const; @@ -554,9 +593,6 @@ protected: int getInterferenceThreshold() const override { return _prefs.interference_threshold; } - bool getCADEnabled() const override { - return _prefs.cad_enabled; - } int getAGCResetInterval() const override { return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds } @@ -720,7 +756,7 @@ public: AbstractBridge* active_bridge = activeBridge(); if (!active_bridge || !active_bridge->isRunning()) return; #ifdef WITH_WEBCONFIG - if (_wc_batch_active) { + if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd() _wc_restart_pending = true; return; } @@ -755,12 +791,52 @@ public: #endif } +#if defined(WITH_MQTT_BRIDGE) + // Broadcast a key OTA milestone (start/fail only) on the configured alert + // channel, in addition to the Serial log -- so an operator who triggered + // `ota update` via remote management still gets feedback that lands well after + // the command's reply window. Respects the `alert on/off` master switch and + // rides the configured alert scope (sendChannel -> resolveAlertScope); a no-op + // when alerts are off or no channel is set. Deliberately NOT wired to routine + // slot connect/disconnect -- those remain in AlertReporter's fault logic. + void otaAlert(const char* msg) { + auto* obs = _cli.getObserverPrefs(); + if (obs && obs->alert_enabled) _alerter.sendText(msg); + } + + // Best-effort flush of the outbound packet queue before an OTA teardown that + // blocks the loop until reboot. The START alert (otaAlert) and the CLI reply + // are queued fire-and-forget (delay 0 / CLI_REPLY_DELAY_MILLIS); once + // setBridgeState(false) + otaFromManifest() run they spin the loop task until + // the chip reboots, so anything still in the send queue at that point is + // silently lost -- the observed "OTA update starting never arrives" case on a + // busy / duty-limited channel where the packet can't win a TX slot inside the + // 2.5 s window. Pump the mesh loop so already-queued packets get their airtime, + // bounded by timeout_ms so a jammed or budget-exhausted channel can't stall the + // update. Respects duty cycle / CAD: it only drains what is queued, it does not + // force a transmit. Returns instantly on a healthy node (queue already empty). + void drainOutbound(uint32_t timeout_ms) { + unsigned long start = millis(); + while (hasOutbound() || _mgr->getOutboundCount(millis()) > 0) { + if (millis() - start >= timeout_ms) break; + mesh::Mesh::loop(); // base dispatcher only -- drives RX + checkSend()/TX + delay(1); // yield to the radio ISR / other FreeRTOS tasks + } + } +#endif + // Schedule the pull-OTA flash to run from loop() in ~2.5 s, leaving time for the // "Beginning update..." CLI reply (CLI_REPLY_DELAY_MILLIS = 600 ms) to transmit // before the flash blocks the loop and reboots. bool beginDeferredOtaUpdate() override { _ota_update_at = millis() + 2500; if (_ota_update_at == 0) _ota_update_at = 1; // 0 means "none" +#if defined(WITH_MQTT_BRIDGE) + // Broadcast START now, while the loop still runs (the 2.5 s reply window): + // the deferred flash blocks the loop and, on success, reboots -- so a start + // alert queued at fire time could never transmit. See otaAlert(). + otaAlert("OTA update starting"); +#endif return true; } @@ -784,7 +860,7 @@ public: #ifdef WITH_MQTT_BRIDGE if (!mqtt_bridge || !mqtt_bridge->isRunning()) return false; // Marshal onto the MQTT task (Core 0); this runs on the CLI thread (Core 1). - return mqtt_bridge->requestForcedNtpSync(); + return mqtt_bridge->requestForcedNtpSync(0); #else return false; #endif diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 8e79c784..b2d7f039 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -191,6 +191,15 @@ void UITask::loop() { } #endif +#ifdef WITH_WEBCONFIG + // While the setup portal is up there's no user button to wake the screen + // reliably - keep it on so the join instructions stay visible. + if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) { + if (!_display->isOn()) _display->turnOn(); + _auto_off = millis() + AUTO_OFF_MILLIS; + } +#endif + if (_display->isOn()) { if (millis() >= _next_refresh) { _display->startFrame(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 571799e2..5bd7adbe 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,5 +1,6 @@ #include "MyMesh.h" #include +#include #include #ifdef WITH_WEBCONFIG #include @@ -12,6 +13,8 @@ static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { return delay_ms > 30000UL ? 30000UL : delay_ms; } +#define ANON_REQ_TYPE_REGIONS 0x01 // client side of the anon-regions scope query (neighbors feature) + #define REPLY_DELAY_MILLIS 1500 #define PUSH_NOTIFY_DELAY_MILLIS 2000 #define SYNC_PUSH_INTERVAL 1200 @@ -31,6 +34,10 @@ static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { #define RESP_SERVER_LOGIN_OK 0 // response to ANON_REQ +// Best-effort bound for the queued CLI reply before OTA blocks the loop and +// reboots. Do not let a busy or duty-limited channel stall the update forever. +#define OTA_TX_DRAIN_TIMEOUT_MS 5000 + #define LAZY_CONTACTS_WRITE_DELAY 5000 struct ServerStats { @@ -499,7 +506,21 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m int MyMesh::searchPeersByHash(const uint8_t *hash) { int n = 0; - for (int i = 0; i < acl.getNumClients(); i++) { +#if defined(WITH_MQTT_NEIGHBORS) + if (neighbor_discover_active) { + for (int i = 0; i < neighbor_discover_count && n < MAX_CLIENTS; i++) { + auto& nb = neighbours[neighbor_discover[i].neighbour_idx]; + // ACL clients already have a matching peer entry and shared secret. Adding + // a second overlay entry would decrypt first and intercept their normal + // CLI/request traffic for the duration of discovery. + if (acl.getClient(nb.id.pub_key, PUB_KEY_SIZE) != nullptr) continue; + if (nb.heard_timestamp > 0 && nb.id.isHashMatch(hash)) { + matching_peer_indexes[n++] = NEIGHBOR_DISCOVER_PEER_BASE + i; + } + } + } +#endif + for (int i = 0; i < acl.getNumClients() && n < MAX_CLIENTS; i++) { if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) } @@ -509,6 +530,15 @@ int MyMesh::searchPeersByHash(const uint8_t *hash) { void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { int i = matching_peer_indexes[peer_idx]; +#if defined(WITH_MQTT_NEIGHBORS) + if (neighbor_discover_active && i >= NEIGHBOR_DISCOVER_PEER_BASE) { + int oi = i - NEIGHBOR_DISCOVER_PEER_BASE; + if (oi >= 0 && oi < neighbor_discover_count) { + self_id.calcSharedSecret(dest_secret, neighbours[neighbor_discover[oi].neighbour_idx].id); + return; + } + } +#endif if (i >= 0 && i < acl.getNumClients()) { // lookup pre-calculated shared_secret memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); @@ -520,11 +550,34 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { int i = matching_peer_indexes[sender_idx]; +#if defined(WITH_MQTT_NEIGHBORS) + // Overlay response: a heard neighbour (not an ACL client) answering our + // anon-regions scope query. Consume it and stop -- it is not a client packet. + if (neighbor_discover_active && i >= NEIGHBOR_DISCOVER_PEER_BASE) { + int oi = i - NEIGHBOR_DISCOVER_PEER_BASE; + if (type == PAYLOAD_TYPE_RESPONSE && oi >= 0 && oi < neighbor_discover_count) { + handleNeighborDiscoverResponse(oi, data, len); + } + return; + } +#endif if (i < 0 || i >= acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context) MESH_DEBUG_PRINTLN("onPeerDataRecv: invalid peer idx: %d", i); return; } auto client = acl.getClientByIdx(i); +#if defined(WITH_MQTT_NEIGHBORS) + // A neighbour that IS an ACL client resolves to a normal index above, so a + // scope-query response from it lands here -- match it against the overlay. + if (neighbor_discover_active && type == PAYLOAD_TYPE_RESPONSE) { + for (int oi = 0; oi < neighbor_discover_count; oi++) { + auto& nb = neighbours[neighbor_discover[oi].neighbour_idx]; + if (client->id.matches(nb.id) && handleNeighborDiscoverResponse(oi, data, len)) { + return; + } + } + } +#endif if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { // a CLI command or new Post uint32_t sender_timestamp; memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) @@ -714,6 +767,107 @@ void MyMesh::onAckRecv(mesh::Packet *packet, uint32_t ack_crc) { } } +#if defined(WITH_MQTT_NEIGHBORS) + +#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 +#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 + +void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { + // find existing neighbour, else use least recently updated + uint32_t oldest_timestamp = 0xFFFFFFFF; + NeighbourInfo *neighbour = &neighbours[0]; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + // if neighbour already known, we should update it + if (id.matches(neighbours[i].id)) { + neighbour = &neighbours[i]; + break; + } + + // otherwise we should update the least recently updated neighbour + if (neighbours[i].heard_timestamp < oldest_timestamp) { + neighbour = &neighbours[i]; + oldest_timestamp = neighbour->heard_timestamp; + } + } + + // update neighbour info + neighbour->id = id; + neighbour->advert_timestamp = timestamp; + neighbour->heard_timestamp = getRTCClock()->getCurrentTime(); + neighbour->snr = (int8_t)(snr * 4); +} + +static bool isShare(const mesh::Packet *packet) { + if (packet->hasTransportCodes()) { + return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere' + } + return false; +} + +void MyMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32_t timestamp, + const uint8_t *app_data, size_t app_data_len) { + mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); // chain to super impl + + // if this a zero hop advert (and not via 'Share'), add it to neighbours + if (packet->getPathHashCount() == 0 && !isShare(packet)) { + AdvertDataParser parser(app_data, app_data_len); + if (parser.isValid() && parser.getType() == ADV_TYPE_REPEATER) { // just keep neigbouring Repeaters + putNeighbour(id, timestamp, packet->getSNR()); + } + } +} + +void MyMesh::onControlDataRecv(mesh::Packet* packet) { + uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits + // A room server is ADV_TYPE_ROOM, so it does NOT answer node-discover requests + // (those filter for repeaters). It only records repeater responses to its own + // discovery, to build the neighbour table. + if (type == CTL_TYPE_NODE_DISCOVER_RESP && packet->payload_len >= 6) { + uint8_t node_type = packet->payload[0] & 0x0F; + if (node_type != ADV_TYPE_REPEATER) { + return; + } + if (packet->payload_len < 6 + PUB_KEY_SIZE) { + MESH_DEBUG_PRINTLN("onControlDataRecv: DISCOVER_RESP pubkey too short: %d", (uint32_t)packet->payload_len); + return; + } + + if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) { + pending_discover_tag = 0; + return; + } + uint32_t tag; + memcpy(&tag, &packet->payload[2], 4); + if (tag != pending_discover_tag) { + return; + } + + mesh::Identity id(&packet->payload[6]); + if (id.matches(self_id)) { + return; + } + putNeighbour(id, getRTCClock()->getCurrentTime(), packet->getSNR()); + } +} + +void MyMesh::sendNodeDiscoverReq() { + uint8_t data[10]; + data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // prefix_only=0 + data[1] = (1 << ADV_TYPE_REPEATER); + getRNG()->random(&data[2], 4); // tag + memcpy(&pending_discover_tag, &data[2], 4); + pending_discover_until = futureMillis(60000); + uint32_t since = 0; + memcpy(&data[6], &since, 4); + + auto pkt = createControlData(data, sizeof(data)); + if (pkt) { + sendZeroHop(pkt); + } +} + +#endif // WITH_MQTT_NEIGHBORS + MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng, mesh::RTCClock &rtc, mesh::MeshTables &tables) : mesh::Mesh(radio, ms, rng, rtc, *createObserverPacketManager(32), tables), @@ -802,6 +956,19 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc memset(posts, 0, sizeof(posts)); _num_posted = _num_post_pushes = 0; +#if defined(WITH_MQTT_NEIGHBORS) + pending_discover_tag = 0; + pending_discover_until = 0; + neighbor_discover_count = 0; + neighbor_discover_active = false; + neighbor_table_refresh_active = false; + neighbor_table_refresh_periodic = false; + neighbor_discover_until = 0; + next_neighbors_publish = 0; + self_scopes_buf[0] = 0; + memset(neighbours, 0, sizeof(neighbours)); +#endif + memset(default_scope.key, 0, sizeof(default_scope.key)); } @@ -1301,6 +1468,65 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { } #endif +void MyMesh::formatNeighborsReply(char *reply) { +#if defined(WITH_MQTT_NEIGHBORS) + char *dp = reply; + + // create copy of neighbours list, skipping empty entries so we can sort it separately from main list + int16_t neighbours_count = 0; + NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS]; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + auto neighbour = &neighbours[i]; + if (neighbour->heard_timestamp > 0) { + sorted_neighbours[neighbours_count] = neighbour; + neighbours_count++; + } + } + + // sort neighbours newest to oldest + std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->heard_timestamp > b->heard_timestamp; // desc + }); + + for (int i = 0; i < neighbours_count && dp - reply < 134; i++) { + NeighbourInfo *neighbour = sorted_neighbours[i]; + + // add new line if not first item + if (i > 0) *dp++ = '\n'; + + char hex[10]; + // get 4 bytes of neighbour id as hex + mesh::Utils::toHex(hex, neighbour->id.pub_key, 4); + + // add next neighbour + uint32_t secs_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp; + sprintf(dp, "%s:%d:%d", hex, secs_ago, neighbour->snr); + while (*dp) + dp++; // find end of string + } + if (dp == reply) { // no neighbours, need empty response + strcpy(dp, "-none-"); + dp += 6; + } + *dp = 0; // null terminator +#else + strcpy(reply, "not supported"); +#endif +} + +void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) { +#if defined(WITH_MQTT_NEIGHBORS) + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + NeighbourInfo *neighbour = &neighbours[i]; + if (memcmp(neighbour->id.pub_key, pubkey, key_len) == 0) { + neighbours[i] = NeighbourInfo(); // clear neighbour entry + } + } +#else + (void)pubkey; (void)key_len; +#endif +} + void MyMesh::formatStatsReply(char *reply) { StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); } @@ -1309,11 +1535,16 @@ void MyMesh::formatRadioStatsReply(char *reply) { StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); } +void MyMesh::formatRadioDiagReply(char *reply) { + StatsFormatHelper::formatRadioDiag(reply, _radio, radio_driver, *_ms, _err_flags, hasOutbound()); +} + void MyMesh::formatPacketStatsReply(char *reply) { StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); } + void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation @@ -1404,6 +1635,45 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply Serial.printf("\n"); } reply[0] = 0; +#if defined(WITH_MQTT_NEIGHBORS) + } else if (memcmp(command, "discover.neighbors", 18) == 0) { + const char* sub = command + 18; + while (*sub == ' ') sub++; + if (*sub != 0) { + strcpy(reply, "Err - discover.neighbors has no options"); + } else { + sendNodeDiscoverReq(); + strcpy(reply, "OK - Discover sent"); + } + } else if (memcmp(command, "discover.scopes", 15) == 0) { + const char* sub = command + 15; + while (*sub == ' ') sub++; + if (*sub != 0) { + strcpy(reply, "Err - discover.scopes has no options"); + } else if (pending_discover_tag != 0 && + !millisHasNowPassed(pending_discover_until) && + !neighbor_discover_active) { + // A zero-hop table refresh is already collecting; queue the scope pass + // behind it (as a manual, non-periodic request) rather than starting a + // second refresh. + if (!neighborDiscoverReady(reply)) { + // reply already set by neighborDiscoverReady + } else { + neighbor_table_refresh_active = true; + neighbor_table_refresh_periodic = false; + long remaining_ms = (long)(pending_discover_until - futureMillis(0)); + unsigned remaining_secs = remaining_ms > 0 + ? (unsigned)(((unsigned long)remaining_ms + 999UL) / 1000UL) : 0; + sprintf(reply, "OK - scopes queued (%us discovery remaining)", remaining_secs); + MESH_DEBUG_PRINTLN("Neighbor scopes queued behind active discovery (%us remaining)", remaining_secs); + } + } else if (!startNeighborDiscover(reply)) { + // reply already set by startNeighborDiscover + } +#elif defined(WITH_MQTT_BRIDGE) + } else if (memcmp(command, "discover.scopes", 15) == 0) { + strcpy(reply, "Err - not supported (requires PSRAM)"); +#endif } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } @@ -1559,6 +1829,44 @@ void MyMesh::loop() { } #endif +#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE) + if (_ota_update_at && millisHasNowPassed(_ota_update_at)) { // deferred `ota update` + _ota_update_at = 0; // clear timer + // The "Beginning update..." reply has now been queued. Flush it before OTA + // blocks the loop until reboot, then free a running bridge for heap headroom. + // Remember its state: an OTA request must not enable MQTT that an operator + // had deliberately stopped. + Serial.println("OTA: starting update"); + const bool bridge_was_running = bridge && bridge->isRunning(); + drainOutbound(OTA_TX_DRAIN_TIMEOUT_MS); + + bool may_flash = true; + if (bridge_was_running) { + setBridgeState(false); + // OTA must not write after a forced/timed-out MQTT shutdown: its TLS/heap + // ownership is uncertain until a subsequent clean start/stop cycle. + may_flash = bridge && bridge->canFlashAfterStop(); + if (!may_flash) { + Serial.println("OTA: aborted, MQTT stop did not complete cleanly"); + } + } + + char ota_reply[160]; + if (may_flash && !_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) { + Serial.print("OTA: aborted - "); Serial.println(ota_reply); + may_flash = false; + } + + // Successful otaFromManifest() reboots and never returns. Restore only a + // bridge that was running before this attempt; leave an intentionally + // stopped bridge stopped after any OTA refusal or download failure. + if (!may_flash && bridge_was_running) { + Serial.println("OTA: resuming bridge"); + setBridgeState(true); + } + } +#endif + // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { acl.save(_fs, MyMesh::saveFilter); @@ -1575,6 +1883,85 @@ void MyMesh::loop() { #ifdef WITH_MQTT_BRIDGE _alerter.onLoop(now); #endif + +#if defined(WITH_MQTT_NEIGHBORS) + // Two-stage periodic neighbors publication: + // stage 1 - zero-hop node-discover refreshes the neighbour table (60s window) + // stage 2 - anon-regions scope query per neighbour (startNeighborDiscover) + // then the table JSON is published and the next cycle is rescheduled. + bool periodic_neighbors_enabled = _cli.getObserverPrefs()->mqtt_neighbors_enabled; + if (neighbor_discover_active) { + loopNeighborDiscover(); + } else if (neighbor_table_refresh_active) { + if (neighbor_table_refresh_periodic && !periodic_neighbors_enabled) { + // periodic switched off mid-refresh -> cancel (leave pending_discover_tag alone) + neighbor_table_refresh_active = false; + neighbor_table_refresh_periodic = false; + next_neighbors_publish = 0; + } else if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) { + // 60s zero-hop window done -> begin the per-neighbour scope queries + bool was_periodic = neighbor_table_refresh_periodic; + pending_discover_tag = 0; + neighbor_table_refresh_active = false; + neighbor_table_refresh_periodic = false; + char tmp_reply[80]; + const char* origin_str = was_periodic ? "periodic" : "manual"; + if (startNeighborDiscover(tmp_reply)) { + MESH_DEBUG_PRINTLN("MQTT %s %s", origin_str, tmp_reply); + } else { + if (periodic_neighbors_enabled) { + next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval); + } + MESH_DEBUG_PRINTLN("MQTT %s neighbor scope discovery failed: %s", origin_str, tmp_reply); + } + } + } else if (periodic_neighbors_enabled && bridge && bridge->isRunning()) { + if (next_neighbors_publish == 0 || + (next_neighbors_publish != 0 && millisHasNowPassed(next_neighbors_publish))) { + if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) { + pending_discover_tag = 0; + sendNodeDiscoverReq(); + MESH_DEBUG_PRINTLN("MQTT periodic neighbor table refresh started"); + } else { + MESH_DEBUG_PRINTLN("MQTT periodic refresh joined active neighbor discovery"); + } + neighbor_table_refresh_active = true; + neighbor_table_refresh_periodic = true; + } + } + + // Report the schedule state back to the bridge for `get mqtt.status`. + if (bridge) { + if (neighbor_discover_active || neighbor_table_refresh_active) { + bridge->setNeighborsSchedule(MQTTBridge::NBR_ACTIVE, 0); + } else if (next_neighbors_publish == 0 || millisHasNowPassed(next_neighbors_publish)) { + bridge->setNeighborsSchedule(MQTTBridge::NBR_DUE, 0); + } else { + long remaining_ms = (long)(next_neighbors_publish - futureMillis(0)); + uint32_t remaining_secs = remaining_ms > 0 ? (uint32_t)(remaining_ms / 1000) : 0; + bridge->setNeighborsSchedule(MQTTBridge::NBR_SCHEDULED, remaining_secs); + } + } +#endif + +#ifdef WITH_SNMP + // Push radio stats to SNMP agent every 2 seconds + if (_snmp_agent.isRunning()) { + static unsigned long last_snmp_stats = 0; + if (now - last_snmp_stats >= 2000) { + last_snmp_stats = now; + _snmp_agent.updateRadioStats( + radio_driver.getPacketsRecv(), radio_driver.getPacketsSent(), + radio_driver.getPacketsRecvErrors(), + (int16_t)_radio->getNoiseFloor(), + (int16_t)radio_driver.getLastRSSI(), + (int16_t)(radio_driver.getLastSNR() * 4), + getNumSentFlood(), getNumSentDirect(), + getNumRecvFlood(), getNumRecvDirect(), + getTotalAirTime() / 1000, uptime_millis / 1000); + } + } +#endif } bool MyMesh::isMillisTimerDue(unsigned long timestamp) const { @@ -1642,3 +2029,254 @@ bool MyMesh::hasPendingWork() const { || (saved_radio_apply_pending && !temp_radio_applied))) return true; return isMillisTimerDue(dirty_contacts_expiry); } +#if defined(WITH_MQTT_NEIGHBORS) +#include "helpers/MQTTMessageBuilder.h" +#if defined(ESP_PLATFORM) +#include +#endif + +// This node's own non-flood scope names, same source the anon-regions server +// reply uses. Empty string when the node has no scoped regions. +void MyMesh::getLocalScopes(char* buf, size_t len) { + if (!buf || len == 0) return; + buf[0] = 0; + region_map.exportNamesTo(buf, (int)len, REGION_DENY_FLOOD); +} + +// Client side of the anon-regions request (the server side is handleAnonRegionsReq). +// Inner payload: {tag(4)}{ANON_REQ_TYPE_REGIONS}{0x00 = zero-hop reply path}. +bool MyMesh::sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag) { + uint8_t secret[PUB_KEY_SIZE]; + self_id.calcSharedSecret(secret, target); + + tag = getRTCClock()->getCurrentTimeUnique(); + uint8_t inner[6]; + memcpy(inner, &tag, 4); + inner[4] = ANON_REQ_TYPE_REGIONS; + inner[5] = 0x00; // request a zero-hop reply path + + mesh::Packet* pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, target, secret, inner, sizeof(inner)); + if (!pkt) return false; + sendDirect(pkt, NULL, 0, 0); + return true; +} + +// Match a RESPONSE against the pending overlay entry by tag; copy its scope +// string (payload after the 8-byte {tag}{clock} header) into the entry. +bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len) { + if (overlay_idx < 0 || overlay_idx >= neighbor_discover_count) return false; + NeighborDiscoverEntry& entry = neighbor_discover[overlay_idx]; + if (entry.status != ND_PENDING || len < 8) return false; + + uint32_t tag; + memcpy(&tag, data, 4); + if (tag != entry.tag) return false; + + size_t scope_len = len - 8; + if (scope_len >= sizeof(entry.scopes)) { + scope_len = sizeof(entry.scopes) - 1; + } + memcpy(entry.scopes, &data[8], scope_len); + entry.scopes[scope_len] = 0; + entry.status = ND_RESPONDED; + return true; +} + +// Publish-ordering: most recently heard first, then stronger SNR, then pubkey. +// The JSON builder drops the tail if the buffer fills, so the head must be the +// most useful entries. +static bool neighborPublishEntryComesBefore( + const MQTTMessageBuilder::NeighborsMessageEntry& lhs, + const MQTTMessageBuilder::NeighborsMessageEntry& rhs) { + if (lhs.heard_secs_ago != rhs.heard_secs_ago) { + return lhs.heard_secs_ago < rhs.heard_secs_ago; // newer first + } + if (lhs.snr != rhs.snr) { + return lhs.snr > rhs.snr; // stronger first when equally recent + } + return strcmp(lhs.pubkey_hex, rhs.pubkey_hex) < 0; +} + +#if defined(ESP_PLATFORM) +// ArduinoJson v7 JsonDocument has no real capacity cap (DynamicJsonDocument(N) +// is a no-op shim). Keep the pool off internal DRAM and soft-cap peak growth to +// the publish buffer size. used only rises on allocate -- conservative for this +// single-shot doc (overflow path removes+breaks, so no further growth after free). +struct NeighborsDocAllocator : ArduinoJson::Allocator { + size_t used = 0; + static const size_t kBudget = MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE; + + void* allocate(size_t size) override { + if (used >= kBudget || size > kBudget - used) return nullptr; + void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (p) used += size; + return p; + } + + void deallocate(void* ptr) override { + heap_caps_free(ptr); + } + + void* reallocate(void* ptr, size_t new_size) override { + size_t old_size = ptr ? heap_caps_get_allocated_size(ptr) : 0; + size_t next_used = (used >= old_size) ? (used - old_size) : 0; + if (next_used >= kBudget || new_size > kBudget - next_used) return nullptr; + void* p = heap_caps_realloc(ptr, new_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (p) used = next_used + new_size; + return p; + } +}; +#endif + +// Build the neighbors-table JSON and hand it to the bridge, then reschedule. +void MyMesh::finishNeighborDiscover() { + getLocalScopes(self_scopes_buf, sizeof(self_scopes_buf)); + + char self_pubkey_hex[65]; + mesh::Utils::toHex(self_pubkey_hex, self_id.pub_key, PUB_KEY_SIZE); + + char origin[32]; + MQTTBridge::getEffectiveMqttOrigin(_prefs.node_name, _cli.getObserverPrefs(), origin, sizeof(origin)); + + char timestamp[40]; + MQTTMessageBuilder::formatIsoTimestampForMqtt(getRTCClock()->getCurrentTime(), 0, nullptr, timestamp, sizeof(timestamp)); + + char pubkey_hex[MAX_NEIGHBOURS][65]; + MQTTMessageBuilder::NeighborsMessageEntry entries[MAX_NEIGHBOURS]; + uint32_t now_secs = getRTCClock()->getCurrentTime(); + + for (int i = 0; i < neighbor_discover_count; i++) { + auto& nb = neighbours[neighbor_discover[i].neighbour_idx]; + mesh::Utils::toHex(pubkey_hex[i], nb.id.pub_key, PUB_KEY_SIZE); + entries[i].pubkey_hex = pubkey_hex[i]; + entries[i].snr = nb.snr / 4.0f; + entries[i].heard_secs_ago = (nb.heard_timestamp > 0 && now_secs >= nb.heard_timestamp) + ? (now_secs - nb.heard_timestamp) : 0; + entries[i].scopes = neighbor_discover[i].scopes; + switch (neighbor_discover[i].status) { + case ND_RESPONDED: entries[i].status = "responded"; break; + case ND_SEND_FAILED: entries[i].status = "send_failed"; break; + default: entries[i].status = "timeout"; break; + } + } + + // insertion sort: most useful first (JSON builder drops the tail on overflow) + for (int i = 1; i < neighbor_discover_count; i++) { + MQTTMessageBuilder::NeighborsMessageEntry entry = entries[i]; + int j = i; + while (j > 0 && neighborPublishEntryComesBefore(entry, entries[j - 1])) { + entries[j] = entries[j - 1]; + j--; + } + entries[j] = entry; + } + +#if defined(ESP_PLATFORM) + char* json_buf = (char*)heap_caps_malloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE, MALLOC_CAP_SPIRAM); +#else + char* json_buf = (char*)malloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE); +#endif + if (!json_buf) { + neighbor_discover_active = false; + neighbor_discover_count = 0; + if (_cli.getObserverPrefs()->mqtt_neighbors_enabled) { + next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval); + } + return; + } + +#if defined(ESP_PLATFORM) + NeighborsDocAllocator doc_alloc; + JsonDocument doc(&doc_alloc); +#else + JsonDocument doc; +#endif + int json_len = MQTTMessageBuilder::buildNeighborsMessage( + doc, origin, self_pubkey_hex, timestamp, self_scopes_buf, + entries, neighbor_discover_count, + json_buf, MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE); + + if (json_len > 0 && bridge) { + bridge->requestPublishNeighbors(json_buf, (size_t)json_len); + } + +#if defined(ESP_PLATFORM) + heap_caps_free(json_buf); +#else + free(json_buf); +#endif + + neighbor_discover_active = false; + neighbor_discover_count = 0; + if (_cli.getObserverPrefs()->mqtt_neighbors_enabled) { + next_neighbors_publish = futureMillis(_cli.getObserverPrefs()->mqtt_neighbors_interval); + } +} + +// Advance the scope-query phase; publish once all entries resolve or the window +// times out (stragglers marked ND_TIMEOUT). +void MyMesh::loopNeighborDiscover() { + if (!neighbor_discover_active) return; + + bool all_done = true; + for (int i = 0; i < neighbor_discover_count; i++) { + if (neighbor_discover[i].status == ND_PENDING) { all_done = false; break; } + } + if (!all_done && !millisHasNowPassed(neighbor_discover_until)) return; + if (!all_done) { + for (int i = 0; i < neighbor_discover_count; i++) { + if (neighbor_discover[i].status == ND_PENDING) neighbor_discover[i].status = ND_TIMEOUT; + } + } + finishNeighborDiscover(); +} + +// Shared precondition for starting a discovery: PSRAM present + bridge running. +bool MyMesh::neighborDiscoverReady(char* reply) { +#if defined(ESP_PLATFORM) + if (!psramFound()) { strcpy(reply, "Err - PSRAM not available"); return false; } +#endif + if (!bridge || !bridge->isRunning()) { strcpy(reply, "Err - MQTT bridge not running"); return false; } + return true; +} + +// Snapshot the neighbor table into the overlay and fire one anon-regions query +// per heard neighbour; arm the 30s scope-query window. +bool MyMesh::startNeighborDiscover(char* reply) { + if (neighbor_discover_active) { + strcpy(reply, "Err - neighbor discover already active"); + return false; + } + if (!neighborDiscoverReady(reply)) { + return false; // reply already set + } + + getLocalScopes(self_scopes_buf, sizeof(self_scopes_buf)); + neighbor_discover_count = 0; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0) { + neighbor_discover[neighbor_discover_count].neighbour_idx = (uint8_t)i; + neighbor_discover[neighbor_discover_count].scopes[0] = 0; + neighbor_discover[neighbor_discover_count].status = ND_PENDING; + uint32_t tag; + if (sendAnonRegionsReq(neighbours[i].id, tag)) { + neighbor_discover[neighbor_discover_count].tag = tag; + } else { + neighbor_discover[neighbor_discover_count].status = ND_SEND_FAILED; + } + neighbor_discover_count++; + } + } + + neighbor_discover_active = true; + neighbor_discover_until = futureMillis(NEIGHBOR_DISCOVER_TIMEOUT_MS); + + if (neighbor_discover_count == 0) { + finishNeighborDiscover(); + strcpy(reply, "OK - neighbor discover started (0 neighbors, self only)"); + } else { + sprintf(reply, "OK - neighbor discover started (%u neighbors)", (unsigned)neighbor_discover_count); + } + return true; +} +#endif // WITH_MQTT_NEIGHBORS diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 065e5e8e..c642c2d6 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -28,6 +28,11 @@ #ifdef WITH_MQTT_BRIDGE #include "helpers/bridges/MQTTBridge.h" #define WITH_BRIDGE +#include "helpers/esp32/WebConfigServer.h" // defines WITH_WEBCONFIG on ESP32 +#endif + +#ifdef WITH_SNMP +#include "helpers/SNMPAgent.h" #endif @@ -99,6 +104,13 @@ struct PostInfo { char text[MAX_POST_TEXT_LEN+1]; }; +struct NeighbourInfo { + mesh::Identity id; + uint32_t advert_timestamp; + uint32_t heard_timestamp; + int8_t snr; // multiplied by 4, user should divide to get float value +}; + class MyMesh : public mesh::Mesh, public CommonCLICallbacks #ifdef WITH_WEBCONFIG , public WebConfigServer::Callbacks @@ -127,6 +139,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks RegionEntry* recv_pkt_region; TransportKey default_scope; unsigned long set_radio_at, revert_radio_at; + unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled) float pending_freq; float pending_bw; uint8_t pending_sf; @@ -137,9 +150,49 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks unsigned long radio_apply_retry_at; uint8_t radio_apply_failures; int matching_peer_indexes[MAX_CLIENTS]; +#if defined(WITH_MQTT_NEIGHBORS) + NeighbourInfo neighbours[MAX_NEIGHBOURS]; + uint32_t pending_discover_tag; + unsigned long pending_discover_until; + enum NeighborDiscoverStatus : uint8_t { + ND_PENDING = 1, + ND_RESPONDED = 2, + ND_TIMEOUT = 3, + ND_SEND_FAILED = 4, + }; + struct NeighborDiscoverEntry { + uint8_t neighbour_idx; + uint32_t tag; + char scopes[96]; + uint8_t status; + }; + NeighborDiscoverEntry neighbor_discover[MAX_NEIGHBOURS]; + uint8_t neighbor_discover_count; + bool neighbor_discover_active; + bool neighbor_table_refresh_active; + bool neighbor_table_refresh_periodic; + unsigned long neighbor_discover_until; + unsigned long next_neighbors_publish; + char self_scopes_buf[96]; + + void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); + void sendNodeDiscoverReq(); + bool sendAnonRegionsReq(const mesh::Identity& target, uint32_t& tag); + bool neighborDiscoverReady(char* reply); + bool startNeighborDiscover(char* reply); + void loopNeighborDiscover(); + void finishNeighborDiscover(); + bool handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len); + void getLocalScopes(char* buf, size_t len); + static const int NEIGHBOR_DISCOVER_PEER_BASE = 1000; + static const unsigned long NEIGHBOR_DISCOVER_TIMEOUT_MS = 30000; +#endif #ifdef WITH_MQTT_BRIDGE MQTTBridge* bridge; #endif +#ifdef WITH_SNMP + MeshSNMPAgent _snmp_agent; +#endif #ifdef WITH_MQTT_BRIDGE AlertReporter _alerter; #endif @@ -193,9 +246,6 @@ protected: int getInterferenceThreshold() const override { return _prefs.interference_threshold; } - bool getCADEnabled() const override { - return _prefs.cad_enabled; - } int getAGCResetInterval() const override { return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds } @@ -215,6 +265,10 @@ protected: void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; +#if defined(WITH_MQTT_NEIGHBORS) + void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; + void onControlDataRecv(mesh::Packet* packet) override; +#endif #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { @@ -269,11 +323,11 @@ public: void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override; bool setRxBoostedGain(bool enable) override; - void formatNeighborsReply(char *reply) override { - strcpy(reply, "not supported"); - } + void formatNeighborsReply(char *reply) override; + void removeNeighbor(const uint8_t* pubkey, int key_len) override; void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; + void formatRadioDiagReply(char *reply) override; void formatPacketStatsReply(char *reply) override; void startRegionsLoad() override; bool saveRegions() override; @@ -317,6 +371,13 @@ public: bridge->setBuildDate(getBuildDate()); #ifdef WITH_MQTT_BRIDGE bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); +#ifdef WITH_SNMP + if (_cli.getObserverPrefs()->snmp_enabled) { + _snmp_agent.setNodeName(_prefs.node_name); + _snmp_agent.setFirmwareVersion(getFirmwareVer()); + bridge->setSNMPAgent(&_snmp_agent); + } +#endif #endif bridge->begin(); #ifdef WITH_MQTT_BRIDGE @@ -335,7 +396,7 @@ public: void restartBridge() override { if (!bridge || !bridge->isRunning()) return; #ifdef WITH_WEBCONFIG - if (_wc_batch_active) { + if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd() _wc_restart_pending = true; return; } @@ -369,6 +430,29 @@ public: #endif } +#if defined(WITH_MQTT_BRIDGE) + // Pump already-queued mesh traffic before OTA blocks the loop and reboots. + // This is deliberately bounded: a jammed or duty-limited channel must not + // prevent an update, and Mesh::loop() continues to respect TX constraints. + void drainOutbound(uint32_t timeout_ms) { + unsigned long start = millis(); + while (hasOutbound() || _mgr->getOutboundCount(millis()) > 0) { + if (millis() - start >= timeout_ms) break; + mesh::Mesh::loop(); + delay(1); + } + } +#endif + + // Schedule the pull-OTA flash to run from loop() in ~2.5 s, leaving time for the + // "Beginning update..." CLI reply (CLI_REPLY_DELAY_MILLIS = 600 ms) to transmit + // before the flash blocks the loop and reboots. + bool beginDeferredOtaUpdate() override { + _ota_update_at = millis() + 2500; + if (_ota_update_at == 0) _ota_update_at = 1; // 0 means "none" + return true; + } + int getQueueSize() override { return bridge ? bridge->getQueueSize() : 0; } @@ -379,8 +463,11 @@ public: bool syncMqttNtp() override { if (!bridge || !bridge->isRunning()) return false; - // Marshal onto the MQTT task (Core 0); this runs on the CLI thread (Core 1). - return bridge->requestForcedNtpSync(); + // Queue the sync onto the MQTT task (Core 0) without blocking: this runs on + // the Arduino loop task (serial CLI and the web config batch both drain + // here), and blocking up to 30 s would stall mesh/radio forwarding. Returns + // true once queued; verify with `get mqtt.ntp.diag`. + return bridge->requestForcedNtpSync(0); } bool runMqttNtpDiag(char* reply, size_t reply_size, bool verbose) override { diff --git a/firmware-notes.html b/firmware-notes.html index e5b2a8fd..eeda7b07 100644 --- a/firmware-notes.html +++ b/firmware-notes.html @@ -1 +1 @@ -MQTT Observer v1.16.0 (experimental)
  • Up to 6 MQTT broker slots with built-in presets
  • Presets: Analyzer, MeshMapper, MeshRank, Waev, Meshomatic, CascadiaMesh, TennMesh, NashMesh, and more
  • JWT (Ed25519) and username/password authentication
  • Automatic reconnection with exponential backoff
  • After flashing, configure via serial console (115200 baud)

See setup guide for configuration instructions.

+MQTT Observer v1.16.0 (experimental)
  • Up to 6 MQTT broker slots with built-in presets
  • Presets: Analyzer, MeshMapper, MeshRank, Waev, Meshomatic, CascadiaMesh, TennMesh, NashMesh, and more
  • JWT (Ed25519) and username/password authentication
  • Automatic reconnection with exponential backoff
  • After flashing, configure via serial console (115200 baud)

See setup guide for configuration instructions.

diff --git a/lib/PsychicMqttClient/src/PsychicMqttClient.cpp b/lib/PsychicMqttClient/src/PsychicMqttClient.cpp index 90fadad3..5f5be4fc 100644 --- a/lib/PsychicMqttClient/src/PsychicMqttClient.cpp +++ b/lib/PsychicMqttClient/src/PsychicMqttClient.cpp @@ -50,6 +50,17 @@ PsychicMqttClient &PsychicMqttClient::setKeepAlive(int keepAlive) return *this; } +PsychicMqttClient &PsychicMqttClient::setNetworkTimeout(int timeoutMs) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.network.timeout_ms = timeoutMs; +#else + _mqtt_cfg.network_timeout_ms = timeoutMs; +#endif + _config_dirty = true; + return *this; +} + PsychicMqttClient &PsychicMqttClient::setAutoReconnect(bool reconnect) { #if ESP_IDF_VERSION_MAJOR == 5 @@ -61,6 +72,17 @@ PsychicMqttClient &PsychicMqttClient::setAutoReconnect(bool reconnect) return *this; } +PsychicMqttClient &PsychicMqttClient::setMessageRetransmitTimeout(int timeoutMs) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.session.message_retransmit_timeout = timeoutMs; +#else + _mqtt_cfg.message_retransmit_timeout = timeoutMs; +#endif + _config_dirty = true; + return *this; +} + PsychicMqttClient &PsychicMqttClient::setClientId(const char *clientId) { #if ESP_IDF_VERSION_MAJOR == 5 @@ -520,17 +542,44 @@ int PsychicMqttClient::publish(const char *topic, int qos, bool retain, const ch if (async) { + // QoS0 async publishes are stored in the esp-mqtt outbox (store=true) so that + // packet topics keep flowing -- enqueue(store=false) produced false-failure + // semantics that stalled the packet path. The outbox has no size bound of its + // own (esp-mqtt frees entries only on send-ack or time-based expiry), so on a + // stalled uplink QoS0 frames pile up on internal heap without limit. Cap it + // here: if the outbox is already at/over _outbox_limit, drop this QoS0 message + // and report -2 ("outbox full") so the caller can retry/drop. QoS1/2 (durable, + // low-rate) are never gated. + if (qos == 0 && _outbox_limit > 0 && _client != nullptr && + esp_mqtt_client_get_outbox_size(_client) >= _outbox_limit) + { + _outbox_drops++; + static unsigned long last_full_log = 0; + unsigned long now = millis(); + if (now - last_full_log > 5000) + { + ESP_LOGW(TAG, "Outbox at cap (%u bytes); dropping QoS0 message to topic %s", + (unsigned)_outbox_limit, topic); + last_full_log = now; + } + return -2; + } + ESP_LOGV(TAG, "Enqueuing message to topic %s with QoS %d", topic, qos); - // Hotfix: restore legacy outbox behavior for QoS0 async publishes. - // This avoids false-failure semantics from enqueue(store=false) on some - // connected paths where packet topics stop flowing. bool store_in_outbox = true; - return esp_mqtt_client_enqueue(_client, topic, payload, length, qos, retain, store_in_outbox); + int result = esp_mqtt_client_enqueue(_client, topic, payload, length, qos, retain, store_in_outbox); + if (result < 0) _publish_err++; else _publish_ok++; + return result; } else { ESP_LOGV(TAG, "Publishing message to topic %s with QoS %d", topic, qos); - return esp_mqtt_client_publish(_client, topic, payload, length, qos, retain); + // Synchronous write (used for QoS0 packet publishes). A negative result is a real + // send failure (socket error / network_timeout on a stalled link), tracked here so + // callers can surface delivery health without per-message logging. + int result = esp_mqtt_client_publish(_client, topic, payload, length, qos, retain); + if (result < 0) _publish_err++; else _publish_ok++; + return result; } } @@ -548,6 +597,40 @@ esp_mqtt_client_config_t *PsychicMqttClient::getMqttConfig() return &_mqtt_cfg; } +PsychicMqttClient &PsychicMqttClient::setOutboxLimit(size_t bytes) +{ + _outbox_limit = bytes; + return *this; +} + +size_t PsychicMqttClient::getOutboxSize() +{ + if (_client == nullptr) + return 0; + int size = esp_mqtt_client_get_outbox_size(_client); + return size > 0 ? (size_t)size : 0; +} + +size_t PsychicMqttClient::getOutboxLimit() +{ + return _outbox_limit; +} + +unsigned long PsychicMqttClient::getOutboxDrops() +{ + return _outbox_drops; +} + +unsigned long PsychicMqttClient::getPublishOk() +{ + return _publish_ok; +} + +unsigned long PsychicMqttClient::getPublishErr() +{ + return _publish_err; +} + void PsychicMqttClient::_onMqttEventStatic(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) { // Since this is a static function, we need to cast the first argument (void*) back to the class instance type diff --git a/lib/PsychicMqttClient/src/PsychicMqttClient.h b/lib/PsychicMqttClient/src/PsychicMqttClient.h index 890e5243..8ef88f3a 100644 --- a/lib/PsychicMqttClient/src/PsychicMqttClient.h +++ b/lib/PsychicMqttClient/src/PsychicMqttClient.h @@ -126,6 +126,29 @@ public: */ PsychicMqttClient &setAutoReconnect(bool reconnect = true); + /** + * @brief Sets the network operation timeout in milliseconds. esp-mqtt aborts a + * network read/write (including a synchronous publish's socket write) if it does + * not complete within this window. A lower value bounds how long a synchronous + * QoS0 publish can block on a stalled/half-open socket before failing and letting + * the slot flip to disconnected. esp-mqtt's default is 10000. + * + * @param timeoutMs Network timeout in milliseconds. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setNetworkTimeout(int timeoutMs); + + /** + * @brief Sets the retransmit timeout for unacknowledged QoS 1/2 messages. + * esp-mqtt resends an unacked PUBLISH (DUP flag set) every time this + * timeout elapses, so a value shorter than the broker's ack latency + * produces byte-identical duplicates on the wire. + * + * @param timeoutMs Retransmit timeout in milliseconds. esp-mqtt's default is 1000. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setMessageRetransmitTimeout(int timeoutMs); + /** * @brief Sets the client ID for the MQTT connection. * @@ -399,6 +422,55 @@ public: */ esp_mqtt_client_config_t *getMqttConfig(); + /** + * @brief Caps the size of the esp-mqtt outbox for async QoS 0 publishes. + * + * QoS 0 async publishes are forced into the esp-mqtt outbox (store=true) so + * packet topics keep flowing, but the outbox has no size bound of its own -- + * it only frees entries on send-ack or time-based expiry. On a stalled uplink + * the entries accumulate on internal heap without limit. When the current + * outbox size is at/over this cap, publish() drops the new QoS 0 message + * (returns -2) instead of enqueuing it, applying backpressure. 0 disables the + * cap. QoS 1/2 publishes are never gated by this. + * + * @param bytes Maximum outbox size in bytes, or 0 to disable. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setOutboxLimit(size_t bytes); + + /** + * @brief Returns the current esp-mqtt outbox size in bytes (0 if the client + * is not initialized). Useful for diagnostics/backpressure monitoring. + * + * @return Current outbox size in bytes. + */ + size_t getOutboxSize(); + + /** + * @brief Returns the configured outbox cap in bytes (0 = disabled). Lets + * callers confirm the cap is actually applied to this client. + */ + size_t getOutboxLimit(); + + /** + * @brief Returns the cumulative count of QoS0 messages dropped because the + * outbox was at/over the cap. Monotonic; useful for backpressure diagnostics. + */ + unsigned long getOutboxDrops(); + + /** + * @brief Cumulative count of publishes that were accepted (enqueued or written + * successfully). Monotonic. Pair with getPublishErr() for delivery-health stats. + */ + unsigned long getPublishOk(); + + /** + * @brief Cumulative count of publishes that failed (negative return from the + * synchronous write or async enqueue -- socket error / network timeout). Monotonic. + * A rising value indicates the uplink is dropping publishes. + */ + unsigned long getPublishErr(); + private: esp_mqtt_client_handle_t _client = nullptr; esp_mqtt_client_config_t _mqtt_cfg; @@ -407,6 +479,15 @@ private: bool _stopMqttClient = false; bool _config_dirty = true; + // Runtime cap on the esp-mqtt outbox for QoS 0 async publishes (bytes). + // 0 = disabled. Enforced in publish(); not an esp-mqtt config field. + size_t _outbox_limit = 0; + // Cumulative count of QoS0 publishes dropped because the outbox hit the cap. + unsigned long _outbox_drops = 0; + // Cumulative publish accept/fail counts (any QoS, sync or async path). + unsigned long _publish_ok = 0; + unsigned long _publish_err = 0; + // Multipart message reassembly. _buffer is lazily allocated at connect() time // to match the configured buffer size, then reused for the client's lifetime. // _topic is inline storage, never heap-allocated. diff --git a/platformio.ini b/platformio.ini index 2818b832..9115fd77 100644 --- a/platformio.ini +++ b/platformio.ini @@ -49,6 +49,9 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 build_src_filter = +<*.cpp> + + ; MQTT-only sources (need Timezone/NTPClient); observer envs add them back. + - + - + + + @@ -76,10 +79,13 @@ build_flags = ${arduino_base.build_flags} -D ENABLE_OTA=1 -D OTA_FLASH_STORE=1 -D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW) + ; Route esp_transport_ws_init through src/helpers/ESP32WsTransportFix.cpp to + ; fix a heap-overflow in the precompiled IDF 4.4 WS transport (see that file). + -Wl,--wrap=esp_transport_ws_init ; -D ESP32_CPU_FREQ=80 ; change it to your need lib_deps = ${arduino_base.lib_deps} ESP32Async/ESPAsyncWebServer @ 3.10.3 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 build_src_filter = ${arduino_base.build_src_filter} + + @@ -236,6 +242,8 @@ build_flags = -UENV_INCLUDE_GPS ; ----------------- TESTING --------------------- +; Host GoogleTest suites for the fork's pure logic. See test/README.md. +; Run: `pio test -e native` (all) or `pio test -e native -f ` (one). [env:native] platform = native @@ -244,10 +252,11 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_filter = test_utils +test_ignore = test_kiss_modem build_src_filter = -<*> +<../src/Utils.cpp> + +<../src/helpers/MQTTPayloadBuilder.cpp> +<../src/Packet.cpp> +<../src/Dispatcher.cpp> +<../src/Identity.cpp> @@ -261,6 +270,7 @@ build_src_filter = +<../src/helpers/ota/detools/detools.c> lib_deps = google/googletest @ 1.17.0 + bblanchon/ArduinoJson @ 7.4.3 [env:native_kiss_modem] platform = native diff --git a/scripts/check_arduinojson_pin.py b/scripts/check_arduinojson_pin.py new file mode 100644 index 00000000..f51b878b --- /dev/null +++ b/scripts/check_arduinojson_pin.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Require ArduinoJson 7.4.3 in checked-in PlatformIO configurations. + +The checker scans the root ``platformio.ini`` plus every +``variants/**/platformio.ini``. It checks every explicit ArduinoJson +declaration and, without a fragile fixed count, requires one in the native +test environment and each MQTT bridge environment. + +Run ``python3 scripts/check_arduinojson_pin.py`` for the repository and add +``--self-test`` to exercise accepted, unpinned, wrong, and missing pins in a +temporary fixture. +""" + +import argparse +from pathlib import Path +import re +import sys +from tempfile import TemporaryDirectory + + +VERSION = "7.4.3" +PACKAGE = "bblanchon/ArduinoJson" +PACKAGE_PATTERN = re.compile(r"\bbblanchon\s*/\s*ArduinoJson\b", re.IGNORECASE) +PIN_PATTERN = re.compile(r"@\s*7\.4\.3\Z") +SECTION_PATTERN = re.compile(r"^\s*\[([^]]+)\]\s*$") + + +def active(line): + """Remove PlatformIO whole-line and inline comments.""" + + if line.lstrip().startswith((";", "#")): + return "" + comments = [index for index in (line.find(";"), line.find("#")) if index >= 0] + return line[: min(comments)] if comments else line + + +def configuration_paths(root): + root_config = root / "platformio.ini" + if not root_config.is_file(): + raise FileNotFoundError(f"root PlatformIO configuration is missing: {root_config}") + variants = root / "variants" + return [root_config] + sorted(variants.rglob("platformio.ini") if variants.is_dir() else []) + + +def sections(lines): + """Yield ``(name, header_line, lines)`` while preserving source locations.""" + + name, header_line, content = "", 1, [] + for number, line in enumerate(lines, 1): + match = SECTION_PATTERN.fullmatch(active(line).strip()) + if match: + yield name, header_line, content + name, header_line, content = match.group(1), number, [] + else: + content.append((number, line)) + yield name, header_line, content + + +def expects_pin(config, root, section, lines): + if config == root / "platformio.ini": + return section == "env:native" + if not section.startswith("env:"): + return False + text = "\n".join(active(line) for _, line in lines) + return "WITH_MQTT_BRIDGE" in text or "elims/PsychicMqttClient" in text + + +def check(root): + """Return (configs, declarations, required environments, error messages).""" + + root = root.resolve() + configs, declarations, required, errors = configuration_paths(root), 0, 0, [] + for config in configs: + relative = config.relative_to(root).as_posix() + for section, header_line, lines in sections(config.read_text(encoding="utf-8").splitlines()): + found = False + for number, raw_line in lines: + declaration = active(raw_line) + match = PACKAGE_PATTERN.search(declaration) + if not match: + continue + found, declarations = True, declarations + 1 + version = declaration[match.end() :].strip() + if not version: + errors.append(f"{relative}:{number}: {PACKAGE} is unpinned; use '@ {VERSION}'") + elif not PIN_PATTERN.fullmatch(version): + errors.append( + f"{relative}:{number}: {PACKAGE} must be pinned exactly to {VERSION}; " + f"found '{version}'" + ) + if expects_pin(config, root, section, lines): + required += 1 + if not found: + errors.append( + f"{relative}:{header_line}: missing required {PACKAGE} @ {VERSION} " + f"in [{section}]" + ) + return configs, declarations, required, errors + + +def self_test(): + """Exercise discovery and the valid, unpinned, wrong, and missing cases.""" + + with TemporaryDirectory() as directory: + root = Path(directory) + root_config = root / "platformio.ini" + variant = root / "variants" / "nested" / "mqtt" / "platformio.ini" + variant.parent.mkdir(parents=True) + root_config.write_text("[env:native]\nbblanchon/ArduinoJson\n", encoding="utf-8") + variant.write_text( + "[env:wrong]\n-D WITH_MQTT_BRIDGE=1\nbblanchon/ArduinoJson @ ^7.4.3\n" + "[env:missing]\nelims/PsychicMqttClient@^0.2.4\n", + encoding="utf-8", + ) + configs, _, required, errors = check(root) + joined = "\n".join(errors) + if len(configs) != 2 or required != 3 or len(errors) != 3 or not all( + phrase in joined for phrase in ("unpinned", "must be pinned exactly", "missing required") + ): + print("ArduinoJson pin checker self-test failed: invalid cases were not reported.", file=sys.stderr) + return 1 + + root_config.write_text("[env:native]\nbblanchon/ArduinoJson @ 7.4.3\n", encoding="utf-8") + variant.write_text( + "[env:one]\n-D WITH_MQTT_BRIDGE=1\nbblanchon/ArduinoJson @ 7.4.3\n" + "[env:two]\nelims/PsychicMqttClient@^0.2.4\nbblanchon/ArduinoJson@7.4.3\n", + encoding="utf-8", + ) + if check(root)[3]: + print("ArduinoJson pin checker self-test failed: valid pins were rejected.", file=sys.stderr) + return 1 + + print("ArduinoJson pin checker self-test passed.") + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project-root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args(argv) + if args.self_test: + return self_test() + + try: + configs, declarations, required, errors = check(args.project_root) + except FileNotFoundError as error: + print(f"ArduinoJson pin check could not run: {error}", file=sys.stderr) + return 2 + if errors: + print("ArduinoJson pin check failed:", *[f" {error}" for error in errors], sep="\n", file=sys.stderr) + return 1 + + print( + f"ArduinoJson pin check passed: {declarations} declaration(s) across {len(configs)} " + f"root/variant configuration file(s); {required} required environment section(s) covered." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_mqtt_preset_parity.py b/scripts/check_mqtt_preset_parity.py new file mode 100755 index 00000000..b65d3bc2 --- /dev/null +++ b/scripts/check_mqtt_preset_parity.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Compare MQTT built-in preset *names* between two MQTTPresets.h files. + +Only the first string field of each ``MQTT_PRESETS`` entry is compared (set +equality, case-sensitive). URL, auth, CA, keepalive, and credentials are +ignored so channel branches may diverge on config details without failing CI. + +Usage:: + + python3 scripts/check_mqtt_preset_parity.py FILE_A FILE_B \\ + --label-a observer-firmware --label-b observer-firmware-dev + + python3 scripts/check_mqtt_preset_parity.py --self-test +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + + +COUNT_PATTERN = re.compile( + r"static\s+const\s+int\s+MQTT_PRESET_COUNT\s*=\s*(\d+)\s*;" +) +# Start of the preset table; allow optional whitespace/newlines. +TABLE_START_PATTERN = re.compile( + r"(?:static|extern)\s+const\s+MQTTPresetDef\s+MQTT_PRESETS\s*" + r"\[\s*[^\]]*\]\s*=\s*\{", + re.MULTILINE, +) +# First quoted string after an opening brace at entry start (after optional WS). +ENTRY_NAME_PATTERN = re.compile(r'\{\s*"([^"]+)"\s*,') + + +def _strip_line_comment(line: str) -> str: + """Remove ``//`` comments; do not treat ``://`` in URLs as comments.""" + + in_string = False + i = 0 + while i < len(line): + ch = line[i] + if ch == '"' and (i == 0 or line[i - 1] != "\\"): + in_string = not in_string + elif not in_string and ch == "/" and i + 1 < len(line) and line[i + 1] == "/": + # Prefer not to cut URL schemes: require that the previous char is not ':'. + if i == 0 or line[i - 1] != ":": + return line[:i] + i += 1 + return line + + +def extract_preset_block(text: str) -> str: + """Return the interior of the ``MQTT_PRESETS[...] = { ... };`` initializer.""" + + match = TABLE_START_PATTERN.search(text) + if not match: + raise ValueError("MQTT_PRESETS table not found") + + depth = 1 + i = match.end() + in_string = False + while i < len(text): + ch = text[i] + if ch == '"' and (i == 0 or text[i - 1] != "\\"): + in_string = not in_string + elif not in_string: + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return text[match.end() : i] + i += 1 + raise ValueError("MQTT_PRESETS table is not closed") + + +def parse_presets(text: str, *, source: str = "") -> tuple[list[str], int | None]: + """Return (names in file order, MQTT_PRESET_COUNT or None if absent).""" + + count_match = COUNT_PATTERN.search(text) + declared_count = int(count_match.group(1)) if count_match else None + + block = extract_preset_block(text) + names: list[str] = [] + for raw_line in block.splitlines(): + line = _strip_line_comment(raw_line).strip() + if not line: + continue + entry = ENTRY_NAME_PATTERN.match(line) + if entry: + names.append(entry.group(1)) + + if not names: + raise ValueError(f"{source}: no preset entries found in MQTT_PRESETS") + + seen: set[str] = set() + dupes: list[str] = [] + for name in names: + if name in seen: + dupes.append(name) + seen.add(name) + if dupes: + raise ValueError(f"{source}: duplicate preset name(s): {', '.join(sorted(set(dupes)))}") + + if declared_count is not None and declared_count != len(names): + raise ValueError( + f"{source}: MQTT_PRESET_COUNT is {declared_count} but found {len(names)} preset entries" + ) + + return names, declared_count + + +def format_missing(label: str, names: set[str]) -> str: + if not names: + return f"Missing from {label}: (none)" + return f"Missing from {label}: {', '.join(sorted(names))}" + + +def compare( + names_a: set[str], + names_b: set[str], + *, + label_a: str, + label_b: str, +) -> list[str]: + """Return human-readable error lines if sets differ; empty list if equal.""" + + only_a = names_a - names_b + only_b = names_b - names_a + if not only_a and not only_b: + return [] + return [ + format_missing(label_b, only_a), # in A but not B -> missing from B + format_missing(label_a, only_b), # in B but not A -> missing from A + ] + + +def load_names(path: Path) -> set[str]: + text = path.read_text(encoding="utf-8") + names, _ = parse_presets(text, source=str(path)) + return set(names) + + +def self_test() -> int: + """Exercise equal-with-different-URLs, missing-name, and duplicate cases.""" + + header = """ +static const int MQTT_PRESET_COUNT = {count}; +static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {{ +{body} +}}; +""" + + with TemporaryDirectory() as directory: + root = Path(directory) + equal_a = root / "a.h" + equal_b = root / "b.h" + missing = root / "missing.h" + dupes = root / "dupes.h" + + equal_a.write_text( + header.format( + count=2, + body=' { "alpha", "wss://a.example/mqtt", nullptr },\n' + ' { "beta", "wss://b.example/mqtt", nullptr },', + ), + encoding="utf-8", + ) + # Same names, different URLs/config -- must pass. + equal_b.write_text( + header.format( + count=2, + body=' { "beta", "mqtt://other:1883", nullptr },\n' + ' { "alpha", "wss://changed.example/mqtt", nullptr },', + ), + encoding="utf-8", + ) + if compare(load_names(equal_a), load_names(equal_b), label_a="a", label_b="b"): + print("self-test failed: equal name sets with different URLs were rejected.", file=sys.stderr) + return 1 + + missing.write_text( + header.format( + count=1, + body=' { "alpha", "wss://a.example/mqtt", nullptr },', + ), + encoding="utf-8", + ) + errs = compare(load_names(equal_a), load_names(missing), label_a="a", label_b="missing") + if len(errs) != 2 or "beta" not in errs[0] or "(none)" not in errs[1]: + print(f"self-test failed: missing-name case unexpected: {errs!r}", file=sys.stderr) + return 1 + + try: + parse_presets( + header.format( + count=2, + body=' { "alpha", "wss://a.example/mqtt", nullptr },\n' + ' { "alpha", "wss://b.example/mqtt", nullptr },', + ), + source="dupes", + ) + print("self-test failed: duplicate names were accepted.", file=sys.stderr) + return 1 + except ValueError as error: + if "duplicate" not in str(error): + print(f"self-test failed: unexpected duplicate error: {error}", file=sys.stderr) + return 1 + + try: + parse_presets( + header.format( + count=99, + body=' { "alpha", "wss://a.example/mqtt", nullptr },', + ), + source="count-mismatch", + ) + print("self-test failed: count mismatch was accepted.", file=sys.stderr) + return 1 + except ValueError as error: + if "MQTT_PRESET_COUNT" not in str(error): + print(f"self-test failed: unexpected count error: {error}", file=sys.stderr) + return 1 + + # URL with :// must not be treated as a line comment. + url_comment = root / "url.h" + url_comment.write_text( + header.format( + count=1, + body=' { "alpha", "wss://mqtt.example:443/mqtt", nullptr }, // note', + ), + encoding="utf-8", + ) + if load_names(url_comment) != {"alpha"}: + print("self-test failed: URL scheme was treated as a comment.", file=sys.stderr) + return 1 + + # Silence unused path in fixture layout. + dupes.write_text("// unused\n", encoding="utf-8") + + print("MQTT preset parity checker self-test passed.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("file_a", type=Path, nargs="?", help="First MQTTPresets.h (e.g. production)") + parser.add_argument("file_b", type=Path, nargs="?", help="Second MQTTPresets.h (e.g. dev)") + parser.add_argument("--label-a", default="file_a", help="Label for file_a in reports") + parser.add_argument("--label-b", default="file_b", help="Label for file_b in reports") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args(argv) + + if args.self_test: + return self_test() + + if args.file_a is None or args.file_b is None: + parser.error("FILE_A and FILE_B are required unless --self-test is set") + + try: + names_a = load_names(args.file_a) + names_b = load_names(args.file_b) + except (OSError, ValueError) as error: + print(f"MQTT preset parity check could not run: {error}", file=sys.stderr) + return 2 + + errors = compare(names_a, names_b, label_a=args.label_a, label_b=args.label_b) + if errors: + print("MQTT preset name parity check failed:", *errors, sep="\n ", file=sys.stderr) + return 1 + + print( + f"MQTT preset name parity check passed: {len(names_a)} preset name(s) " + f"match between {args.label_a} and {args.label_b}." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py new file mode 100644 index 00000000..c654cd7a --- /dev/null +++ b/scripts/webconfig_mock_server.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python3 +"""Local mock of the WebConfig portal backend, for iterating on webui/index.html +in a real browser with no firmware, no flashing, and no paid emulator account. + +It serves the real webui/index.html and implements the same /api/* contract as +src/helpers/esp32/WebConfigServer.cpp -- including the 202+reqid handshake, the +pending -> done result polling, aggregate-success reboot gating, secret masking +(********), and the IATA / owner-key / length validation the firmware enforces. +So the browser drives the actual portal JS (wizard, save/poll/reqid, effective +value handling, reboot overlay, stats, scan) against realistic responses. + +It does NOT run the C++ handlers (that's what test/ gtest covers) or the +AsyncTCP transport -- it's a frontend + contract harness. + +Usage: + python3 scripts/webconfig_mock_server.py # LAN mode (login: password) + python3 scripts/webconfig_mock_server.py --setup # first-boot setup wizard + python3 scripts/webconfig_mock_server.py --port 9000 --active-slots 2 +Then open http://localhost:8080/ (or the chosen port). Editing index.html and +refreshing shows changes immediately -- the page is re-read per request. + +Stdlib only; no pip install. +""" + +import argparse +import copy +import json +import os +import re +import secrets +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + +HERE = os.path.dirname(os.path.abspath(__file__)) +INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html") + +SENTINEL = "********" +ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build flag +BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling +SCAN_SECS = 0.8 + +# Destination buffer sizes (chars, minus the NUL) -- mirrors the MQTTPrefs fields +# the firmware validates in CommonCLI_Observer.cpp. +LEN_LIMITS = { + "name": 31, "wifi.ssid": 31, "wifi.pwd": 63, "mqtt.origin": 31, + "mqtt.email": 63, "mqtt.ntp": 63, "timezone": 31, "snmp.community": 23, +} +SLOT_LEN_LIMITS = {"server": 63, "username": 31, "password": 63, + "token": 47, "topic": 95, "audience": 63} + +# Preset names + what the UI must collect (mirrors handlePresets()). +PRESETS = ( + [(n, "none") for n in ( + "analyzer-us", "analyzer-eu", "nz-analyzer", "meshmapper", "waev", + "meshomatic", "cascadiamesh", "tennmesh", "nashmesh", "ctmesh", "chimesh", + "meshat.se", "eastidahomesh", "coloradomesh", "dutchmeshcore-1", + "dutchmeshcore-2", "meshcore-ca-1", "meshcore-ca-2", "meshcore-fi", + "bostonmesh", "rflab", "ipnt.uk", "flmesh", "corecomms")] + + [("meshrank", "token"), ("inwmesh", "userpass")] +) + +SCAN_NETWORKS = [ + {"ssid": "Wokwi-GUEST", "rssi": -42, "enc": False}, + {"ssid": "HomeNet", "rssi": -55, "enc": True}, + {"ssid": "HomeNet-5G", "rssi": -61, "enc": True}, + {"ssid": "Neighbor 2.4", "rssi": -78, "enc": True}, + {"ssid": "OpenGuest", "rssi": -83, "enc": False}, +] + + +def default_config(setup_mode): + return { + "radio": { + "freq": 910.525, "bw": 62.5, "sf": 7, "cr": 5, "tx": 22, "af": 1.0, + "rxdelay": 0.0, "txdelay": 0.5, "cad": False, "rxgain": True, + "repeat": True, "flood_max": 64, "flood_max_advert": 8, + "flood_max_unscoped": 8, "loop_detect": "moderate", + "name": "MockNode", "lat": 39.7392, "lon": -104.9903, + "advert_interval": 240, "flood_advert_interval": 6, + }, + "wifi": { + # setup mode = unconfigured (empty ssid -> wizard); LAN mode = joined + "ssid": "" if setup_mode else "HomeNet", + "pwd": "" if setup_mode else "secretpw", # stored raw; masked on GET + "powersave": "min", + }, + "mqtt": { + "origin": "" if setup_mode else "MockNode", "iata": "" if setup_mode else "DEN", + "status": True, "packets": True, "raw": False, "tx": "advert", "rx": True, + "interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7, + "ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False, + "snmp_community": "public", + "slots": [_slot() for _ in range(6)], + }, + } + + +def _slot(): + return {"preset": "none", "server": "", "port": 8883, "username": "", + "password": "", "token": "", "topic": "", "audience": ""} + + +class State: + def __init__(self, args): + self.lock = threading.Lock() + self.setup_mode = args.setup + self.active_slots = args.active_slots + self.cfg = default_config(args.setup) + # latched at AP start, like WebConfigServer::_initial_setup + self.initial_setup = args.setup and self.cfg["wifi"]["ssid"] == "" + self.start = time.time() + self.session = None # cookie token when logged in (LAN mode) + self.batch = {"state": "idle"} + self.scan_started = None + + # ---- auth ------------------------------------------------------------- + def is_authed(self, headers): + if self.setup_mode: + return True # setup mode: proximity trust, no auth + if not self.session: + return False + cookie = headers.get("Cookie", "") + m = re.search(r"wcs=([0-9a-f]+)", cookie) + return bool(m and m.group(1) == self.session) + + # ---- config serialization (masks secrets, like handleConfigGet) ------- + def config_json(self): + c = copy.deepcopy(self.cfg) + c["wifi"]["pwd"] = SENTINEL if self.cfg["wifi"]["pwd"] else "" + for s in c["mqtt"]["slots"]: + s["password"] = SENTINEL if s["password"] else "" + s["token"] = SENTINEL if s["token"] else "" + return c + + def status_json(self, authed): + return { + "mode": "setup" if self.setup_mode else "lan", + "auth": authed, + "needs_setup": self.cfg["wifi"]["ssid"] == "", + "name": self.cfg["radio"]["name"], "node_id": "a1b2c3d4e5f60718", + "fw": "v1.7.1-mock", "role": "Repeater", "board": "Heltec V3 (mock)", + "uptime_s": int(time.time() - self.start), + "runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots, + } + + +# --------------------------------------------------------------------------- +# set-command application + validation (mirrors the firmware's setters enough +# to produce realistic per-field OK / Error replies for the UI chips). +# --------------------------------------------------------------------------- +BOOL_KEYS = {"cad": ("radio", "cad"), "radio.rxgain": ("radio", "rxgain"), + "repeat": ("radio", "repeat"), "mqtt.status": ("mqtt", "status"), + "mqtt.packets": ("mqtt", "packets"), "mqtt.raw": ("mqtt", "raw"), + "mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp")} +INT_KEYS = {"tx": ("radio", "tx"), "flood.max": ("radio", "flood_max"), + "flood.max.advert": ("radio", "flood_max_advert"), + "flood.max.unscoped": ("radio", "flood_max_unscoped"), + "advert.interval": ("radio", "advert_interval"), + "flood.advert.interval": ("radio", "flood_advert_interval"), + "mqtt.interval": ("mqtt", "interval"), + "timezone.offset": ("mqtt", "timezone_offset")} +FLOAT_KEYS = {"lat": ("radio", "lat"), "lon": ("radio", "lon"), + "af": ("radio", "af"), "rxdelay": ("radio", "rxdelay"), + "txdelay": ("radio", "txdelay")} +STR_KEYS = {"name": ("radio", "name"), "wifi.ssid": ("wifi", "ssid"), + "wifi.powersave": ("wifi", "powersave"), "loop.detect": ("radio", "loop_detect"), + "mqtt.origin": ("mqtt", "origin"), "mqtt.ntp": ("mqtt", "ntp"), + "mqtt.email": ("mqtt", "email"), "timezone": ("mqtt", "timezone"), + "snmp.community": ("mqtt", "snmp_community"), "mqtt.tx": ("mqtt", "tx")} +SECRET_STR_KEYS = {"wifi.pwd": ("wifi", "pwd")} + + +def _hex64(v): + return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v) + + +def apply_set(cfg, key, val): + """Return (ok, reply) and mutate cfg. Mirrors the firmware's validation for + the fields where it matters (length, IATA, owner key, port, radio combo).""" + # length guard for the plain string fields + if key in LEN_LIMITS and len(val) > LEN_LIMITS[key]: + return False, "Error: %s too long (max %d chars)" % (key, LEN_LIMITS[key]) + + if key == "password": + # Stored outside cfg: it must never appear in the /api/config GET. The + # firmware overwrites the CLI's "password now: " echo, so the + # reply carries no secret either. + global ADMIN_PASSWORD + ADMIN_PASSWORD = val + return True, "OK" + + if key == "radio": + try: + f, bw, sf, cr = val.split(",") + f, bw, sf, cr = float(f), float(bw), int(sf), int(cr) + except ValueError: + return False, "Error, invalid radio params" + if not (150 <= f <= 2500 and 7 <= bw <= 500 and 5 <= sf <= 12 and 5 <= cr <= 8): + return False, "Error, invalid radio params" + cfg["radio"].update(freq=f, bw=bw, sf=sf, cr=cr) + return True, "OK - reboot to apply" + + if key == "mqtt.iata": + if val == "": + cfg["mqtt"]["iata"] = "" + return True, "OK - IATA cleared" + if len(val) != 3 or not val.isalnum() or not val.isascii(): + return False, "Error: IATA code must be exactly 3 letters/digits (e.g. DEN)" + cfg["mqtt"]["iata"] = val.upper() + return True, "OK" + + if key == "mqtt.owner": + if val == "": + cfg["mqtt"]["owner"] = "" + return True, "OK - owner key cleared" + if not _hex64(val): + return False, "Error: public key must be 64 hex characters (32 bytes)" + cfg["mqtt"]["owner"] = val + return True, "OK" + + m = re.match(r"^mqtt([1-6])\.(\w+)$", key) + if m: + return apply_slot_set(cfg, int(m.group(1)) - 1, m.group(2), val) + + if key in BOOL_KEYS: + sec, f = BOOL_KEYS[key] + cfg[sec][f] = (val == "on") + return True, "OK" + if key in INT_KEYS: + sec, f = INT_KEYS[key] + try: + cfg[sec][f] = int(val) + except ValueError: + return False, "Error: expected a number" + return True, "OK" + if key in FLOAT_KEYS: + sec, f = FLOAT_KEYS[key] + try: + cfg[sec][f] = float(val) + except ValueError: + return False, "Error: expected a number" + return True, "OK" + if key in SECRET_STR_KEYS: + sec, f = SECRET_STR_KEYS[key] + cfg[sec][f] = val + return True, "OK" + if key in STR_KEYS: + sec, f = STR_KEYS[key] + cfg[sec][f] = val + return True, "OK" + return True, "OK" # unknown-but-allowlisted: accept (mock is lenient here) + + +def apply_slot_set(cfg, idx, field, val): + slot = cfg["mqtt"]["slots"][idx] + if field in SLOT_LEN_LIMITS and len(val) > SLOT_LEN_LIMITS[field]: + return False, "Error: %s too long (max %d chars)" % (field, SLOT_LEN_LIMITS[field]) + if field == "port": + try: + p = int(val) + except ValueError: + return False, "Error: port must be between 1 and 65535" + if not (1 <= p <= 65535): + return False, "Error: port must be between 1 and 65535" + slot["port"] = p + return True, "OK" + if field in ("preset", "server", "username", "password", "token", "topic", "audience"): + slot[field] = val + if field == "token": + return True, "OK - slot %d token set" % (idx + 1) + return True, "OK" + return False, "Error: unknown slot field" + + +def is_secret_key(key): + return key == "wifi.pwd" or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key)) + + +def valid_reqid(reqid): + return isinstance(reqid, str) and bool(re.fullmatch(r"[0-9A-Fa-f]{16}", reqid)) + + +# --------------------------------------------------------------------------- +# HTTP handler +# --------------------------------------------------------------------------- +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): # concise one-line log + print(" %s %s" % (self.command, self.path)) + + # -- helpers -- + def _json(self, code, obj, extra_headers=None): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + for k, v in (extra_headers or {}): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def _read_body(self): + n = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(n) if n else b"" + + def _need_auth(self): + if not ST.is_authed(self.headers): + self._json(401, {"error": "auth"}) + return True + return False + + # -- GET -- + def do_GET(self): + path = self.path.split("?", 1)[0] + if path == "/": + return self._serve_index() + if path == "/api/status": + return self._json(200, ST.status_json(ST.is_authed(self.headers))) + if path == "/api/presets": + return self._json(200, {"presets": [{"name": n, "needs": nd} for n, nd in PRESETS]}) + if path == "/api/config": + if self._need_auth(): + return + with ST.lock: + return self._json(200, ST.config_json()) + if path == "/api/config/result": + if self._need_auth(): + return + return self._config_result() + if path == "/api/stats": + if self._need_auth(): + return + return self._json(200, self._stats()) + if path == "/api/scan": + if self._need_auth(): + return + return self._scan() + return self._json(404, {"error": "not found"}) + + # -- POST -- + def do_POST(self): + path = self.path.split("?", 1)[0] + if path == "/api/login": + return self._login() + if path == "/api/logout": + ST.session = None + return self._json(200, {"ok": True}, [("Set-Cookie", "wcs=; Max-Age=0; Path=/")]) + if path == "/api/config": + if self._need_auth(): + return + return self._config_post() + if path == "/api/reboot": + if self._need_auth(): + return + return self._json(200, {"ok": True}) + if path == "/api/portal/exit": + return self._json(200, {"ok": True, "url": "http://localhost:%d/" % PORT}) + return self._json(404, {"error": "not found"}) + + # -- endpoint impls -- + def _serve_index(self): + try: + with open(INDEX_HTML, "rb") as f: # re-read each time -> live edits + html = f.read() + except OSError: + self.send_error(500, "webui/index.html not found") + return + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(html))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(html) + + def _login(self): + if ST.setup_mode: + return self._json(200, {"ok": True}) + try: + body = json.loads(self._read_body() or b"{}") + except ValueError: + return self._json(400, {"error": "bad request"}) + if body.get("password") != ADMIN_PASSWORD: + return self._json(401, {"error": "wrong password"}) + ST.session = secrets.token_hex(16) + return self._json(200, {"ok": True}, + [("Set-Cookie", "wcs=%s; HttpOnly; SameSite=Lax; Path=/" % ST.session)]) + + def _config_post(self): + raw = self._read_body() + if len(raw) > 4096: + return self._json(413, {"error": "body too large"}) + try: + body = json.loads(raw or b"{}") + except ValueError: + return self._json(400, {"error": "bad json"}) + reqid = body.get("reqid", "") + if not valid_reqid(reqid): + return self._json(400, {"error": "bad reqid"}) + reboot = bool(body.get("reboot", False)) + setmap = body.get("set", {}) or {} + + # `password` maps to the top-level CLI command rather than a setter. It + # is accepted in both modes (LAN already required a login), but first + # onboarding cannot finish without it. + if "password" in setmap: + pwd = str(setmap["password"]) + if not 0 < len(pwd) <= 15 or "\r" in pwd or "\n" in pwd: + return self._json(400, {"error": "admin password must be 1-15 characters with no line breaks"}) + elif ST.setup_mode and ST.initial_setup and (reboot or "wifi.ssid" in setmap): + return self._json(400, {"error": "admin password required for initial setup"}) + + with ST.lock: + if ST.batch.get("state") != "idle" and ST.batch.get("reqid") == reqid: + return self._json(202, { + "state": ST.batch["state"], "count": len(ST.batch.get("results", [])), + "reqid": reqid, + }) + if ST.batch.get("state") == "pending": + return self._json(409, {"error": "busy", "reqid": ST.batch.get("reqid", "")}) + # drop unchanged secrets (sentinel), like the firmware does + entries = [(k, v) for k, v in setmap.items() + if not (is_secret_key(k) and v == SENTINEL)] + if not entries and not reboot: + return self._json(400, {"error": "no changes"}) + # apply now, but expose as pending->done to exercise polling + results, all_ok = [], True + for k, v in entries: + ok, reply = apply_set(ST.cfg, k, str(v)) + if not ok: + all_ok = False + results.append({"key": k, "reply": reply}) + ST.batch = {"state": "pending", "reqid": reqid, "results": results, + "all_ok": all_ok, "reboot": reboot, + "done_at": time.time() + BATCH_PENDING_SECS} + return self._json(202, {"state": "pending", "count": len(entries), "reqid": reqid}) + + def _config_result(self): + query = parse_qs(urlsplit(self.path).query) + reqid = query.get("reqid", [""])[0] + if not valid_reqid(reqid): + return self._json(400, {"error": "bad reqid"}) + with ST.lock: + b = ST.batch + if b.get("state") == "idle": + return self._json(200, {"state": "idle", "reqid": reqid}) + if b.get("reqid") != reqid: + return self._json(404, {"error": "unknown request"}) + if b["state"] == "pending" and time.time() < b["done_at"]: + return self._json(200, {"state": "pending", "reqid": b["reqid"]}) + b["state"] = "done" # stays readable until next POST + return self._json(200, { + "state": "done", "reqid": b["reqid"], "all_ok": b["all_ok"], + "reboot": b["reboot"] and b["all_ok"], "results": b["results"], + }) + + def _scan(self): + rescan = "rescan=1" in self.path + now = time.time() + if rescan or ST.scan_started is None: + ST.scan_started = now + return self._json(200, {"state": "scanning"}) + if now - ST.scan_started < SCAN_SECS: + return self._json(200, {"state": "scanning"}) + return self._json(200, {"state": "done", "networks": SCAN_NETWORKS}) + + def _stats(self): + up = int(time.time() - ST.start) + slots = [] + for i, s in enumerate(ST.cfg["mqtt"]["slots"]): + if s["preset"] == "none": + continue + slots.append({"n": i + 1, "name": s["preset"], "state": "ok", + "ok": 100 + up, "err": 0}) + return { + "uptime_s": up, "batt_mv": 4020, "heap_free": 142000, "heap_min": 118000, + "heap_max_alloc": 96000, "noise": -98, "rssi": -71, "snr": 9.5, + "airtime_s": up // 20, "rx_airtime_s": up // 8, "recv": 512 + up, + "sent": 88 + up // 3, "rx_err": 3, "sent_flood": 40, "sent_direct": 48, + "recv_flood": 300, "recv_direct": 212, "tx_queue": 0, "mqtt_queue": 0, + "wifi_rssi": -58, "ip": "192.168.1.42", "slots": slots, + } + + +def main(): + global ST, PORT + ap = argparse.ArgumentParser(description="Mock WebConfig portal backend") + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--setup", action="store_true", help="first-boot setup wizard mode") + ap.add_argument("--active-slots", type=int, default=5, help="server slots to expose (2 or 5)") + args = ap.parse_args() + ST, PORT = State(args), args.port + + srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + mode = "SETUP (wizard)" if args.setup else "LAN (login: %s)" % ADMIN_PASSWORD + print("WebConfig mock backend -- %s" % mode) + print(" open http://localhost:%d/ (Ctrl-C to stop)" % args.port) + try: + srv.serve_forever() + except KeyboardInterrupt: + print("\nstopped") + + +if __name__ == "__main__": + main() diff --git a/src/MeshCore.h b/src/MeshCore.h index f599acf2..558667ba 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -76,7 +76,7 @@ public: virtual void setGpio(uint32_t values) {} virtual uint8_t getStartupReason() const = 0; virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; } - virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported + virtual bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) { return false; } // not supported virtual bool stopOTAUpdate(char reply[]) { return false; } // not supported virtual bool isOTAUpdateRunning() const { return false; } // Pull-based OTA: fetch the firmware build for this variant from a baked-in manifest and flash it. diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index ba2a74f3..e54d7f03 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -218,6 +218,12 @@ void AlertReporter::onLoop(unsigned long now_ms) { // already enforces this on set, but a stale prefs file or future field // tweak shouldn't be able to drag the floor below 1 hour and let a // flapping link spam the mesh. + // + // The rate limiter only applies between two real sends: fired_at_ms == 0 + // means "never fired since boot/config change", and treating it as a send + // at millis()==0 would suppress every first alert until uptime reaches + // min_interval (observed as a 30-minute alert.mqtt threshold not reporting + // until 60 minutes after a reboot). uint16_t cfg_min = _obs->alert_min_interval_min; if (cfg_min < 60) cfg_min = 60; unsigned long min_interval_ms = (unsigned long)cfg_min * 60000UL; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 45b8e000..e4f6c600 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -53,6 +53,9 @@ static void resetToUf2Bootloader() { #ifdef WITH_MQTT_BRIDGE #include "bridges/MQTTBridge.h" #include "MQTTDefaults.h" +#include "MQTTPrefsAtomicStore.h" +#include "MQTTPrefsCodec.h" +#include "MQTTPrefsRecovery.h" #endif #define RECENT_REPEATER_PREFIX_MAX_BYTES 3 @@ -839,14 +842,29 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { // contain its appended byte, so they safely inherit the enabled default. _prefs->system_watchdog_enabled = 1; +#ifdef WITH_MQTT_BRIDGE + bool node_prefs_needs_migration = false; +#endif + if (fs->exists("/com_prefs")) { loadPrefsInt(fs, "/com_prefs"); loaded = true; // new filename } else if (fs->exists("/node_prefs")) { loadPrefsInt(fs, "/node_prefs"); loaded = true; is_upgrade = true; // Migrating from old filename - savePrefs(fs); // save to new filename - fs->remove("/node_prefs"); // remove old +#ifdef WITH_MQTT_BRIDGE + // Wait for loadMQTTPrefs() to persist any observer tail captured from this + // old file before replacing or removing its only on-flash copy. + node_prefs_needs_migration = true; +#else + savePrefs(fs); + if (fs->exists("/com_prefs")) { + fs->remove("/node_prefs"); + } else { + MESH_DEBUG_PRINTLN("Prefs: preserving legacy /node_prefs because /com_prefs was not created"); + } + _com_prefs_needs_upgrade = false; +#endif } else { // File doesn't exist - set default bridge settings for fresh installs is_fresh_install = true; @@ -856,28 +874,70 @@ 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 || node_prefs_needs_migration); + 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 (node_prefs_needs_migration) { + // The atomic /node_prefs -> /com_prefs handoff below persists this + // in-memory change. Do not publish /com_prefs before that transaction. + MESH_DEBUG_PRINTLN("MQTT Bridge: bridge.source will be saved with node 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) { +#ifdef WITH_MQTT_BRIDGE + if (node_prefs_needs_migration) { + if (legacy_upgrade.mayRewriteComPrefs()) { + // The MQTT image (and any tail from /node_prefs) is committed, so it is + // now safe to publish the replacement name. Keep /node_prefs until the + // complete /com_prefs image is closed and atomically renamed into place. + if (saveCommonPrefsImageAtomically(fs)) { + fs->remove("/node_prefs"); + legacy_upgrade.recordComPrefsRewrite(); + _com_prefs_needs_upgrade = false; + } else { + MESH_DEBUG_PRINTLN("MQTT: preserving legacy /node_prefs until /com_prefs migration commits"); + } + } else { + MESH_DEBUG_PRINTLN("MQTT: preserving legacy /node_prefs until /mqtt_prefs migration commits"); + } + } else 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. + if (legacy_upgrade.mayRewriteComPrefs()) { + // loadMQTTPrefs has already committed the full MQTT payload (including + // any recovered observer tail), so compact only /com_prefs now. + savePrefs(fs, false); + legacy_upgrade.recordComPrefsRewrite(); + _com_prefs_needs_upgrade = false; + } else { + MESH_DEBUG_PRINTLN("MQTT: preserving legacy /com_prefs until /mqtt_prefs migration commits"); + } + } +#else + if (_com_prefs_needs_upgrade) { savePrefs(fs); _com_prefs_needs_upgrade = false; } +#endif #if defined(ENABLE_OTA) if (loaded) syncOtaConfigFromPrefs(); // persisted OTA policy/keys -> OtaContext (else keep safe defaults) #endif @@ -1342,7 +1402,144 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { } } -void CommonCLI::savePrefs(FILESYSTEM* fs) { +#ifdef WITH_MQTT_BRIDGE +// Keep the byte layout in one place for the observer's coordinated atomic +// legacy migration. Ordinary builds use savePrefs() directly, avoiding a +// duplicate writer on flash-constrained targets. +template +static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { + uint8_t pad[8]; + memset(pad, 0, sizeof(pad)); + +#define WRITE_COMMON_PREFS(value) \ + do { \ + if (writer.write((const uint8_t *)(value), sizeof(*(value))) != sizeof(*(value))) return false; \ + } while (0) +#define WRITE_COMMON_PREFS_BYTES(value, size) \ + do { \ + if (writer.write((const uint8_t *)(value), (size)) != (size)) return false; \ + } while (0) + + WRITE_COMMON_PREFS(&prefs->airtime_factor); // 0 + WRITE_COMMON_PREFS(&prefs->node_name); // 4 + WRITE_COMMON_PREFS_BYTES(pad, 4); // 36 + WRITE_COMMON_PREFS(&prefs->node_lat); // 40 + WRITE_COMMON_PREFS(&prefs->node_lon); // 48 + WRITE_COMMON_PREFS_BYTES(prefs->password, sizeof(prefs->password)); // 56 + WRITE_COMMON_PREFS(&prefs->freq); // 72 + WRITE_COMMON_PREFS(&prefs->tx_power_dbm); // 76 + WRITE_COMMON_PREFS(&prefs->disable_fwd); // 77 + WRITE_COMMON_PREFS(&prefs->advert_interval); // 78 + WRITE_COMMON_PREFS_BYTES(pad, 1); // 79 + WRITE_COMMON_PREFS(&prefs->rx_delay_base); // 80 + WRITE_COMMON_PREFS(&prefs->tx_delay_factor); // 84 + WRITE_COMMON_PREFS_BYTES(prefs->guest_password, sizeof(prefs->guest_password)); // 88 + WRITE_COMMON_PREFS(&prefs->direct_tx_delay_factor); // 104 + WRITE_COMMON_PREFS_BYTES(pad, 4); // 108 + WRITE_COMMON_PREFS(&prefs->sf); // 112 + WRITE_COMMON_PREFS(&prefs->cr); // 113 + WRITE_COMMON_PREFS(&prefs->allow_read_only); // 114 + WRITE_COMMON_PREFS(&prefs->multi_acks); // 115 + WRITE_COMMON_PREFS(&prefs->bw); // 116 + WRITE_COMMON_PREFS(&prefs->agc_reset_interval); // 120 + WRITE_COMMON_PREFS(&prefs->path_hash_mode); // 121 + WRITE_COMMON_PREFS(&prefs->loop_detect); // 122 + WRITE_COMMON_PREFS_BYTES(pad, 1); // 123 + WRITE_COMMON_PREFS(&prefs->flood_max); // 124 + WRITE_COMMON_PREFS(&prefs->flood_advert_interval); // 125 + WRITE_COMMON_PREFS(&prefs->interference_threshold); // 126 + WRITE_COMMON_PREFS(&prefs->bridge_enabled); // 127 + WRITE_COMMON_PREFS(&prefs->bridge_delay); // 128 + WRITE_COMMON_PREFS(&prefs->bridge_pkt_src); // 130 + WRITE_COMMON_PREFS(&prefs->bridge_baud); // 131 + WRITE_COMMON_PREFS(&prefs->bridge_channel); // 135 + WRITE_COMMON_PREFS_BYTES(prefs->bridge_secret, sizeof(prefs->bridge_secret)); // 136 + WRITE_COMMON_PREFS(&prefs->powersaving_enabled); // 152 + WRITE_COMMON_PREFS(&prefs->reboot_interval); // 153 + WRITE_COMMON_PREFS_BYTES(pad, 2); // 154 + WRITE_COMMON_PREFS(&prefs->gps_enabled); // 156 + WRITE_COMMON_PREFS(&prefs->gps_interval); // 157 + WRITE_COMMON_PREFS(&prefs->advert_loc_policy); // 161 + WRITE_COMMON_PREFS(&prefs->discovery_mod_timestamp); // 162 + WRITE_COMMON_PREFS(&prefs->adc_multiplier); // 166 + WRITE_COMMON_PREFS_BYTES(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. + WRITE_COMMON_PREFS(&prefs->rx_boosted_gain); // 290 + WRITE_COMMON_PREFS(&prefs->flood_max_unscoped); // 291 + WRITE_COMMON_PREFS(&prefs->flood_max_advert); // 292 + WRITE_COMMON_PREFS(&prefs->radio_fem_rxgain); // 293 + WRITE_COMMON_PREFS(&prefs->cad_enabled); // 294 + + markDirectRetryPrefsValid(prefs); + WRITE_COMMON_PREFS(&prefs->retry_preset); // 295 + WRITE_COMMON_PREFS(&prefs->direct_retry_attempts); // 296 + WRITE_COMMON_PREFS(&prefs->direct_retry_base_ms); // 297 + WRITE_COMMON_PREFS(&prefs->direct_retry_step_ms); // 299 + WRITE_COMMON_PREFS(&prefs->direct_retry_snr_margin_x4); // 301 + WRITE_COMMON_PREFS(&prefs->direct_retry_cr4_snr_x4); // 303 + WRITE_COMMON_PREFS(&prefs->direct_retry_cr5_snr_x4); // 304 + WRITE_COMMON_PREFS(&prefs->direct_retry_cr7_snr_x4); // 305 + WRITE_COMMON_PREFS(&prefs->direct_retry_cr8_snr_x4); // 306 + WRITE_COMMON_PREFS(&prefs->direct_retry_enabled); // 307 + WRITE_COMMON_PREFS(&prefs->direct_retry_cr_enabled); // 308 + WRITE_COMMON_PREFS(&prefs->direct_retry_prefs_magic); // 309 + WRITE_COMMON_PREFS(&prefs->flood_retry_attempts); // 311 + WRITE_COMMON_PREFS(&prefs->flood_retry_max_path); // 312 + WRITE_COMMON_PREFS(&prefs->flood_retry_prefixes); // 313 + WRITE_COMMON_PREFS(&prefs->flood_retry_bridge_enabled); + WRITE_COMMON_PREFS(&prefs->flood_retry_bridge_buckets); + WRITE_COMMON_PREFS(&prefs->flood_retry_ignore_prefixes); + WRITE_COMMON_PREFS(&prefs->flood_retry_advert_enabled); + WRITE_COMMON_PREFS(&prefs->battery_alert_enabled); + WRITE_COMMON_PREFS(&prefs->battery_alert_low_percent); + WRITE_COMMON_PREFS(&prefs->battery_alert_critical_percent); + WRITE_COMMON_PREFS(&prefs->direct_retry_recent_enabled); + WRITE_COMMON_PREFS(&prefs->flood_channel_data_enabled); + WRITE_COMMON_PREFS(&prefs->flood_channel_block_max_hops); + WRITE_COMMON_PREFS(&prefs->flood_channel_data_max_hops); + WRITE_COMMON_PREFS(&prefs->telemetry_access); // 674 +#if defined(ENABLE_OTA) + WRITE_COMMON_PREFS(&prefs->ota_autofetch); // 675 + WRITE_COMMON_PREFS(&prefs->ota_autoinstall); // 676 + WRITE_COMMON_PREFS(&prefs->ota_signer_count); // 677 + WRITE_COMMON_PREFS(&prefs->ota_signers); // 678 + WRITE_COMMON_PREFS(&prefs->ota_checkpoint_blocks); // 806 + WRITE_COMMON_PREFS(&prefs->ota_advert_interval); // 808 + WRITE_COMMON_PREFS(&prefs->ota_max_hops); // 810 +#else + // Reserve the OTA tail so RXPS has the same offset in every build. + WRITE_COMMON_PREFS_BYTES(pad, 3); + for (size_t remaining = 128; remaining > 0; ) { + const size_t n = remaining > sizeof(pad) ? sizeof(pad) : remaining; + WRITE_COMMON_PREFS_BYTES(pad, n); + remaining -= n; + } + const uint16_t ota_checkpoint_blocks = 4; + const uint16_t ota_advert_interval = 0; + const uint8_t ota_max_hops = 3; + WRITE_COMMON_PREFS(&ota_checkpoint_blocks); + WRITE_COMMON_PREFS(&ota_advert_interval); + WRITE_COMMON_PREFS(&ota_max_hops); +#endif + WRITE_COMMON_PREFS(&prefs->rx_powersaving_enabled); // 811 + WRITE_COMMON_PREFS(&prefs->rx_ps_rx_us); // 812 + WRITE_COMMON_PREFS(&prefs->rx_ps_sleep_us); // 816 + WRITE_COMMON_PREFS(&prefs->rx_ps_level); // 820 + WRITE_COMMON_PREFS(&prefs->rx_ps_preamble); // 821 + WRITE_COMMON_PREFS(&prefs->battery_alert_region); // 822 + WRITE_COMMON_PREFS(&prefs->flood_retry_group_max_path); // 853 + WRITE_COMMON_PREFS(&prefs->rx_watchdog_enabled); // 854 + WRITE_COMMON_PREFS(&prefs->system_watchdog_enabled); // 855 + +#undef WRITE_COMMON_PREFS_BYTES +#undef WRITE_COMMON_PREFS + return true; +} +#endif + +void CommonCLI::savePrefs(FILESYSTEM* fs, bool save_mqtt) { #if defined(NRF52_PLATFORM) mesh::AtomicFileWriter file(fs, "/com_prefs"); #elif defined(STM32_PLATFORM) @@ -1486,7 +1683,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { #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); + if (save_mqtt) saveMQTTPrefs(fs); #endif } @@ -1496,226 +1693,422 @@ 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); - if (hdr.payload_len < payload_avail) payload_avail = hdr.payload_len; - 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 { - const size_t observer_tail_end = offsetof(MQTTPrefs, alert_region) - + sizeof(_mqtt_prefs.alert_region); - has_observer_fields = to_read >= observer_tail_end; - } - } 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"); } + 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; +} - 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(); +// 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) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + : _fs(fs), _file(*fs) {} +#else + : _fs(fs) {} +#endif - // 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(); + 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; + } - if (bytes_read > 0) { - MESH_DEBUG_PRINTLN("MQTT: Migrating old-format prefs to versioned layout"); + 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; + } - // 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; + 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; + } - // 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)); + 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; + } - // 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); - } + 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; + } - // 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); - } +private: + FILESYSTEM* _fs; + File _file; + bool _open = false; + bool _finished = false; + bool _owns_temp = false; + size_t _bytes_written = 0; +}; - // 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(); +#endif // WITH_MQTT_BRIDGE - if (bytes_read > 0) { - MESH_DEBUG_PRINTLN("MQTT: Migrating 3-slot prefs to versioned layout"); +#ifdef WITH_MQTT_BRIDGE +// The old /node_prefs name is only removed after this transaction has published +// a complete /com_prefs image. At this migration point /com_prefs is absent, so +// rename never needs a platform-specific replace-existing implementation. +class CommonPrefsFileStore { +public: + explicit CommonPrefsFileStore(FILESYSTEM* fs) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + : _fs(fs), _file(*fs) {} +#else + : _fs(fs) {} +#endif - // 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; + bool begin() { + _finished = false; + _open = false; + _bytes_written = 0; + // The old-name migration only starts when /com_prefs is absent. Refuse to + // overwrite a destination that appeared unexpectedly before this handoff. + if (_fs->exists("/com_prefs")) return false; + // Clear only stale, unpublished output. Guarded on exists() for the same + // reason as the /mqtt_prefs store above: remove() on a missing file logs a + // spurious VFS error on ESP32. + if (_fs->exists("/com_prefs.tmp")) { + _fs->remove("/com_prefs.tmp"); + if (_fs->exists("/com_prefs.tmp")) return false; // could not clear it + } +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + _file = _fs->open("/com_prefs.tmp", FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + _file = _fs->open("/com_prefs.tmp", "w"); +#else + _file = _fs->open("/com_prefs.tmp", "w", true); +#endif + _open = _file; + return _open; + } - // 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() + 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; + } - // 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)); + bool finish() { + if (!_open) return false; + _file.close(); + _open = false; +#if defined(RP2040_PLATFORM) + File verify = _fs->open("/com_prefs.tmp", "r"); +#else + File verify = _fs->open("/com_prefs.tmp"); +#endif + if (!verify) return false; + const bool complete = verify.size() == _bytes_written; + verify.close(); + if (!complete) return false; + _finished = true; + return true; + } - // 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(); + bool commit() { + return _finished && _fs->rename("/com_prefs.tmp", "/com_prefs"); + } - if (bytes_read > 0) { - MESH_DEBUG_PRINTLN("MQTT: Migrating headerless 6-slot prefs to versioned layout"); + void abort() { + if (_open) _file.close(); + _open = false; + _finished = false; + if (_fs->exists("/com_prefs.tmp")) _fs->remove("/com_prefs.tmp"); + } - 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. +private: + FILESYSTEM* _fs; + File _file; + bool _open = false; + bool _finished = false; + size_t _bytes_written = 0; +}; - saveMQTTPrefs(fs); - } - } else { - file.close(); +static const char* commonPrefsSaveResultName(MQTTPrefsAtomicStore::ImageResult result) { + switch (result) { + case MQTTPrefsAtomicStore::ImageResult::BeginFailed: return "begin"; + case MQTTPrefsAtomicStore::ImageResult::WriteFailed: return "write"; + case MQTTPrefsAtomicStore::ImageResult::FinishFailed: return "close"; + case MQTTPrefsAtomicStore::ImageResult::CommitFailed: return "rename"; + case MQTTPrefsAtomicStore::ImageResult::Committed: return "committed"; + } + return "unknown"; +} + +bool CommonCLI::saveCommonPrefsImageAtomically(FILESYSTEM* fs) { + CommonPrefsFileStore store(fs); + const MQTTPrefsAtomicStore::ImageResult result = MQTTPrefsAtomicStore::writeImage( + store, [this](CommonPrefsFileStore& target) { + return writeCommonPrefsImage(target, _prefs); + }); + if (!MQTTPrefsAtomicStore::imageCommitted(result)) { + MESH_DEBUG_PRINTLN("Prefs: atomic /com_prefs migration save failed at %s; /node_prefs preserved", + commonPrefsSaveResultName(result)); + return false; + } + return true; +} + +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; + } + 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 @@ -1724,11 +2117,6 @@ void CommonCLI::loadMQTTPrefs(FILESYSTEM* fs) { // 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)); @@ -1740,45 +2128,67 @@ 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"); } + + // Keep persisted values inside the signed-delta millis() scheduling window. + // This also repairs any manually-written or experimental value from firmware + // that briefly accepted intervals longer than the supported two-week cap. + 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) - mesh::AtomicFileWriter file(fs, "/mqtt_prefs"); -#elif 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)); -#if defined(NRF52_PLATFORM) - if (!file.commit()) { - MESH_DEBUG_PRINTLN("ERROR: saveMQTTPrefs atomic commit failed"); - } -#else - file.close(); -#endif + + // Write header and payload sequentially so the transaction needs no second + // full-size (2.8 KiB) staging buffer on constrained targets. + const MQTTPrefsHeader header = MQTTPrefsCodec::makeHeader(); + MQTTPrefsFileStore store(fs); + const MQTTPrefsAtomicStore::Result result = MQTTPrefsAtomicStore::write( + store, (const uint8_t *)&header, sizeof(header), + (const uint8_t *)&_mqtt_prefs, sizeof(_mqtt_prefs)); + if (!MQTTPrefsAtomicStore::committed(result)) { + MESH_DEBUG_PRINTLN("MQTT: atomic /mqtt_prefs save failed at %s; source preserved", + mqttPrefsSaveResultName(result)); + return false; } + return true; } #endif @@ -1875,12 +2285,16 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re #endif } else if (memcmp(command, "start ota", 9) == 0 && (command[9] == 0 || command[9] == ' ')) { // Manual OTA: bring up the board's browser server for a hand-uploaded binary. + const bool force_ap = command[9] == ' ' && strcmp(&command[10], "ap") == 0; + if (command[9] == ' ' && !force_ap) { + strcpy(reply, "ERR: usage start ota [ap]"); + } else #if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) if (_callbacks->isWebConfigActive()) { strcpy(reply, "ERR: stop webconfig first"); } else #endif - if (!_board->startOTAUpdate(_prefs->node_name, reply)) { + if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) { strcpy(reply, "Error"); } #if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 6159797d..8275bc14 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -227,111 +227,8 @@ struct NodePrefs { // persisted to file }; #ifdef WITH_MQTT_BRIDGE -// Old MQTT preferences layout (pre-slot firmware) - used only for migration detection -struct OldMQTTPrefs { - char mqtt_origin[32]; - char mqtt_iata[8]; - uint8_t mqtt_status_enabled; - uint8_t mqtt_packets_enabled; - uint8_t mqtt_raw_enabled; - uint8_t mqtt_tx_enabled; - uint32_t mqtt_status_interval; - char wifi_ssid[32]; - char wifi_password[64]; - uint8_t wifi_power_save; - char timezone_string[32]; - int8_t timezone_offset; - char mqtt_server[64]; - uint16_t mqtt_port; - char mqtt_username[32]; - char mqtt_password[64]; - uint8_t mqtt_analyzer_us_enabled; - uint8_t mqtt_analyzer_eu_enabled; - char mqtt_owner_public_key[65]; - char mqtt_email[64]; -}; - -// 3-slot MQTTPrefs layout - used for migrating from 3-slot to 6-slot format. -// Changing array sizes from [3] to [6] shifts all field offsets, so raw file.read() -// into the new struct would corrupt data. This struct preserves the old binary layout. -struct ThreeSlotMQTTPrefs { - char mqtt_origin[32]; - char mqtt_iata[8]; - uint8_t mqtt_status_enabled; - uint8_t mqtt_packets_enabled; - uint8_t mqtt_raw_enabled; - uint8_t mqtt_tx_enabled; - uint32_t mqtt_status_interval; - char wifi_ssid[32]; - char wifi_password[64]; - uint8_t wifi_power_save; - char timezone_string[32]; - int8_t timezone_offset; - char mqtt_slot_preset[3][24]; - char mqtt_slot_host[3][64]; - uint16_t mqtt_slot_port[3]; - char mqtt_slot_username[3][32]; - char mqtt_slot_password[3][64]; - char mqtt_owner_public_key[65]; - char mqtt_email[64]; - uint8_t _legacy_analyzer_us_enabled; - uint8_t _legacy_analyzer_eu_enabled; - char _legacy_mqtt_server[64]; - uint16_t _legacy_mqtt_port; - char _legacy_mqtt_username[32]; - char _legacy_mqtt_password[64]; - char mqtt_slot_token[3][48]; - char mqtt_slot_topic[3][96]; -}; - -// Versionless 6-slot layout as shipped on mqtt-bridge-implementation-flex (the -// several-thousand-device deployed fleet). This is the current MQTTPrefs minus the -// observer tail, and it still carries the now-removed `_legacy_*` fields mid-struct. -// loadMQTTPrefs reads a headerless file of this size into this struct, then -// field-copies (dropping `_legacy_*`) into the compact versioned MQTTPrefs. -struct Legacy6SlotMQTTPrefs { - char mqtt_origin[32]; - char mqtt_iata[8]; - uint8_t mqtt_status_enabled; - uint8_t mqtt_packets_enabled; - uint8_t mqtt_raw_enabled; - uint8_t mqtt_tx_enabled; - uint32_t mqtt_status_interval; - char wifi_ssid[32]; - char wifi_password[64]; - uint8_t wifi_power_save; - char timezone_string[32]; - int8_t timezone_offset; - char mqtt_slot_preset[MAX_MQTT_SLOTS][24]; - char mqtt_slot_host[MAX_MQTT_SLOTS][64]; - uint16_t mqtt_slot_port[MAX_MQTT_SLOTS]; - char mqtt_slot_username[MAX_MQTT_SLOTS][32]; - char mqtt_slot_password[MAX_MQTT_SLOTS][64]; - char mqtt_owner_public_key[65]; - char mqtt_email[64]; - uint8_t _legacy_analyzer_us_enabled; - uint8_t _legacy_analyzer_eu_enabled; - char _legacy_mqtt_server[64]; - uint16_t _legacy_mqtt_port; - char _legacy_mqtt_username[32]; - char _legacy_mqtt_password[64]; - char mqtt_slot_token[MAX_MQTT_SLOTS][48]; - char mqtt_slot_topic[MAX_MQTT_SLOTS][96]; - char mqtt_slot_audience[MAX_MQTT_SLOTS][64]; - uint8_t mqtt_rx_enabled; - char mqtt_ntp_server[64]; -}; - -// The legacy layouts above describe files already written to the deployed fleet's -// flash, so their sizes are frozen forever - loadMQTTPrefs() tells the eras apart -// by file size and reads each file as a raw struct dump. These asserts pin the -// layouts on every target toolchain; if one fires, the compiler (or an edit to a -// legacy struct or MAX_MQTT_SLOTS) has changed a layout and fleet files would be -// read at wrong offsets. -static_assert(sizeof(MQTTPrefsHeader) == 8, "versioned /mqtt_prefs header must stay 8 bytes"); -static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed"); -static_assert(sizeof(ThreeSlotMQTTPrefs) == 1464, "frozen 3-slot /mqtt_prefs layout changed"); -static_assert(sizeof(Legacy6SlotMQTTPrefs) == 2904, "frozen deployed-fleet /mqtt_prefs layout changed"); +static_assert(MQTT_PREFS_SLOT_COUNT == MAX_MQTT_SLOTS, + "MQTT prefs layout and slot count must change together"); // Observer settings captured from the trailing block of an old-format /com_prefs // (fork firmware that predates the NodePrefs -> MQTTPrefs split). loadPrefsInt() @@ -476,7 +373,6 @@ public: // Browser-based configuration portal. ESP32 infrastructure roles override // these; force_ap=true asks for the captive SoftAP even when WiFi is set. -#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) virtual bool startWebConfig(bool force_ap, char* reply) { (void)force_ap; (void)reply; @@ -498,7 +394,6 @@ public: (void)reply; return false; }; -#endif virtual int getQueueSize() { return 0; // no op by default @@ -553,6 +448,12 @@ public: }; }; +#ifdef WITH_MQTT_BRIDGE +namespace MQTTPrefsAtomicStore { +class LegacyUpgradeGate; +} +#endif + class CommonCLI { mesh::RTCClock* _rtc; NodePrefs* _prefs; @@ -565,9 +466,8 @@ class CommonCLI { #ifdef WITH_MQTT_BRIDGE MQTTPrefs _mqtt_prefs; LegacyObserverTail _legacy_tail; - // /mqtt_prefs carries a version newer than this firmware understands (a downgrade). - // The in-memory prefs run on defaults and saveMQTTPrefs() must not overwrite the - // file, or the first `set` command would destroy the newer config. + // /mqtt_prefs is newer, corrupt, or temporarily unreadable. The in-memory prefs + // run on defaults and saveMQTTPrefs() must not overwrite the source file. bool _mqtt_prefs_hold = false; #endif bool _com_prefs_needs_upgrade = false; // old-format /com_prefs detected; rewrite once after load @@ -576,8 +476,9 @@ class CommonCLI { void savePrefs(); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); #ifdef WITH_MQTT_BRIDGE - void loadMQTTPrefs(FILESYSTEM* fs); - void saveMQTTPrefs(FILESYSTEM* fs); + bool saveCommonPrefsImageAtomically(FILESYSTEM* fs); + void loadMQTTPrefs(FILESYSTEM* fs, MQTTPrefsAtomicStore::LegacyUpgradeGate* legacy_upgrade); + bool saveMQTTPrefs(FILESYSTEM* fs); #endif #if defined(ENABLE_OTA) void syncOtaConfigFromPrefs(); // persisted OTA policy + signer allowlist -> running OtaContext @@ -607,7 +508,7 @@ public: : _board(&board), _rtc(&rtc), _sensors(&sensors), _region_map(®ion_map), _acl(&acl), _prefs(prefs), _callbacks(callbacks) { } void loadPrefs(FILESYSTEM* _fs); - void savePrefs(FILESYSTEM* _fs); + void savePrefs(FILESYSTEM* _fs, bool save_mqtt = true); void handleCommand(uint32_t sender_timestamp, char* command, char* reply); mesh::MainBoard* getBoard() { return _board; } uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data); diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index e78922f4..bf70df65 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,6 +23,7 @@ #endif #ifdef WITH_MQTT_BRIDGE #include "bridges/MQTTBridge.h" +#include "MQTTConnectionPolicy.h" // classifySlotActivation() -- "will this slot connect here?" #include "MQTTDefaults.h" #endif @@ -92,19 +96,16 @@ static int getMQTTPresetNameCount() { return MQTT_PRESET_COUNT + 2; // built-ins + custom + none } -static bool isValidNtpHostname(const char* host) { - if (!host || host[0] == '\0') return false; - size_t len = strlen(host); - if (len > 63) return false; - if (host[0] == '.' || host[len - 1] == '.') return false; - for (size_t i = 0; i < len; i++) { - char c = host[i]; - if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || c == '.' || c == '-')) { - return false; - } +// Reject a value that wouldn't fit its destination MQTTPrefs buffer (which must +// hold the string plus a NUL) so an over-long CLI/web submission fails loudly +// instead of being silently truncated. Fills reply and returns true when too +// long. reply is the caller's 160-byte command buffer. +static bool valueTooLong(const char* val, size_t bufsize, char* reply, const char* label) { + if (!mqttValueFits(val, bufsize)) { + snprintf(reply, 160, "Error: %s too long (max %u chars)", label, (unsigned)(bufsize - 1)); + return true; } - return true; + return false; } static const char* getMQTTPresetNameByIndex(int index) { @@ -163,6 +164,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf #ifdef WITH_MQTT_BRIDGE bool handled = true; if (memcmp(config, "snmp.community ", 15) == 0) { + if (valueTooLong(&config[15], sizeof(_mqtt_prefs.snmp_community), reply, "snmp.community")) return true; StrHelper::strncpy(_mqtt_prefs.snmp_community, &config[15], sizeof(_mqtt_prefs.snmp_community)); savePrefs(); strcpy(reply, "OK - restart to apply"); @@ -200,18 +202,36 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf savePrefs(); strcpy(reply, "OK"); } else if (memcmp(config, "mqtt.origin ", 12) == 0) { + if (valueTooLong(&config[12], sizeof(_mqtt_prefs.mqtt_origin), reply, "origin")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_origin, &config[12], sizeof(_mqtt_prefs.mqtt_origin)); StrHelper::stripSurroundingQuotes(_mqtt_prefs.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin)); savePrefs(); strcpy(reply, "OK"); } else if (memcmp(config, "mqtt.iata ", 10) == 0) { - StrHelper::strncpy(_mqtt_prefs.mqtt_iata, &config[10], sizeof(_mqtt_prefs.mqtt_iata)); - for (int i = 0; _mqtt_prefs.mqtt_iata[i]; i++) { - _mqtt_prefs.mqtt_iata[i] = toupper(_mqtt_prefs.mqtt_iata[i]); + const char* iata = &config[10]; + size_t iata_len = strlen(iata); + if (iata_len == 0) { + // Empty clears the region code (meshcore-topic publishing stays disabled + // until one is set). This keeps the pre-existing "clear IATA" capability. + _mqtt_prefs.mqtt_iata[0] = '\0'; + savePrefs(); + _callbacks->restartBridge(); + strcpy(reply, "OK - IATA cleared"); + } else { + // A region code goes straight into MQTT topic paths, so require exactly + // three alphanumeric characters (real IATA codes are 3 letters, e.g. DEN). + if (!mqttIataValid(iata)) { + strcpy(reply, "Error: IATA code must be exactly 3 letters/digits (e.g. DEN)"); + } else { + StrHelper::strncpy(_mqtt_prefs.mqtt_iata, iata, sizeof(_mqtt_prefs.mqtt_iata)); + for (int i = 0; _mqtt_prefs.mqtt_iata[i]; i++) { + _mqtt_prefs.mqtt_iata[i] = toupper(_mqtt_prefs.mqtt_iata[i]); + } + savePrefs(); + _callbacks->restartBridge(); + strcpy(reply, "OK"); + } } - savePrefs(); - _callbacks->restartBridge(); - strcpy(reply, "OK"); } else if (memcmp(config, "mqtt.status ", 12) == 0) { _mqtt_prefs.mqtt_status_enabled = memcmp(&config[12], "on", 2) == 0; savePrefs(); @@ -246,11 +266,35 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } else { strcpy(reply, "Error: interval must be between 1-60 minutes"); } +#if defined(WITH_MQTT_NEIGHBORS) + } else if (memcmp(config, "mqtt.neighbors.interval ", 24) == 0) { + // Hours in, milliseconds stored. The 12-336h band keeps the interval under + // INT32_MAX so the mesh's wrap-safe signed-delta millis math stays valid. + uint32_t hours = _atoi(&config[24]); + if (hours >= MQTT_NEIGHBORS_MIN_INTERVAL_HOURS && hours <= MQTT_NEIGHBORS_MAX_INTERVAL_HOURS) { + _mqtt_prefs.mqtt_neighbors_interval = hours * 3600000UL; + savePrefs(); + sprintf(reply, "OK - neighbors interval set to %u hours (%lu ms)", (unsigned)hours, + (unsigned long)_mqtt_prefs.mqtt_neighbors_interval); + } else { + strcpy(reply, "Error: neighbors interval must be between 12-336 hours"); + } + } else if (memcmp(config, "mqtt.neighbors ", 15) == 0) { + // The mesh loop reads this live, so no bridge restart is needed; enabling it + // triggers a discovery on the next eligible loop pass. + _mqtt_prefs.mqtt_neighbors_enabled = memcmp(&config[15], "on", 2) == 0; + savePrefs(); + strcpy(reply, "OK"); +#elif defined(WITH_MQTT_BRIDGE) + } else if (memcmp(config, "mqtt.neighbors.interval ", 24) == 0 || + memcmp(config, "mqtt.neighbors ", 15) == 0) { + strcpy(reply, "Err - not supported (requires PSRAM)"); +#endif } else if (memcmp(config, "mqtt.ntp ", 9) == 0) { const char* host = &config[9]; while (*host == ' ') host++; bool clearing = strcmp(host, "none") == 0; - if (!clearing && !isValidNtpHostname(host)) { + if (!clearing && !mqttNtpHostnameValid(host)) { strcpy(reply, "Error: invalid NTP hostname"); } else { if (clearing) { @@ -260,26 +304,30 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } savePrefs(); #ifdef ESP_PLATFORM - // Validate by running an immediate sync. syncMqttNtp() marshals onto the MQTT - // task (Core 0) so no NTP I/O happens on this (Core 1) CLI thread. + // Queue a sync on the MQTT task (Core 0) but do NOT block: this handler + // runs on the Arduino loop task, shared with mesh/radio processing and the + // web config batch, so a synchronous wait of up to 30 s would stall the + // node. The sync runs in the background; verify with `get mqtt.ntp.diag`. if (WiFi.status() != WL_CONNECTED) { strcpy(reply, "OK - saved (WiFi not connected; NTP sync pending)"); } else if (!_callbacks->isMqttBridgeRunning()) { strcpy(reply, "OK - saved (MQTT bridge not running)"); } else if (_callbacks->syncMqttNtp()) { - strcpy(reply, "OK - time synced"); + strcpy(reply, "OK - saved (NTP sync started; check 'get mqtt.ntp.diag')"); } else { - strcpy(reply, "Error: NTP sync failed"); + strcpy(reply, "OK - saved (NTP sync unavailable)"); } #else strcpy(reply, "OK - saved"); #endif } } else if (memcmp(config, "wifi.ssid ", 10) == 0) { + if (valueTooLong(&config[10], sizeof(_mqtt_prefs.wifi_ssid), reply, "wifi.ssid")) return true; StrHelper::strncpy(_mqtt_prefs.wifi_ssid, &config[10], sizeof(_mqtt_prefs.wifi_ssid)); savePrefs(); strcpy(reply, "OK"); } else if (memcmp(config, "wifi.pwd ", 9) == 0) { + if (valueTooLong(&config[9], sizeof(_mqtt_prefs.wifi_password), reply, "wifi.pwd")) return true; StrHelper::strncpy(_mqtt_prefs.wifi_password, &config[9], sizeof(_mqtt_prefs.wifi_password)); savePrefs(); strcpy(reply, "OK"); @@ -323,6 +371,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf #endif } } else if (memcmp(config, "timezone ", 9) == 0) { + if (valueTooLong(&config[9], sizeof(_mqtt_prefs.timezone_string), reply, "timezone")) return true; StrHelper::strncpy(_mqtt_prefs.timezone_string, &config[9], sizeof(_mqtt_prefs.timezone_string)); savePrefs(); strcpy(reply, "OK"); @@ -369,6 +418,12 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } else if (p && p->topic_style == MQTT_TOPIC_MESHCORE && (strlen(_mqtt_prefs.mqtt_iata) == 0 || strcmp(_mqtt_prefs.mqtt_iata, "XXX") == 0)) { sprintf(reply, "OK - slot %d preset: %s (run 'set mqtt.iata ' to publish)", slot + 1, preset_name); + } else if (p && mqttPresetNeedsSlotPassword(p) && + _mqtt_prefs.mqtt_slot_password[slot][0] == '\0' && + !mqttPresetNeedsSlotUsername(p)) { + sprintf(reply, + "OK - slot %d preset: %s (run 'set mqtt%d.password ' to connect)", + slot + 1, preset_name, slot + 1); } else if (p && mqttPresetNeedsSlotCredentials(p) && (_mqtt_prefs.mqtt_slot_username[slot][0] == '\0' || _mqtt_prefs.mqtt_slot_password[slot][0] == '\0')) { @@ -378,13 +433,45 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } else { sprintf(reply, "OK - slot %d preset: %s", slot + 1, preset_name); } + // Warn when this slot won't actually connect on this hardware. The set + // is never blocked -- prefs persist so the config carries over if the + // device is moved to a board with more slots -- but flag it, or the + // operator waits for a connection that never comes (A15). Two failure + // modes, keyed off the same rule the bridge's setup loop uses + // (classifySlotActivation): slots past the runtime array are never + // tried; slots within it are skipped once more than getMaxActiveSlots() + // are enabled (each WSS/TLS link costs ~40 KB heap). + if (strcmp(preset_name, MQTT_PRESET_NONE) != 0) { + bool slot_enabled[MAX_MQTT_SLOTS]; + for (int s = 0; s < MAX_MQTT_SLOTS; s++) { + slot_enabled[s] = _mqtt_prefs.mqtt_slot_preset[s][0] != '\0' && + strcmp(_mqtt_prefs.mqtt_slot_preset[s], MQTT_PRESET_NONE) != 0; + } + const int max_active = MQTTBridge::getMaxActiveSlots(); + const MQTTConnectionPolicy::SlotActivation act = + MQTTConnectionPolicy::classifySlotActivation(slot, slot_enabled, + RUNTIME_MQTT_SLOTS, max_active); + size_t used = strlen(reply); + if (used < 158) { + if (act == MQTTConnectionPolicy::SlotActivation::BeyondArray) { + snprintf(reply + used, 160 - used, " (slot inactive on this hardware)"); + } else if (act == MQTTConnectionPolicy::SlotActivation::OverActiveCap) { + snprintf(reply + used, 160 - used, + " (won't connect: %d-slot limit on this hardware)", max_active); + } + } + } } } else { strcpy(reply, "Error: unknown preset. Use 'get mqtt.presets'"); } } else if (memcmp(subcmd, "server ", 7) == 0) { + if (valueTooLong(&subcmd[7], sizeof(_mqtt_prefs.mqtt_slot_host[slot]), reply, "server")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_slot_host[slot], &subcmd[7], sizeof(_mqtt_prefs.mqtt_slot_host[slot])); savePrefs(); + // Reconfigure the slot so the new host reaches the live connection (other + // custom-slot setters do the same; without it the change only applies on + // the next reboot/bridge restart). _callbacks->restartBridgeSlot(slot); strcpy(reply, "OK"); } else if (memcmp(subcmd, "port ", 5) == 0) { @@ -398,16 +485,19 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf strcpy(reply, "Error: port must be between 1 and 65535"); } } else if (memcmp(subcmd, "username ", 9) == 0) { + if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_username[slot]), reply, "username")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_slot_username[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_username[slot])); savePrefs(); _callbacks->restartBridgeSlot(slot); strcpy(reply, "OK"); } else if (memcmp(subcmd, "password ", 9) == 0) { + if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_password[slot]), reply, "password")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_slot_password[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_password[slot])); savePrefs(); _callbacks->restartBridgeSlot(slot); strcpy(reply, "OK"); } else if (memcmp(subcmd, "token ", 6) == 0) { + if (valueTooLong(&subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_token[slot]), reply, "token")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_slot_token[slot], &subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_token[slot])); savePrefs(); _callbacks->restartBridgeSlot(slot); @@ -415,6 +505,8 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } else if (memcmp(subcmd, "topic ", 6) == 0) { if (strcmp(_mqtt_prefs.mqtt_slot_preset[slot], "custom") != 0) { sprintf(reply, "Error: topic template only applies to custom preset slots"); + } else if (valueTooLong(&subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_topic[slot]), reply, "topic")) { + return true; } else { StrHelper::strncpy(_mqtt_prefs.mqtt_slot_topic[slot], &subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_topic[slot])); savePrefs(); @@ -422,6 +514,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf sprintf(reply, "OK - slot %d topic: %s", slot + 1, _mqtt_prefs.mqtt_slot_topic[slot]); } } else if (memcmp(subcmd, "audience ", 9) == 0) { + if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_audience[slot]), reply, "audience")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_slot_audience[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_audience[slot])); savePrefs(); _callbacks->restartBridgeSlot(slot); @@ -465,28 +558,21 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf strcpy(reply, "OK - owner cleared"); } else if (memcmp(config, "mqtt.owner ", 11) == 0) { const char* owner_key = &config[11]; - int key_len = strlen(owner_key); - if (key_len == 64) { - bool valid_key = true; - for (int i = 0; i < key_len; i++) { - if (!((owner_key[i] >= '0' && owner_key[i] <= '9') || - (owner_key[i] >= 'A' && owner_key[i] <= 'F') || - (owner_key[i] >= 'a' && owner_key[i] <= 'f'))) { - valid_key = false; - break; - } - } - if (valid_key) { - StrHelper::strncpy(_mqtt_prefs.mqtt_owner_public_key, owner_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: invalid hex characters in public key"); - } + if (owner_key[0] == '\0') { + // Owner key is optional -- empty clears it (previously this errored, so a + // set key could never be removed via the portal/CLI). + _mqtt_prefs.mqtt_owner_public_key[0] = '\0'; + savePrefs(); + strcpy(reply, "OK - owner key cleared"); + } else if (mqttOwnerKeyValid(owner_key)) { + StrHelper::strncpy(_mqtt_prefs.mqtt_owner_public_key, owner_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); + savePrefs(); + strcpy(reply, "OK"); } else { strcpy(reply, "Error: public key must be 64 hex characters (32 bytes)"); } } else if (memcmp(config, "mqtt.email ", 11) == 0) { + if (valueTooLong(&config[11], sizeof(_mqtt_prefs.mqtt_email), reply, "email")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_email, &config[11], sizeof(_mqtt_prefs.mqtt_email)); savePrefs(); strcpy(reply, "OK"); @@ -702,6 +788,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf start = (int)_atoi(start_arg); } formatMQTTPresetListReply(reply, 160, start); + } else if (memcmp(config, "mqtt.stats", 10) == 0) { + MQTTBridge::formatMqttStatsReply(reply, 160); } else if (memcmp(config, "mqtt.status", 11) == 0) { MQTTBridge::formatMqttStatusReply(reply, 160, &_mqtt_prefs); } else if (memcmp(config, "mqtt.packets", 12) == 0) { @@ -716,6 +804,19 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf } else if (memcmp(config, "mqtt.interval", 13) == 0) { uint32_t minutes = (_mqtt_prefs.mqtt_status_interval + 29999) / 60000; sprintf(reply, "> %u minutes (%lu ms)", minutes, (unsigned long)_mqtt_prefs.mqtt_status_interval); +#if defined(WITH_MQTT_NEIGHBORS) + // Longer token first: a bare "mqtt.neighbors" (14) would otherwise swallow + // "mqtt.neighbors.interval" since the GET tokens carry no trailing space. + } else if (memcmp(config, "mqtt.neighbors.interval", 23) == 0) { + uint32_t hours = (_mqtt_prefs.mqtt_neighbors_interval + 3599999) / 3600000; + sprintf(reply, "> %u hours (%lu ms)", (unsigned)hours, (unsigned long)_mqtt_prefs.mqtt_neighbors_interval); + } else if (memcmp(config, "mqtt.neighbors", 14) == 0) { + sprintf(reply, "> %s", _mqtt_prefs.mqtt_neighbors_enabled ? "on" : "off"); +#elif defined(WITH_MQTT_BRIDGE) + } else if (memcmp(config, "mqtt.neighbors.interval", 23) == 0 || + memcmp(config, "mqtt.neighbors", 14) == 0) { + strcpy(reply, "Err - not supported (requires PSRAM)"); +#endif } else if (memcmp(config, "mqtt.ntp.diag", 13) == 0 && (config[13] == '\0' || config[13] == ' ')) { #if defined(PORTABLE_MQTT_OBSERVER) strcpy(reply, "Error: NTP diagnostics omitted from portable build"); @@ -750,12 +851,20 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf } else if (memcmp(subcmd, "username", 8) == 0) { sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_username[slot]); } else if (memcmp(subcmd, "password", 8) == 0) { - sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_password[slot]); + // Serial only; remote sees set/unset. + if (sender_timestamp == 0) { + sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_password[slot]); + } else { + strcpy(reply, _mqtt_prefs.mqtt_slot_password[slot][0] ? "> ******** (serial only)" : "> (not set)"); + } } else if (memcmp(subcmd, "token", 5) == 0) { - if (_mqtt_prefs.mqtt_slot_token[slot][0] != '\0') { + // Serial only; remote sees set/unset. + if (_mqtt_prefs.mqtt_slot_token[slot][0] == '\0') { + strcpy(reply, "> (not set)"); + } else if (sender_timestamp == 0) { sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_token[slot]); } else { - strcpy(reply, "> (not set)"); + strcpy(reply, "> ******** (serial only)"); } } else if (memcmp(subcmd, "topic", 5) == 0) { if (_mqtt_prefs.mqtt_slot_topic[slot][0] != '\0') { @@ -777,7 +886,12 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf } else if (memcmp(config, "wifi.ssid", 9) == 0) { sprintf(reply, "> %s", _mqtt_prefs.wifi_ssid); } else if (memcmp(config, "wifi.pwd", 8) == 0) { - sprintf(reply, "> %s", _mqtt_prefs.wifi_password); + // Serial only (WiFi creds grant LAN access); remote sees set/unset. + if (sender_timestamp == 0) { + sprintf(reply, "> %s", _mqtt_prefs.wifi_password); + } else { + strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (serial only)" : "> (not set)"); + } } else if (memcmp(config, "wifi.status", 11) == 0) { wl_status_t status = WiFi.status(); const char* status_str; @@ -801,8 +915,11 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf unsigned long h = (uptime_sec % 86400) / 3600; unsigned long m = (uptime_sec % 3600) / 60; unsigned long s = uptime_sec % 60; + // reply points at the caller's char[160] command buffer (see main.cpp); + // compute the actual remaining space instead of assuming 128. + const size_t kReplyBufSize = 160; size_t len = strlen(reply); - const size_t reply_remaining = 128; + const size_t reply_remaining = (len < kReplyBufSize) ? (kReplyBufSize - len) : 0; if (d > 0) { snprintf(reply + len, reply_remaining, ", uptime: %lud %luh %lum %lus", d, h, m, s); } else if (h > 0) { @@ -835,10 +952,13 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf uint8_t ps = _mqtt_prefs.wifi_power_save; const char* ps_name = (ps == 1) ? "none" : (ps == 2) ? "max" : "min"; sprintf(reply, "> %s", ps_name); + } else if (memcmp(config, "timezone.offset", 15) == 0) { + // Must precede the "timezone" (8-byte) check below -- that prefix-matches + // "timezone.offset" too, so the more-specific key has to come first or + // `get timezone.offset` returns the string and never the offset (A3). + sprintf(reply, "> %d", _mqtt_prefs.timezone_offset); } else if (memcmp(config, "timezone", 8) == 0) { sprintf(reply, "> %s", _mqtt_prefs.timezone_string); - } else if (memcmp(config, "timezone.offset", 15) == 0) { - sprintf(reply, "> %d", _mqtt_prefs.timezone_offset); } else if (memcmp(config, "mqtt.analyzer.us", 17) == 0) { sprintf(reply, "> %s", strcmp(_mqtt_prefs.mqtt_slot_preset[0], "analyzer-us") == 0 ? "on" : "off"); } else if (memcmp(config, "mqtt.analyzer.eu", 17) == 0) { @@ -985,6 +1105,21 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, strcpy(reply, "ERR: online OTA not supported on this build"); #endif return true; + } else if (memcmp(command, "start webconfig", 15) == 0 && (command[15] == 0 || command[15] == ' ')) { + // Web config portal: `start webconfig` binds to the LAN IP (or raises the + // setup AP when WiFi is unconfigured); `start webconfig ap` forces the AP. + bool force_ap = (command[15] == ' ' && strcmp(&command[16], "ap") == 0); + if (command[15] == ' ' && !force_ap) { + strcpy(reply, "ERR: usage start webconfig [ap]"); + } else if (!_callbacks->startWebConfig(force_ap, reply)) { + strcpy(reply, "ERR: webconfig not supported on this build"); + } + return true; + } else if (strcmp(command, "stop webconfig") == 0) { + if (!_callbacks->stopWebConfig(reply)) { + strcpy(reply, "ERR: webconfig not supported on this build"); + } + return true; } else if (memcmp(command, "alert test", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { // Send a one-off test alert on the configured alert channel. const char* extra = command[10] == ' ' ? &command[11] : ""; @@ -998,7 +1133,16 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, strcpy(reply, "Error: alert channel not configured (set alert.psk or set alert.hashtag)"); } else { bool ok = _callbacks->sendAlertText(text); - strcpy(reply, ok ? "OK - alert sent" : "Error: alert send failed (bad PSK or PUBLIC key refused?)"); + if (!ok) { + strcpy(reply, "Error: alert send failed (bad PSK or PUBLIC key refused?)"); + } else if (!_mqtt_prefs.alert_enabled) { + // `alert test` deliberately bypasses the master switch, so a successful + // send here does NOT mean automatic WiFi/MQTT/OTA alerts will fire -- those + // gate on `alert on`. Flag it so a working test can't give false confidence. + strcpy(reply, "OK - test sent, but automatic alerts are OFF (run 'set alert on')"); + } else { + strcpy(reply, "OK - alert sent"); + } } return true; } diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 00d286b3..6c7ad4a9 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -211,12 +211,12 @@ public: static LightweightOTAServer lightweight_ota_server; -bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { +bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { (void)id; inhibit_sleep = true; IPAddress ip; - if (WiFi.status() == WL_CONNECTED) { + if (!force_ap && WiFi.status() == WL_CONNECTED) { ip = WiFi.localIP(); } else { if (!lightweight_ota_started_ap) { @@ -273,7 +273,7 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { #include -bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { +bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { inhibit_sleep = true; // prevent sleep during OTA if (ota_server != nullptr) { // already running (idempotent restart) @@ -285,8 +285,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { // If the device is already on a WiFi network (e.g. an observer joined in STA // mode), serve ElegantOTA on the station IP so it's reachable from the LAN // without joining a separate AP. Otherwise raise the MeshCore-OTA SoftAP. + // force_ap ("start ota ap") always raises the SoftAP, so the OTA UI stays + // reachable even when the joined network applies client isolation and the + // station IP can't be reached. IPAddress ip; - if (WiFi.status() == WL_CONNECTED) { + if (!force_ap && WiFi.status() == WL_CONNECTED) { ip = WiFi.localIP(); } else { const IPAddress ap_ip(192, 168, 4, 1); @@ -343,7 +346,7 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { } #else -bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { +bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { return false; // not supported } diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 96fa20bd..28257e24 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -180,7 +180,7 @@ public: esp_restart(); } - bool startOTAUpdate(const char* id, char reply[]) override; + bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override; bool stopOTAUpdate(char reply[]) override; bool isOTAUpdateRunning() const override { return ota_server != nullptr; } bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) override; diff --git a/src/helpers/ESP32WsTransportFix.cpp b/src/helpers/ESP32WsTransportFix.cpp new file mode 100644 index 00000000..ab5ebdff --- /dev/null +++ b/src/helpers/ESP32WsTransportFix.cpp @@ -0,0 +1,130 @@ +#ifdef ESP_PLATFORM + +// Link-time workaround for an off-by-one heap overflow in ESP-IDF v4.4's +// WebSocket transport (components/tcp_transport/transport_ws.c), which ships +// PRECOMPILED in the Arduino-ESP32 2.x SDK (libtcp_transport.a) and cannot be +// patched at source level. +// +// The bug (transport_ws.c, ws_connect() response-read loop): +// +// header_len += len; +// ws->buffer[header_len] = '\0'; // header_len can reach WS_BUFFER_SIZE +// } while (... && header_len < WS_BUFFER_SIZE); +// +// ws->buffer is malloc(WS_BUFFER_SIZE) (1024). When a wss:// endpoint answers +// the WebSocket upgrade with >= 1024 bytes of HTTP response before the blank +// line terminator (typical for a down/misconfigured broker behind a proxy or +// CDN that serves a large HTML error page), the final iteration writes one +// '\0' one byte past the block. With heap poisoning enabled that zeroes the +// LSB of the tail canary (0xbaad5678 -> 0xbaad5600); the corruption then sits +// silent until the block is freed -- which happens in ws_destroy() during +// esp_mqtt_client_destroy(), i.e. MQTTBridge::end() -- and the free asserts: +// +// CORRUPT HEAP: Bad tail at 0x.... Expected 0xbaad5678 got 0xbaad5600 +// assert failed: multi_heap_free multi_heap_poisoning.c:259 +// +// On observer builds that teardown runs at the start of the deferred +// `ota update`, so a single down wss broker made every online OTA panic and +// reboot before the download began (backtrace decoded from a Heltec V3 on +// v1.16.0.11: free <- ws_destroy <- esp_transport_list_destroy <- +// esp_mqtt_client_destroy <- ~PsychicMqttClient <- destroySlotClients <- +// MQTTBridge::end <- MyMesh::setBridgeState <- MyMesh::loop). +// +// Fix: [esp32_base] adds `-Wl,--wrap=esp_transport_ws_init`, so every +// creation of a WS transport (esp-mqtt does one per wss slot) is routed +// through __wrap_esp_transport_ws_init below, which replaces the freshly +// allocated 1024-byte buffer with a (WS_BUFFER_SIZE + 1)-byte one. The +// out-of-bounds index WS_BUFFER_SIZE then lands on our extra byte and the +// handshake fails cleanly ("Upgrade" header not found) instead of corrupting +// the heap. Upstream fixed this in ESP-IDF 5.x, so this file compiles to a +// pass-through there and can be deleted (together with the --wrap flag) when +// the fork moves to Arduino core 3.x. +// +// transport_ws_t below is copied verbatim from ESP-IDF release/v4.4 +// transport_ws.c (the struct is file-private, so it is not in any shipped +// header). Source fidelity was verified against the shipped binary: addr2line +// on the crash backtrace resolves to the exact line numbers of that file +// (e.g. free(ws->buffer) at transport_ws.c:546). Only the first two members +// (path, buffer) are dereferenced here. + +#include "esp_idf_version.h" + +#if ESP_IDF_VERSION_MAJOR == 4 + +#include +#include "sdkconfig.h" +#include "esp_transport.h" +#include "esp_transport_ws.h" + +#ifndef CONFIG_WS_BUFFER_SIZE +#define CONFIG_WS_BUFFER_SIZE 1024 +#endif + +// --- copied from ESP-IDF release/v4.4 components/tcp_transport/transport_ws.c --- +typedef struct { + uint8_t opcode; + char mask_key[4]; + int payload_len; + int bytes_remaining; + bool header_received; +} ws_transport_frame_state_t; + +typedef struct { + char *path; + char *buffer; + char *sub_protocol; + char *user_agent; + char *headers; + bool propagate_control_frames; + ws_transport_frame_state_t frame_state; + esp_transport_handle_t parent; +} transport_ws_t; +// -------------------------------------------------------------------------------- + +extern "C" { + +esp_transport_handle_t __real_esp_transport_ws_init(esp_transport_handle_t parent_handle); + +esp_transport_handle_t __wrap_esp_transport_ws_init(esp_transport_handle_t parent_handle) { + esp_transport_handle_t t = __real_esp_transport_ws_init(parent_handle); + if (t != nullptr) { + transport_ws_t* ws = (transport_ws_t*)esp_transport_get_context_data(t); + if (ws != nullptr && ws->buffer != nullptr) { + // The buffer is untouched at this point (allocated moments ago inside + // __real_esp_transport_ws_init), so a swap is safe. + char* padded = (char*)malloc(CONFIG_WS_BUFFER_SIZE + 1); + if (padded != nullptr) { + free(ws->buffer); + ws->buffer = padded; + } + // On alloc failure keep the original buffer: same behavior as before + // this fix, which is still strictly better than failing init here. + } + } + return t; +} + +} // extern "C" + +#else // ESP_IDF_VERSION_MAJOR != 4 + +// IDF 5.x fixed the overflow upstream; keep a pass-through so the --wrap flag +// (set for all ESP32 envs in [esp32_base]) still links if anything references +// the symbol. + +#include "esp_transport.h" +#include "esp_transport_ws.h" + +extern "C" { + +esp_transport_handle_t __real_esp_transport_ws_init(esp_transport_handle_t parent_handle); + +esp_transport_handle_t __wrap_esp_transport_ws_init(esp_transport_handle_t parent_handle) { + return __real_esp_transport_ws_init(parent_handle); +} + +} // extern "C" + +#endif // ESP_IDF_VERSION_MAJOR + +#endif // ESP_PLATFORM diff --git a/src/helpers/MQTTConnectionPolicy.h b/src/helpers/MQTTConnectionPolicy.h new file mode 100644 index 00000000..0175a1aa --- /dev/null +++ b/src/helpers/MQTTConnectionPolicy.h @@ -0,0 +1,195 @@ +#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 + +// 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 0deb42ba..2865e6ef 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -100,6 +100,11 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { prefs->alert_wifi_minutes = 30; prefs->alert_mqtt_minutes = 240; prefs->alert_min_interval_min = 60; + + // Neighbors publishing defaults off; a defaulted tail is a valid 24h interval + // (not 0) so an in-lineage upgrade from a pre-neighbors payload is sane. + prefs->mqtt_neighbors_enabled = 0; + prefs->mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; } #endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTLifecycle.h b/src/helpers/MQTTLifecycle.h new file mode 100644 index 00000000..f1c0daee --- /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 e834630b..1389cfeb 100644 --- a/src/helpers/MQTTMessageBuilder.cpp +++ b/src/helpers/MQTTMessageBuilder.cpp @@ -2,6 +2,7 @@ #ifdef WITH_MQTT_BRIDGE +#include "MQTTPayloadBuilder.h" #include #include #include @@ -59,66 +60,11 @@ int MQTTMessageBuilder::buildStatusMessage( int packets_received, const char* repeat ) { - // doc is provided by the caller (heap-allocated DynamicJsonDocument in MQTTBridge), - // keeping this 768-byte scratch space off the MQTT task stack. - doc.clear(); - JsonObject root = doc.to(); - - root["status"] = status; - root["timestamp"] = timestamp; - root["origin"] = origin; - root["origin_id"] = origin_id; - root["model"] = model; - root["firmware_version"] = firmware_version; - root["radio"] = radio; - root["client_version"] = client_version; - if (repeat != nullptr) { - root["repeat"] = repeat; - } - - // Add stats object if any stats are provided - if (battery_mv >= 0 || uptime_secs >= 0 || errors >= 0 || queue_len >= 0 || - noise_floor > -999 || tx_air_secs >= 0 || rx_air_secs >= 0 || recv_errors >= 0 || - internal_heap >= 0 || packets_sent >= 0 || packets_received >= 0) { - JsonObject stats = root.createNestedObject("stats"); - - if (battery_mv >= 0) { - stats["battery_mv"] = battery_mv; - } - if (uptime_secs >= 0) { - stats["uptime_secs"] = uptime_secs; - } - if (packets_sent >= 0) { - stats["packets_sent"] = packets_sent; - } - if (packets_received >= 0) { - stats["packets_received"] = packets_received; - } - if (errors >= 0) { - stats["errors"] = errors; - } - if (queue_len >= 0) { - stats["queue_len"] = queue_len; - } - if (noise_floor > -999) { - stats["noise_floor"] = noise_floor; - } - if (tx_air_secs >= 0) { - stats["tx_air_secs"] = tx_air_secs; - } - if (rx_air_secs >= 0) { - stats["rx_air_secs"] = rx_air_secs; - } - if (recv_errors >= 0) { - stats["recv_errors"] = recv_errors; - } - if (internal_heap >= 0) { - stats["internal_heap"] = internal_heap; - } - } - - size_t len = serializeJson(root, buffer, buffer_size); - return (len > 0 && len < buffer_size) ? len : 0; + return MQTTPayloadBuilder::buildStatusMessage( + doc, origin, origin_id, model, firmware_version, radio, client_version, + status, timestamp, buffer, buffer_size, battery_mv, uptime_secs, errors, + queue_len, noise_floor, tx_air_secs, rx_air_secs, recv_errors, internal_heap, + packets_sent, packets_received, repeat); } int MQTTMessageBuilder::buildPacketMessage( @@ -144,71 +90,10 @@ int MQTTMessageBuilder::buildPacketMessage( char* buffer, size_t buffer_size ) { - // doc is provided by the caller (heap-allocated DynamicJsonDocument in MQTTBridge), - // keeping this 2048-byte scratch space off the MQTT task stack. - doc.clear(); - JsonObject root = doc.to(); - - // Format numeric values as strings to avoid String object allocations - char len_str[16]; - char packet_type_str[16]; - char payload_len_str[16]; - char snr_str[16]; - char rssi_str[16]; - char score_str[16]; - - snprintf(len_str, sizeof(len_str), "%d", len); - snprintf(packet_type_str, sizeof(packet_type_str), "%d", packet_type); - snprintf(payload_len_str, sizeof(payload_len_str), "%d", payload_len); - snprintf(snr_str, sizeof(snr_str), "%.1f", snr); - snprintf(rssi_str, sizeof(rssi_str), "%d", rssi); - - root["timestamp"] = timestamp; - root["hash"] = hash; - root["origin"] = origin; - root["type"] = "PACKET"; - root["direction"] = direction; - root["time"] = time; - root["date"] = date; - root["len"] = len_str; - root["packet_type"] = packet_type_str; - root["route"] = route; - root["payload_len"] = payload_len_str; - root["raw"] = raw; - root["origin_id"] = origin_id; - // SNR and RSSI are only meaningful for RX packets (received from radio) - if (strcmp(direction, "rx") == 0) { - root["SNR"] = snr_str; - root["RSSI"] = rssi_str; - // Firmware's rebroadcast "score" for this RX packet, scaled x1000 to match the - // integer form printed in the serial RX log (see Dispatcher::checkRecv()). - if (!isnan(score)) { - snprintf(score_str, sizeof(score_str), "%d", (int)(score * 1000)); - root["score"] = score_str; - } - } - - // Routing path as an array of lowercase hex hop tokens, one element per hop - // (e.g. ["aa","bb","cc"], or ["aaaa","bbbb"] for multi-byte hashes). This matches - // meshcore-packet-capture's _split_path_hops() representation. - if (path_bytes && path_hop_count > 0 && path_hash_size > 0) { - JsonArray path_arr = root.createNestedArray("path"); - char hop_hex[2 * 4 + 1]; // hop hash is 1-4 bytes -> up to 8 hex chars + null - for (int i = 0; i < path_hop_count; i++) { - size_t pos = 0; - for (int b = 0; b < path_hash_size && b < 4; b++) { - size_t idx = (size_t)i * path_hash_size + b; - if (idx >= MAX_PATH_SIZE) break; - snprintf(hop_hex + pos, 3, "%02x", path_bytes[idx]); - pos += 2; - } - hop_hex[pos] = '\0'; - path_arr.add(hop_hex); // char[] (non-const) -> ArduinoJson copies the string - } - } - - size_t json_len = serializeJson(root, buffer, buffer_size); - return (json_len > 0 && json_len < buffer_size) ? json_len : 0; + return MQTTPayloadBuilder::buildPacketMessage( + doc, origin, origin_id, timestamp, direction, time, date, len, packet_type, + route, payload_len, raw, snr, rssi, score, hash, path_bytes, path_hop_count, + path_hash_size, MAX_PATH_SIZE, buffer, buffer_size); } int MQTTMessageBuilder::buildRawMessage( @@ -219,18 +104,24 @@ int MQTTMessageBuilder::buildRawMessage( char* buffer, size_t buffer_size ) { - // Use StaticJsonDocument to avoid heap fragmentation (fixed-size stack allocation) - StaticJsonDocument<512> doc; - JsonObject root = doc.to(); - - root["origin"] = origin; - root["origin_id"] = origin_id; - root["timestamp"] = timestamp; - root["type"] = "RAW"; - root["data"] = raw; - - size_t len = serializeJson(root, buffer, buffer_size); - return (len > 0 && len < buffer_size) ? len : 0; + return MQTTPayloadBuilder::buildRawMessage( + origin, origin_id, timestamp, raw, buffer, buffer_size); +} + +int MQTTMessageBuilder::buildNeighborsMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const NeighborsMessageEntry* neighbors, + int neighbor_count, + char* buffer, + size_t buffer_size +) { + return MQTTPayloadBuilder::buildNeighborsMessage( + doc, origin, origin_id, timestamp, self_scopes, neighbors, neighbor_count, + buffer, buffer_size); } int MQTTMessageBuilder::buildPacketJSON( @@ -421,6 +312,11 @@ const char* MQTTMessageBuilder::getRouteTypeString(int route_type) { } void MQTTMessageBuilder::bytesToHex(const uint8_t* data, size_t len, char* hex, size_t hex_size) { + if (hex == nullptr || hex_size == 0) return; + // Guarantee a valid (empty) string even if we bail out below, so a caller's + // uninitialized stack buffer is never serialized into the JSON raw/hash fields + // when the buffer is too small (A6). + hex[0] = '\0'; if (hex_size < len * 2 + 1) return; // Nibble lookup instead of a per-byte snprintf("%02X"): same uppercase hex @@ -434,6 +330,11 @@ void MQTTMessageBuilder::bytesToHex(const uint8_t* data, size_t len, char* hex, } void MQTTMessageBuilder::packetToHex(mesh::Packet* packet, char* hex, size_t hex_size) { + if (hex == nullptr || hex_size == 0) return; + // Empty string on any early-out below (serialization returned nothing, or the + // hex buffer is too small) so an uninitialized raw_hex[] never reaches the + // published JSON (A6). + hex[0] = '\0'; // Serialize full on-air/wire format using Packet::writeTo() // This includes header, transport codes (if present), path_len, path, and payload uint8_t raw_buf[512]; diff --git a/src/helpers/MQTTMessageBuilder.h b/src/helpers/MQTTMessageBuilder.h index 39128996..69c943b3 100644 --- a/src/helpers/MQTTMessageBuilder.h +++ b/src/helpers/MQTTMessageBuilder.h @@ -4,6 +4,7 @@ #include "MeshCore.h" #include +#include "MQTTPayloadBuilder.h" #include #include @@ -155,6 +156,22 @@ public: size_t buffer_size ); + // Neighbors table entry + JSON builder. The layout logic lives in the pure, + // host-tested MQTTPayloadBuilder; this is the firmware-facing alias/delegate, + // matching the status/packet/raw builders. + using NeighborsMessageEntry = MQTTPayloadBuilder::NeighborsMessageEntry; + static int buildNeighborsMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const NeighborsMessageEntry* neighbors, + int neighbor_count, + char* buffer, + size_t buffer_size + ); + /** * Convert packet to JSON message * diff --git a/src/helpers/MQTTObserverValidation.h b/src/helpers/MQTTObserverValidation.h new file mode 100644 index 00000000..2042f226 --- /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/MQTTPacketQueuePolicy.h b/src/helpers/MQTTPacketQueuePolicy.h new file mode 100644 index 00000000..377ebc5b --- /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 00000000..5a40e502 --- /dev/null +++ b/src/helpers/MQTTPayloadBuilder.cpp @@ -0,0 +1,231 @@ +#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); +} + +int MQTTPayloadBuilder::buildNeighborsMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const NeighborsMessageEntry* neighbors, + int neighbor_count, + char* buffer, + size_t buffer_size +) { + if (!buffer || buffer_size == 0) return 0; + + doc.clear(); + JsonObject root = doc.to(); + root["timestamp"] = timestamp; + root["origin"] = origin; + root["origin_id"] = origin_id; + + JsonObject self = root["self"].to(); + self["scopes"] = self_scopes ? self_scopes : ""; + + JsonArray arr = root["neighbors"].to(); + if (measureJson(root) >= buffer_size) return 0; + + for (int i = 0; i < neighbor_count; i++) { + JsonObject nb = arr.add(); + nb["pubkey"] = neighbors[i].pubkey_hex; + nb["snr"] = neighbors[i].snr; + nb["heard_secs_ago"] = neighbors[i].heard_secs_ago; + nb["scopes"] = neighbors[i].scopes ? neighbors[i].scopes : ""; + nb["status"] = neighbors[i].status; + + // Entries arrive ordered most- to least-useful. Stop as soon as the next + // one would fill the fixed publish buffer, dropping the remaining tail so + // document growth stays bounded. + if (measureJson(root) >= buffer_size) { + arr.remove(arr.size() - 1); + break; + } + } + + return serializeComplete(root, buffer, buffer_size); +} diff --git a/src/helpers/MQTTPayloadBuilder.h b/src/helpers/MQTTPayloadBuilder.h new file mode 100644 index 00000000..931601d5 --- /dev/null +++ b/src/helpers/MQTTPayloadBuilder.h @@ -0,0 +1,94 @@ +#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; + }; + + // Build neighbors-table JSON for the meshcore/{iata}/{device}/neighbors topic. + // Callers order entries most- to least-useful; document growth is bounded to + // buffer_size and the remaining tail is dropped once the next entry won't fit. + static int buildNeighborsMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const NeighborsMessageEntry* neighbors, + int neighbor_count, + char* buffer, + size_t buffer_size + ); +}; diff --git a/src/helpers/MQTTPrefs.h b/src/helpers/MQTTPrefs.h index fd29f01c..caca83fa 100644 --- a/src/helpers/MQTTPrefs.h +++ b/src/helpers/MQTTPrefs.h @@ -2,66 +2,6 @@ #ifdef WITH_MQTT_BRIDGE -#include -#include - -// MQTT preferences are kept separate from role-specific NodePrefs. Companion -// and infrastructure roles intentionally use different NodePrefs layouts, but -// they can safely share this MQTT configuration structure. -struct MQTTPrefs { - char mqtt_origin[32]; - char mqtt_iata[8]; - uint8_t mqtt_status_enabled; - uint8_t mqtt_packets_enabled; - uint8_t mqtt_raw_enabled; - uint8_t mqtt_tx_enabled; - uint32_t mqtt_status_interval; - - char wifi_ssid[32]; - char wifi_password[64]; - uint8_t wifi_power_save; - - char timezone_string[32]; - int8_t timezone_offset; - - char mqtt_slot_preset[MAX_MQTT_SLOTS][24]; - char mqtt_slot_host[MAX_MQTT_SLOTS][64]; - uint16_t mqtt_slot_port[MAX_MQTT_SLOTS]; - char mqtt_slot_username[MAX_MQTT_SLOTS][32]; - char mqtt_slot_password[MAX_MQTT_SLOTS][64]; - - char mqtt_owner_public_key[65]; - char mqtt_email[64]; - - char mqtt_slot_token[MAX_MQTT_SLOTS][48]; - char mqtt_slot_topic[MAX_MQTT_SLOTS][96]; - char mqtt_slot_audience[MAX_MQTT_SLOTS][64]; - - uint8_t mqtt_rx_enabled; - char mqtt_ntp_server[64]; - - uint8_t snmp_enabled; - char snmp_community[24]; - uint8_t radio_watchdog_minutes; - uint8_t alert_enabled; - char alert_psk_hex[33]; - uint16_t alert_wifi_minutes; - uint16_t alert_mqtt_minutes; - uint16_t alert_min_interval_min; - char alert_hashtag[24]; - char alert_region[31]; -}; - -// /mqtt_prefs is written with an 8-byte header so the format is -// self-describing. This is also used by the companion NVS wrapper to reject -// incompatible payloads cleanly. -static const uint8_t MQTT_PREFS_MAGIC[4] = {0xF5, 'M', 'Q', 'P'}; -static const uint16_t MQTT_PREFS_VERSION = 1; - -struct MQTTPrefsHeader { - uint8_t magic[4]; - uint16_t version; - uint16_t payload_len; -}; +#include #endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPrefsAtomicStore.h b/src/helpers/MQTTPrefsAtomicStore.h new file mode 100644 index 00000000..be77bf99 --- /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 00000000..715d33f2 --- /dev/null +++ b/src/helpers/MQTTPrefsCodec.h @@ -0,0 +1,446 @@ +#pragma once + +#include + +#include "MQTTPrefsStorage.h" + +#ifdef WITH_MQTT_BRIDGE + +// Pure /mqtt_prefs format classification and field-copy migration. Production +// reads directly into the selected layout; this header never requires a second +// large staging buffer. +namespace MQTTPrefsCodec { + +enum class Source : uint8_t { + Defaults, + Current, + LegacyPreSlot, + LegacyThreeSlotBase, + LegacyThreeSlot, + LegacySixSlotBase, + LegacySixSlotAudience, + LegacySixSlotAudienceRx, + LegacySixSlot, + UnsupportedVersion, + Corrupt, +}; + +struct DecodePlan { + Source source; + bool rewrite_legacy; + bool preserve_file; + // False means the decoded payload stops before snmp_enabled, so production + // may apply a captured observer tail from legacy /com_prefs. + bool observer_fields_present; + size_t payload_len; +}; + +static const size_t kV1PreObserverPayloadSize = MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE; +static const size_t kV1PreNeighborsPayloadSize = MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE; +static const size_t kV1BaselinePayloadSize = MQTT_PREFS_V1_FULL_PAYLOAD_SIZE; +static const size_t kEncodedSize = sizeof(MQTTPrefsHeader) + kV1BaselinePayloadSize; + +inline MQTTPrefsHeader makeHeader() { + MQTTPrefsHeader header; + memcpy(header.magic, MQTT_PREFS_MAGIC, sizeof(header.magic)); + header.version = MQTT_PREFS_VERSION; + header.payload_len = static_cast(kV1BaselinePayloadSize); + return header; +} + +inline size_t encode(const MQTTPrefs& prefs, uint8_t* output, size_t output_size) { + if (output == nullptr || output_size < kEncodedSize) return 0; + const MQTTPrefsHeader header = makeHeader(); + memcpy(output, &header, sizeof(header)); + memcpy(output + sizeof(header), &prefs, sizeof(prefs)); + return kEncodedSize; +} + +inline bool isMagicPrefix(const uint8_t* input, size_t available) { + if (input == nullptr || available == 0) return false; + const size_t compare_len = available < sizeof(MQTT_PREFS_MAGIC) + ? available : sizeof(MQTT_PREFS_MAGIC); + return memcmp(input, MQTT_PREFS_MAGIC, compare_len) == 0; +} + +inline DecodePlan corruptPlan() { + return {Source::Corrupt, false, true, false, 0}; +} + +// Classify from the first eight bytes and the filesystem-reported file size. +// Headerless layouts are an explicit, audited whitelist. Call +// isPlausibleLegacy() after reading the selected layout and before rewriting: +// size alone cannot distinguish a valid legacy payload from arbitrary bytes. +inline DecodePlan classify(const uint8_t* prefix, size_t prefix_read, size_t file_size) { + if (file_size == 0) return corruptPlan(); + if (prefix == nullptr || prefix_read == 0) return corruptPlan(); + const size_t expected_prefix = file_size < sizeof(MQTTPrefsHeader) + ? file_size : sizeof(MQTTPrefsHeader); + if (prefix_read < expected_prefix) return corruptPlan(); + + if (file_size < sizeof(MQTTPrefsHeader) && isMagicPrefix(prefix, prefix_read)) { + return corruptPlan(); + } + if (file_size >= sizeof(MQTTPrefsHeader)) { + MQTTPrefsHeader header; + memcpy(&header, prefix, sizeof(header)); + if (memcmp(header.magic, MQTT_PREFS_MAGIC, sizeof(header.magic)) == 0) { + if (header.version != MQTT_PREFS_VERSION) { + return {Source::UnsupportedVersion, false, true, false, 0}; + } + const size_t payload_available = file_size - sizeof(header); + if (header.payload_len != payload_available) { + return corruptPlan(); + } + // A same-version append is still unknown to this binary. Holding the + // file prevents a downgrade from discarding it on the next CLI save. + if (header.payload_len > kV1BaselinePayloadSize) { + return {Source::UnsupportedVersion, false, true, false, 0}; + } + if (header.payload_len == kV1BaselinePayloadSize) { + return {Source::Current, false, false, true, kV1BaselinePayloadSize}; + } + if (header.payload_len == kV1PreNeighborsPayloadSize) { + // Written by observer/webconfig firmware before the neighbors tail + // existed. The observer fields ARE present; only the neighbors tail is + // missing, so it loads and keeps its defaults (off / 24h). + return {Source::Current, false, false, true, kV1PreNeighborsPayloadSize}; + } + if (header.payload_len == kV1PreObserverPayloadSize) { + return {Source::Current, false, false, false, kV1PreObserverPayloadSize}; + } + return corruptPlan(); + } + } + + switch (file_size) { + case sizeof(OldMQTTPrefs): + return {Source::LegacyPreSlot, true, false, false, file_size}; + case sizeof(ThreeSlotBaseMQTTPrefs): + return {Source::LegacyThreeSlotBase, true, false, false, file_size}; + case sizeof(ThreeSlotMQTTPrefs): + return {Source::LegacyThreeSlot, true, false, false, file_size}; + case LEGACY6_BASE_SIZE: + return {Source::LegacySixSlotBase, true, false, false, file_size}; + case LEGACY6_AUDIENCE_SIZE: + return {Source::LegacySixSlotAudience, true, false, false, file_size}; + case LEGACY6_AUDIENCE_RX_SIZE: + return {Source::LegacySixSlotAudienceRx, true, false, false, file_size}; + case sizeof(Legacy6SlotMQTTPrefs): + return {Source::LegacySixSlot, true, false, false, file_size}; + default: + return corruptPlan(); + } +} + +inline bool looksLikePreWifiPower(const uint8_t* input, size_t size) { + if (input == nullptr || size != sizeof(OldMQTTPrefs)) return false; + // At byte 144 the newer layout has wifi_power_save (0..2); the older + // layout has timezone_string[0]. A non-empty timezone is unambiguous. + if (input[144] > 2) return true; + // With an empty timezone, byte 177 is the older mqtt_server[0] but the + // newer timezone_offset. If it cannot be an offset, it is also unambiguous. + const int8_t newer_offset = static_cast(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 00000000..7534678e --- /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 00000000..4d13e830 --- /dev/null +++ b/src/helpers/MQTTPrefsStorage.h @@ -0,0 +1,299 @@ +#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; +}; + +// Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose +// signed-delta comparison requires intervals below INT32_MAX ms. The 336h +// (two-week) cap stays comfortably inside that range. +static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_HOURS = 12; +static const uint32_t MQTT_NEIGHBORS_MAX_INTERVAL_HOURS = 336; +static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS = 24; +static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_MS = MQTT_NEIGHBORS_MIN_INTERVAL_HOURS * 3600000UL; +static const uint32_t MQTT_NEIGHBORS_MAX_INTERVAL_MS = MQTT_NEIGHBORS_MAX_INTERVAL_HOURS * 3600000UL; +static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS = MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS * 3600000UL; + +// Version-1 has three payload layouts this firmware can decode. Never infer a +// compatible payload from an arbitrary shorter size: raw prefs have no checksum. +// - PRE_OBSERVER (2736): stops before the observer tail (snmp_*/alert_*). +// - PRE_NEIGHBORS (2860): full observer tail, no neighbors fields yet. +// - FULL (2864): current baseline, with the neighbors tail. +static const size_t MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE = 2736; +static const size_t MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE = 2860; +static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2864; + +// /mqtt_prefs starts with a self-describing 8-byte header. Headerless files +// are deployed legacy layouts and continue to be distinguished by size. +static const uint8_t MQTT_PREFS_MAGIC[4] = {0xF5, 'M', 'Q', 'P'}; +static const uint16_t MQTT_PREFS_VERSION = 1; + +struct MQTTPrefsHeader { + uint8_t magic[4]; + uint16_t version; + uint16_t payload_len; +}; + +// 3-slot MQTTPrefs layout. Array dimensions changed in the current format, so +// it must be field-copied rather than read into MQTTPrefs directly. +struct ThreeSlotMQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + char wifi_ssid[32]; + char wifi_password[64]; + uint8_t wifi_power_save; + char timezone_string[32]; + int8_t timezone_offset; + char mqtt_slot_preset[3][24]; + char mqtt_slot_host[3][64]; + uint16_t mqtt_slot_port[3]; + char mqtt_slot_username[3][32]; + char mqtt_slot_password[3][64]; + char mqtt_owner_public_key[65]; + char mqtt_email[64]; + uint8_t _legacy_analyzer_us_enabled; + uint8_t _legacy_analyzer_eu_enabled; + char _legacy_mqtt_server[64]; + uint16_t _legacy_mqtt_port; + char _legacy_mqtt_username[32]; + char _legacy_mqtt_password[64]; + char mqtt_slot_token[3][48]; + char mqtt_slot_topic[3][96]; +}; + +// The earlier 3-slot format preceded token/topic fields. +struct ThreeSlotBaseMQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + char wifi_ssid[32]; + char wifi_password[64]; + uint8_t wifi_power_save; + char timezone_string[32]; + int8_t timezone_offset; + char mqtt_slot_preset[3][24]; + char mqtt_slot_host[3][64]; + uint16_t mqtt_slot_port[3]; + char mqtt_slot_username[3][32]; + char mqtt_slot_password[3][64]; + char mqtt_owner_public_key[65]; + char mqtt_email[64]; + uint8_t _legacy_analyzer_us_enabled; + uint8_t _legacy_analyzer_eu_enabled; + char _legacy_mqtt_server[64]; + uint16_t _legacy_mqtt_port; + char _legacy_mqtt_username[32]; + char _legacy_mqtt_password[64]; +}; + +// Headerless 6-slot layout shipped to the deployed flex fleet. It retains the +// removed `_legacy_*` block, so it too is field-copied into MQTTPrefs. +struct Legacy6SlotMQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + char wifi_ssid[32]; + char wifi_password[64]; + uint8_t wifi_power_save; + char timezone_string[32]; + int8_t timezone_offset; + char mqtt_slot_preset[MQTT_PREFS_SLOT_COUNT][24]; + char mqtt_slot_host[MQTT_PREFS_SLOT_COUNT][64]; + uint16_t mqtt_slot_port[MQTT_PREFS_SLOT_COUNT]; + char mqtt_slot_username[MQTT_PREFS_SLOT_COUNT][32]; + char mqtt_slot_password[MQTT_PREFS_SLOT_COUNT][64]; + char mqtt_owner_public_key[65]; + char mqtt_email[64]; + uint8_t _legacy_analyzer_us_enabled; + uint8_t _legacy_analyzer_eu_enabled; + char _legacy_mqtt_server[64]; + uint16_t _legacy_mqtt_port; + char _legacy_mqtt_username[32]; + char _legacy_mqtt_password[64]; + char mqtt_slot_token[MQTT_PREFS_SLOT_COUNT][48]; + char mqtt_slot_topic[MQTT_PREFS_SLOT_COUNT][96]; + char mqtt_slot_audience[MQTT_PREFS_SLOT_COUNT][64]; + uint8_t mqtt_rx_enabled; + char mqtt_ntp_server[64]; +}; + +// Historical headerless 6-slot variants. They share a common prefix but only +// later files contain the appended audience, RX, and NTP fields. +static const size_t LEGACY6_BASE_SIZE = 2452; +static const size_t LEGACY6_AUDIENCE_SIZE = 2836; +static const size_t LEGACY6_AUDIENCE_RX_SIZE = 2840; + +// Frozen on-flash layouts; every firmware and native fixture build checks them. +static_assert(sizeof(MQTTPrefsHeader) == 8, "versioned /mqtt_prefs header must stay 8 bytes"); +static_assert(offsetof(MQTTPrefs, snmp_enabled) == MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE, + "v1 pre-observer /mqtt_prefs boundary changed"); +static_assert(sizeof(MQTTPrefs) == MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, + "v1 /mqtt_prefs payload layout changed"); +// Lock the neighbors tail to the flex neighbors build's layout so a /mqtt_prefs +// written by either firmware is byte-for-byte interchangeable. The enable flag +// lands in the old struct's zeroed trailing padding (offset 2857), and the +// interval begins exactly at the pre-neighbors payload size (2860) so a +// pre-neighbors read stops right before it and the interval keeps its default. +static_assert(offsetof(MQTTPrefs, mqtt_neighbors_enabled) == 2857, + "neighbors enable flag must sit at the flex-compatible offset"); +static_assert(offsetof(MQTTPrefs, mqtt_neighbors_interval) == MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE, + "neighbors interval offset must equal the pre-neighbors payload size"); +static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed"); +static_assert(sizeof(PreWifiPowerOldMQTTPrefs) == 472, "frozen pre-WiFi-power /mqtt_prefs layout changed"); +static_assert(offsetof(OldMQTTPrefs, wifi_power_save) == 144, + "frozen post-WiFi-power discriminator offset changed"); +static_assert(offsetof(OldMQTTPrefs, timezone_string) == 145, + "frozen post-WiFi-power timezone offset changed"); +static_assert(offsetof(OldMQTTPrefs, timezone_offset) == 177, + "frozen post-WiFi-power UTC offset changed"); +static_assert(offsetof(OldMQTTPrefs, mqtt_server) == 178, + "frozen post-WiFi-power server offset changed"); +static_assert(offsetof(PreWifiPowerOldMQTTPrefs, timezone_string) == 144, + "frozen pre-WiFi-power timezone offset changed"); +static_assert(offsetof(PreWifiPowerOldMQTTPrefs, timezone_offset) == 176, + "frozen pre-WiFi-power UTC offset changed"); +static_assert(offsetof(PreWifiPowerOldMQTTPrefs, mqtt_server) == 177, + "frozen pre-WiFi-power server offset changed"); +static_assert(sizeof(ThreeSlotBaseMQTTPrefs) == 1032, "frozen early 3-slot /mqtt_prefs layout changed"); +static_assert(sizeof(ThreeSlotMQTTPrefs) == 1464, "frozen 3-slot /mqtt_prefs layout changed"); +static_assert(offsetof(ThreeSlotMQTTPrefs, mqtt_slot_token) == 1030, + "frozen 3-slot token offset changed"); +static_assert(offsetof(ThreeSlotMQTTPrefs, mqtt_slot_topic) == 1174, + "frozen 3-slot topic offset changed"); +static_assert(offsetof(Legacy6SlotMQTTPrefs, mqtt_slot_audience) == LEGACY6_BASE_SIZE, + "frozen early 6-slot /mqtt_prefs prefix changed"); +static_assert(offsetof(Legacy6SlotMQTTPrefs, mqtt_rx_enabled) == LEGACY6_AUDIENCE_SIZE, + "frozen audience 6-slot /mqtt_prefs prefix changed"); +static_assert(offsetof(Legacy6SlotMQTTPrefs, mqtt_ntp_server) == 2837, + "frozen RX 6-slot /mqtt_prefs prefix changed"); +static_assert(sizeof(Legacy6SlotMQTTPrefs) == 2904, "frozen deployed-fleet /mqtt_prefs layout changed"); + +#endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPresets.h b/src/helpers/MQTTPresets.h index 8b5fdbff..b0689b8c 100644 --- a/src/helpers/MQTTPresets.h +++ b/src/helpers/MQTTPresets.h @@ -1,5 +1,6 @@ #pragma once +#include #include // Maximum number of configurable MQTT connection slots (available to all builds for struct layout). @@ -49,14 +50,36 @@ struct MQTTPresetDef { const char* userpass_password; // MQTT_AUTH_USERPASS: embedded password, or nullptr to use mqttN.password }; -// True when preset uses MQTT_AUTH_USERPASS but credentials come from slot prefs (mqttN.username/password). -static inline bool mqttPresetNeedsSlotCredentials(const MQTTPresetDef* preset) { +// Sentinel: resolve MQTT username from device public-key hex at connect time. +// Braces match topic placeholders ({device}/{iata}); never send this string to the broker. +static const char MQTT_USERPASS_USERNAME_PUBKEY[] = "{pubkey}"; + +static inline bool mqttPresetUsesDevicePubkeyUsername(const MQTTPresetDef* preset) { return preset && preset->auth_type == MQTT_AUTH_USERPASS && - (!preset->userpass_username || !preset->userpass_password); + preset->userpass_username && + strcmp(preset->userpass_username, MQTT_USERPASS_USERNAME_PUBKEY) == 0; +} + +// True when USERPASS username must come from mqttN.username (null embedded username). +// "{pubkey}" is an embedded sentinel, so it does not need a slot username. +static inline bool mqttPresetNeedsSlotUsername(const MQTTPresetDef* preset) { + return preset && preset->auth_type == MQTT_AUTH_USERPASS && + !preset->userpass_username; +} + +// True when USERPASS password must come from mqttN.password (null embedded password). +static inline bool mqttPresetNeedsSlotPassword(const MQTTPresetDef* preset) { + return preset && preset->auth_type == MQTT_AUTH_USERPASS && + !preset->userpass_password; +} + +// True when preset uses MQTT_AUTH_USERPASS but at least one credential comes from slot prefs. +static inline bool mqttPresetNeedsSlotCredentials(const MQTTPresetDef* preset) { + return mqttPresetNeedsSlotUsername(preset) || mqttPresetNeedsSlotPassword(preset); } // Number of built-in presets -static const int MQTT_PRESET_COUNT = 25; +static const int MQTT_PRESET_COUNT = 29; // Keep the certificate and preset tables in one translation unit. Defining // these as header-local constants created a complete flash copy in every MQTT @@ -123,12 +146,17 @@ extern const char ISRG_ROOT_X1[] PROGMEM = // Built-in preset definitions (stored in flash) extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = { - // name url server rootCA auth topic keepalive tls enabled interval user pass + // name url audience rootCA auth topic tokenLife retain keepAlive user pass { "analyzer-us", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", "mqtt-us-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "analyzer-eu", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", "mqtt-eu-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "nz-analyzer", "wss://meshcore-mqtt-1.baird.io:443", "meshcore-mqtt-1.baird.io", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "meshmapper", "wss://mqtt.meshmapper.net:443/mqtt", "mqtt.meshmapper.net", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "meshrank", "mqtts://meshrank.net:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHRANK, 0, false, 0, nullptr, nullptr }, + // waev token_lifetime is 3300 (55 min) on purpose: the broker's real JWT TTL is + // 60 min, and claiming less keeps fresh tokens accepted even with ~5 min of fast + // device-clock skew (and off any exp-iat<=3600 boundary strictness). Do NOT + // "fix" this to 3600 -- the renewal race is handled separately by + // tokenRenewalBufferSecs() in MQTTBridge, which renews another 5 min earlier. { "waev", "wss://mqtt.waev.app:443/mqtt", "mqtt.waev.app", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 3300, false, 55, nullptr, nullptr }, { "meshomatic", "wss://us-east.meshomatic.net:443/mqtt", "us-east.meshomatic.net", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "cascadiamesh", "wss://mqtt-v1.cascadiamesh.org:443/mqtt", "mqtt-v1.cascadiamesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, @@ -138,7 +166,7 @@ extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = { { "chimesh", "wss://mqtt.chimesh.org:443", "mqtt.chimesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "meshat.se", "wss://meshcore-mqtt.meshat.se:443", "meshcore-mqtt.meshat.se", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "eastidahomesh", "wss://broker.eastidahomesh.net:443", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, - { "coloradomesh", "wss://mqtt.meshcore.coloradomesh.org:1883","mqtt.meshcore.coloradomesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "coloradomesh", "wss://mqtt.meshcore.coloradomesh.org:443","mqtt.meshcore.coloradomesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "dutchmeshcore-1", "wss://collector1.dutchmeshcore.nl:443/mqtt", "collector1.dutchmeshcore.nl", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "dutchmeshcore-2", "wss://collector2.dutchmeshcore.nl:443/mqtt", "collector2.dutchmeshcore.nl", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "meshcore-ca-1", "wss://mqtt1.meshcore.ca:443/mqtt", "mqtt1.meshcore.ca", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, @@ -149,6 +177,12 @@ extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = { { "rflab", "wss://mqtt.rflab.io:443", "mqtt.rflab.io", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "ipnt.uk", "wss://mqtt.ipnt.uk:443", "mqtt.ipnt.uk", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "flmesh", "wss://mcmqtt.jntconnections.com:443", "mcmqtt.jntconnections.com", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "corecomms", "wss://mqtt.corecomms.net:443/mqtt", "mqtt.corecomms.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "meshtexas", "wss://mqtt.meshtexas.org:443/mqtt", "mqtt.meshtexas.org", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + // Username is device pubkey hex at connect; password from mqttN.password. No TLS. + { "mesh-chaun14", "mqtt://mqtt.mesh.chaun14.fr:1884", nullptr, nullptr, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 60, MQTT_USERPASS_USERNAME_PUBKEY, nullptr }, + // LetsMesh-compatible JWT; TLS is Let's Encrypt (ISRG Root X1), not GTS. + { "wcmesh", "wss://mqtt.wcmesh.com:443", "mqtt.wcmesh.com", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, }; #else diff --git a/src/helpers/MQTTReplyFormat.h b/src/helpers/MQTTReplyFormat.h new file mode 100644 index 00000000..bdb343d7 --- /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 00000000..bb061a9e --- /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 00000000..1340ce0f --- /dev/null +++ b/src/helpers/MQTTTopicRouter.h @@ -0,0 +1,86 @@ +#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 is deliberately +// packets-only; status, raw, and neighbors are unsupported by the current +// broker contract (the type != PACKETS guard below rejects them all). +// MeshCore routes require a configured IATA and device id. Custom templates may +// omit either placeholder, so their individual values are allowed to be empty. +static inline bool mqttBuildPublicationTopic(MQTTTopicRouteStyle style, int type, + const char* custom_template, + const char* iata, const char* device, + const char* token, + char* buf, size_t buf_size) { + if (!buf || buf_size == 0) return false; + buf[0] = '\0'; + + const char* type_name = mqttPublicationTypeName(type); + if (!type_name) return false; + + switch (style) { + case MQTT_ROUTE_MESHCORE: + if (!mqttIataValid(iata) || strcmp(iata, "XXX") == 0 || !device || device[0] == '\0') { + return false; + } + return mqttWriteTopic(buf, buf_size, "meshcore/%s/%s/%s", iata, device, type_name); + + case MQTT_ROUTE_MESHRANK: + if (type != MQTT_PUBLICATION_PACKETS || !token || token[0] == '\0' || + !device || device[0] == '\0') { + return false; + } + return mqttWriteTopic(buf, buf_size, "meshrank/uplink/%s/%s/%s", + token, device, type_name); + + case MQTT_ROUTE_CUSTOM: + return mqttSubstituteTopic(custom_template, iata, device, token, type_name, + buf, buf_size); + + default: + return false; + } +} diff --git a/src/helpers/MQTTTopicTemplate.h b/src/helpers/MQTTTopicTemplate.h new file mode 100644 index 00000000..e0af4d76 --- /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/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 17997d34..df8d70de 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -514,8 +514,9 @@ bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) { return false; } -bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { +bool NRF52Board::startOTAUpdate(const char *id, char reply[], bool force_ap) { (void)id; + (void)force_ap; if (ota_active) { format_ota_reply(reply); diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index d2d45496..30a30f21 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -65,7 +65,7 @@ public: virtual void shutdownPeripherals(); virtual void powerOff() override; virtual bool getBootloaderVersion(char* version, size_t max_len) override; - virtual bool startOTAUpdate(const char *id, char reply[]) override; + virtual bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override; virtual bool stopOTAUpdate(char reply[]) override; virtual void sleep(uint32_t secs) override; bool isExternalPowered() override; diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 8e85cf5f..d8364620 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -7,6 +7,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) diff --git a/src/helpers/WebConfigBatch.h b/src/helpers/WebConfigBatch.h new file mode 100644 index 00000000..1d49ca87 --- /dev/null +++ b/src/helpers/WebConfigBatch.h @@ -0,0 +1,205 @@ +#pragma once + +#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); +} + +// -------------------------------------------------------------------------- +// 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 00000000..a03a034d --- /dev/null +++ b/src/helpers/WebConfigKeys.h @@ -0,0 +1,98 @@ +#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", "radio.fem.rxgain", "repeat", + "advert.interval", "flood.advert.interval", + "flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect", + // MQTTPrefs (WiFi / MQTT / misc observer) + "wifi.ssid", "wifi.pwd", "wifi.powersave", + "mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw", + "mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.neighbors", "mqtt.neighbors.interval", + "mqtt.ntp", "mqtt.owner", "mqtt.email", + "timezone", "timezone.offset", "snmp", "snmp.community", +}; +static const char* const WC_ALLOWED_SLOT_KEYS[] = { + "preset", "server", "port", "username", "password", "token", "topic", "audience", +}; + +// True when `key` is a well-formed per-slot key ("mqttN." 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; +} + +// 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/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 1cb5c5b7..1c05b81a 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1,6 +1,11 @@ #define MQTT_PRESETS_IMPLEMENTATION #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 @@ -212,6 +217,22 @@ unsigned long MQTTBridge::getWifiConnectedAtMillis() { return s_wifi_connected_at; } +#if defined(WITH_MQTT_NEIGHBORS) +// 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, len, "%us", (unsigned)secs); + } +} +#endif + void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs) { if (buf == nullptr || bufsize == 0) return; const char* msgs = (obs && obs->mqtt_status_enabled) ? "on" : "off"; @@ -232,8 +253,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; @@ -256,25 +280,96 @@ 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); + replyAppendf(buf, bufsize, &pos, ", %d: %s (%s)", i + 1, name, state); } - snprintf(buf + pos, bufsize - pos, ", q:%d", q); + 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) { + 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; + default: + formatDuration(when, sizeof(when), + b->_neighbors_secs_until_next.load(std::memory_order_relaxed)); + break; + } + const char* last; + switch (b->_neighbors_last_result.load(std::memory_order_relaxed)) { + case NBR_RESULT_OK: last = "ok"; break; + case NBR_RESULT_FAIL: last = "failed"; break; + default: last = "none"; break; + } + replyAppendf(buf, bufsize, &pos, ", nbr: %s/%s", when, last); + } +#endif } -bool MQTTBridge::getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out) { - if (!out || slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return false; - if (!s_mqtt_bridge_instance || !s_mqtt_bridge_instance->_initialized) return false; +// On-demand publish-health + heap snapshot for the `get mqtt.stats` CLI command. +// Same data as the (MQTT_MEMORY_DEBUG-only) periodic logMemoryStatus() line, but +// returned as a reply instead of logged. Per-slot "sN=ok/err": ok = cumulative +// accepted publishes, err = cumulative failures (socket error / network timeout). +// Outbox should read ~0 (QoS0 publishes synchronously); a rising err isolates a +// broker whose uplink is dropping writes. +void MQTTBridge::formatMqttStatsReply(char* buf, size_t bufsize) { + if (buf == nullptr || bufsize == 0) return; + if (s_mqtt_bridge_instance == nullptr || !s_mqtt_bridge_instance->_initialized) { + snprintf(buf, bufsize, "> (bridge not running)"); + return; + } + MQTTBridge* b = s_mqtt_bridge_instance; + + int q = 0; +#ifdef ESP_PLATFORM + if (b->_packet_queue_handle != nullptr) { + q = (int)uxQueueMessagesWaiting(b->_packet_queue_handle); + } +#else + q = b->_queue_count; +#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(); + outbox_drops += b->_slots[i].client->getOutboxDrops(); + } + } + + // 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); + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (!b->_slots[i].enabled || !b->_slots[i].client) continue; + replyAppendf(buf, bufsize, &pos, " s%d=%lu/%lu", i + 1, + b->_slots[i].client->getPublishOk(), + b->_slots[i].client->getPublishErr()); + } +} + +// 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]; - MQTTBridge* bridge = s_mqtt_bridge_instance; - const MQTTSlot& slot = bridge->_slots[slot_index]; if (!slot.enabled && slot.preset) { out->name = slot.preset->name; out->state = "inactive"; } else if (!slot.enabled) { - return false; + return false; // unconfigured slot } else { - out->name = slot.preset ? slot.preset->name : MQTT_PRESET_CUSTOM; - if (!bridge->isSlotReady(slot_index)) { + out->name = slot.preset ? slot.preset->name : "custom"; + if (!b->isSlotReady(slot_index)) { out->state = "wait"; } else if (slot.connected) { out->state = "ok"; @@ -284,13 +379,23 @@ bool MQTTBridge::getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out) out->state = "disc"; } } - - out->has_publish_counts = false; - out->publish_ok = 0; - out->publish_err = 0; + out->has_publish_counts = true; + out->publish_ok = slot.client ? slot.client->getPublishOk() : 0; + out->publish_err = slot.client ? slot.client->getPublishErr() : 0; return true; } +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 + return 2; +#endif +} + uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; } unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; } @@ -381,18 +486,21 @@ 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). + 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 if (slot.connected && slot.last_error_time == 0) { - snprintf(buf + pos, bufsize - pos, ", no errors"); + replyAppendf(buf, bufsize, &pos, ", no errors"); return; } @@ -402,33 +510,59 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index) 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"); } } +// 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; +} + // --------------------------------------------------------------------------- // Constructor // --------------------------------------------------------------------------- @@ -451,7 +585,13 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs, // 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), @@ -470,6 +610,11 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs, #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); @@ -502,6 +647,7 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs, _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) @@ -514,6 +660,17 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs, _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'; @@ -530,20 +687,65 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs, #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); - #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)); +#if defined(WITH_MQTT_NEIGHBORS) + // Persistent neighbors JSON buffer. Unlike status/packet there is no stack + // fallback: the feature is PSRAM-gated, so a nullptr simply disables publishing + // (requestPublishNeighbors/publishNeighbors both no-op on nullptr). + _neighbors_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( + _neighbors_json_buffer, NEIGHBORS_JSON_BUFFER_SIZE, psram_malloc)); +#endif + MQTT_DEBUG_PRINTLN("Runtime buffers: raw=%s publish=%s status=%s", + _last_raw_data ? "PSRAM" : "unavailable", + _publish_json_buffer ? "PSRAM" : "stack fallback", + _status_json_buffer ? "PSRAM" : "stack fallback"); + #endif +} + +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)); +#if defined(WITH_MQTT_NEIGHBORS) + _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 + #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() // --------------------------------------------------------------------------- @@ -587,6 +789,14 @@ void MQTTBridge::begin() { _ntp_estimate_ok = false; _ntp_estimate_epoch = 0; + // 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 { @@ -619,15 +829,8 @@ void MQTTBridge::begin() { MQTT_DEBUG_PRINTLN("PSRAM: not configured for this board (no BOARD_HAS_PSRAM)"); #endif - // Limit active slots based on available memory. - // Each WSS/TLS connection needs ~40KB for mbedTLS buffers. - // Without PSRAM, even 3 concurrent connections would exhaust internal heap. - // With PSRAM, cap at 5 for safety (6 configurable but 5 active max). - #if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM) - _max_active_slots = psramFound() ? 5 : 2; - #else - _max_active_slots = 2; - #endif + // Limit active slots based on available memory (see getMaxActiveSlots()). + _max_active_slots = getMaxActiveSlots(); MQTT_DEBUG_PRINTLN("Max active slots: %d", _max_active_slots); // Check if WiFi credentials are configured first @@ -636,6 +839,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); @@ -648,7 +855,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; @@ -747,6 +957,7 @@ void MQTTBridge::begin() { psram_free(_packet_queue_storage); #endif _packet_queue_storage = nullptr; + releaseRuntimeBuffers(); return; } @@ -765,6 +976,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", @@ -785,6 +1001,7 @@ void MQTTBridge::begin() { psram_free(_packet_queue_storage); #endif _packet_queue_storage = nullptr; + releaseRuntimeBuffers(); return; } @@ -804,6 +1021,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"); @@ -814,74 +1039,158 @@ 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; - #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() _ntp_estimate_requested = false; _ntp_estimate_done = false; _ntp_estimate_ok = false; _ntp_estimate_epoch = 0; - 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)"); } // --------------------------------------------------------------------------- @@ -970,6 +1279,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(); @@ -988,6 +1313,19 @@ void MQTTBridge::mqttTaskLoop() { #endif unsigned long now = millis(); + + // Periodic heap + publish-health snapshot. Gated behind MQTT_MEMORY_DEBUG (a + // dedicated diagnostics flag, NOT enabled on production or plain MQTT_DEBUG builds) + // so it stays off by default -- the same data is available on demand via the + // `get mqtt.stats` CLI command (formatMqttStatsReply / logMemoryStatus()). + #ifdef MQTT_MEMORY_DEBUG + static unsigned long last_mem_log = 0; + if (now - last_mem_log >= 30000) { + last_mem_log = now; + logMemoryStatus(); + } + #endif + bool wifi_just_connected = handleWiFiConnection(now); if (wifi_just_connected) { // WiFi recovered - reset last_reconnect_attempt for disconnected slots so they @@ -1092,12 +1430,44 @@ 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(); // Process packet queue 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 { + MQTT_DEBUG_PRINTLN("Neighbors publish failed"); + } + _neighbors_publish_pending.store(false, std::memory_order_release); + } +#endif + #ifdef WITH_SNMP // SNMP agent loop - process incoming UDP requests if (_snmp_agent) { @@ -1127,8 +1497,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 @@ -1222,16 +1594,32 @@ void MQTTBridge::initSlotClients() { slot.client->onConnect([this, index](bool sessionPresent) { MQTT_DEBUG_PRINTLN("MQTT%d connected", index + 1); _slots[index].connected = true; - _slots[index].reconnect_backoff = 0; - _slots[index].max_backoff_failures = 0; + // NOTE: reconnect_backoff / max_backoff_failures are NOT reset here. + // A CONNACK alone doesn't prove the link is healthy -- a broker that + // accepts and then drops within seconds would reset the ladder every + // cycle and retry at the 10 s rung forever, and each retry is a full + // TLS session alloc/free (~40 KB of internal-heap churn, a known + // fragmentation driver). The ladder is instead cleared by + // maintainSlotConnection() once the connection has stayed up for + // BACKOFF_STABLE_RESET_MS, so flapping endpoints keep their earned + // backoff level. The breaker itself does clear now: while connected + // the diag/status must not claim the slot gave up, and the next + // disconnect should be governed by the (still-elevated) ladder. + _slots[index].connected_at_ms = millis(); _slots[index].circuit_breaker_tripped = false; _slots[index].last_tls_err = 0; _slots[index].last_tls_stack_err = 0; _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); @@ -1243,6 +1631,7 @@ void MQTTBridge::initSlotClients() { _slots[index].current_outage_started_ms = millis(); } _slots[index].connected = false; + _slots[index].connected_at_ms = 0; // stability clock only runs while connected updateCachedConnectionStatus(); }); slot.client->onError([this, index](esp_mqtt_error_codes error) { @@ -1363,10 +1752,19 @@ void MQTTBridge::setupSlot(int index) { slot.client->setCredentials(_jwt_username, slot.auth_token); } } else if (slot.preset->auth_type == MQTT_AUTH_USERPASS) { - if (slot.preset->userpass_username && slot.preset->userpass_password) { - slot.client->setCredentials(slot.preset->userpass_username, slot.preset->userpass_password); - } else if (strlen(slot.username) > 0) { - slot.client->setCredentials(slot.username, slot.password); + const char* user = nullptr; + const char* pass = slot.preset->userpass_password + ? slot.preset->userpass_password + : slot.password; + if (mqttPresetUsesDevicePubkeyUsername(slot.preset)) { + user = _device_id; // never send "{pubkey}" literally + } else if (slot.preset->userpass_username) { + user = slot.preset->userpass_username; + } else if (slot.username[0] != '\0') { + user = slot.username; + } + if (user && user[0] != '\0' && pass && pass[0] != '\0') { + slot.client->setCredentials(user, pass); } } } else { @@ -1525,8 +1923,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; @@ -1539,8 +1937,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; @@ -1562,7 +1960,19 @@ void MQTTBridge::maintainSlotConnections() { void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted) { MQTTSlot& slot = _slots[index]; - if (slot.connected) { + // Forgive past failures only after the connection has proven stable. + // 2 minutes covers at least one keepalive round-trip (keepalive is 75 s), + // so a link that can't survive a single keepalive period never resets the + // ladder. Flapping endpoints therefore stay at their earned backoff rung + // (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. + if (slot.connected && + (slot.reconnect_backoff != 0 || slot.max_backoff_failures != 0) && + 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; slot.max_backoff_failures = 0; } @@ -1571,20 +1981,20 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns bool slot_uses_jwt = (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) || (!slot.preset && slot.audience[0] != '\0'); if (slot_uses_jwt) { - bool token_needs_renewal = false; - if (!time_synced) { - token_needs_renewal = (slot.token_expires_at == 0); - } else { - const unsigned long RENEWAL_BUFFER = 60; - 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)); - } + // Renew (and below, reconnect) this many seconds before the token's exp + // 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 = 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; @@ -1594,12 +2004,16 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns if (createSlotAuthToken(index)) { MQTT_DEBUG_PRINTLN("MQTT%d token renewed", index + 1); - const unsigned long DISCONNECT_THRESHOLD = 60; + // Bounce the connection while WE control the timing whenever the old + // token is inside the renewal buffer -- waiting for the broker to + // enforce exp mid-session means a FIN plus a trip through the backoff + // ladder instead of one clean reconnect. Same buffer as the renewal + // trigger above, so a renewal implies a proactive reconnect. bool old_token_expired_or_imminent = !time_synced || (old_token_expires_at == 0) || (current_time >= old_token_expires_at) || (time_synced && old_token_expires_at >= 1000000000 && - current_time >= (old_token_expires_at - DISCONNECT_THRESHOLD)); + current_time >= (old_token_expires_at - renewal_buffer)); if (old_token_expired_or_imminent || !slot.client->connected()) { // Disconnect + reconnect with fresh credentials, reusing existing client @@ -1637,11 +2051,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; @@ -1670,24 +2083,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)); @@ -1717,6 +2124,34 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns } } +// Effective JWT lifetime for a slot: the preset's token_lifetime (or the 24 h +// default for custom/audience slots), minus the per-slot expiry stagger that +// keeps multiple JWT slots from renewing/reconnecting simultaneously. This is +// the exact value createSlotAuthToken() puts in the token's exp claim, so the +// 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 = MQTTConnectionPolicy::kDefaultJwtLifetimeSecs; + if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT && slot.preset->token_lifetime > 0) { + base_lifetime = slot.preset->token_lifetime; + } + return MQTTConnectionPolicy::jwtLifetimeSecs( + static_cast(base_lifetime), static_cast(index)); +} + +// How early (seconds before the token's exp claim) to renew the token AND +// proactively bounce the connection with fresh credentials. exp and the +// renewal schedule are locked together (both derive from slotTokenLifetime), +// so this buffer is the ONLY margin between "device re-authenticates" and +// "broker enforces exp and FIN-closes the session mid-stream" -- shortening a +// preset's token_lifetime moves both times together and cannot widen it. +// The old flat 60 s lost that race whenever the device clock ran slow, or a +// single renewal attempt failed (the 60 s renewal throttle then ate the whole +// margin) -- observed on the waev preset, whose 55-minute tokens are the only +// ones short enough for brokers to enforce exp against a live session. +// 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. bool MQTTBridge::createSlotAuthToken(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; MQTTSlot& slot = _slots[index]; @@ -1724,10 +2159,8 @@ bool MQTTBridge::createSlotAuthToken(int index) { // Determine JWT audience: preset takes priority, then custom slot audience field const char* audience = nullptr; - unsigned long base_lifetime = 86400; // default 24h if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) { audience = slot.preset->jwt_audience; - if (slot.preset->token_lifetime > 0) base_lifetime = slot.preset->token_lifetime; } else if (slot.audience[0] != '\0') { audience = slot.audience; } @@ -1757,10 +2190,7 @@ bool MQTTBridge::createSlotAuthToken(int index) { const char* email = (_obs->mqtt_email[0] != '\0') ? _obs->mqtt_email : nullptr; unsigned long current_time = time(nullptr); - // 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); - unsigned long expires_in = base_lifetime - stagger; + unsigned long expires_in = slotTokenLifetime(index); // preset/default lifetime minus per-slot stagger bool time_synced = (current_time >= 1000000000); if (JWTHelper::createAuthToken( @@ -1787,14 +2217,23 @@ bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload return false; } - // QoS 0 for the high-rate packet/raw publish paths: no PUBACK, no outbox store, - // no per-message heap alloc - critical for non-PSRAM fragmentation. QoS 1 is used - // only for low-rate retained status messages where delivery matters. + // Publish path by QoS: + // - QoS 0 (high-rate packets/raw): SYNCHRONOUS (async=false -> esp_mqtt_client_publish), + // which writes straight to the socket. The async/outbox path drains only one queued + // item per esp-mqtt task loop (~1 msg/s/conn, gated by the 1s poll_read), so under + // even light packet load the outbox pins at its cap and drops ~20-30%. A synchronous + // write bypasses that drain ceiling entirely and does not store in the outbox. It can + // block the (Core-0, prio-1) MQTT task on a stalled socket, but only up to + // network_timeout_ms (lowered in optimizeMqttClientConfig); mesh RX (Core 1) and the + // WiFi/TCP stack (higher-prio system tasks) are unaffected, and a failed write flips + // the slot to disconnected so subsequent packets skip it. + // - QoS 1 (low-rate retained status): async, so it keeps the durable outbox + retransmit. // - // esp_mqtt_client_enqueue return convention: QoS 0 returns msg_id == 0 on success - // (no tracking since there's no PUBACK); QoS 1/2 return a positive msg_id. Negative - // values (-1 generic failure, -2 outbox full) are the only actual failures. - int result = slot.client->publish(topic, qos, retained, payload, strlen(payload), true); + // Return convention: QoS 0 sync publish returns msg_id == 0 on success (no PUBACK + // tracking). Negative values (-1 write/failure) are the only actual failures; the queue + // retry/drop path below handles them. + bool async = (qos > 0); + int result = slot.client->publish(topic, qos, retained, payload, strlen(payload), async); if (result < 0) { // QoS0 packet/raw publishes are best-effort and may be retried from the // bridge queue; avoid logging transient first-attempt failures here. @@ -1828,67 +2267,29 @@ bool MQTTBridge::publishToAllSlots(const char* topic, const char* payload, bool // Presets use hardcoded topic logic; custom slots support user-defined templates. // --------------------------------------------------------------------------- bool MQTTBridge::substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size) { - const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw"; - 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: packets only, uses per-slot token in topic path - if (type != MSG_PACKETS) return false; - const char* token = _obs->mqtt_slot_token[index]; - if (!token || token[0] == '\0') return false; - snprintf(topic_buf, buf_size, "meshrank/uplink/%s/%s/packets", token, _device_id); - return true; - } - // MQTT_TOPIC_MESHCORE (default for all other presets) - if (!isIATAValid()) return false; - const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw"; - snprintf(topic_buf, buf_size, "meshcore/%s/%s/%s", _iata, _device_id, type_str); - 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 @@ -1896,10 +2297,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; - const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw"; - snprintf(topic_buf, buf_size, "meshcore/%s/%s/%s", _iata, _device_id, type_str); - 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) { @@ -1916,7 +2316,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; @@ -1985,7 +2388,12 @@ void MQTTBridge::publishStatusToSlot(int index) { ); if (len > 0) { - int result = slot.client->publish(status_topic, 1, true, json_buffer, strlen(json_buffer)); + // 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); } @@ -2042,10 +2450,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; @@ -2161,18 +2583,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 (_manage_wifi && 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 (_manage_wifi && 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); } @@ -2211,15 +2632,15 @@ bool MQTTBridge::isSlotReady(int index, char* reason_buf, size_t reason_size) co return false; } } - if (mqttPresetNeedsSlotCredentials(slot.preset)) { - if (_obs->mqtt_slot_username[index][0] == '\0') { - if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.username ", index + 1); - return false; - } - if (_obs->mqtt_slot_password[index][0] == '\0') { - if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.password ", index + 1); - return false; - } + if (mqttPresetNeedsSlotUsername(slot.preset) && + _obs->mqtt_slot_username[index][0] == '\0') { + if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.username ", index + 1); + return false; + } + if (mqttPresetNeedsSlotPassword(slot.preset) && + _obs->mqtt_slot_password[index][0] == '\0') { + if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.password ", index + 1); + return false; } } else { // Custom slot without a topic template uses meshcore format, needs IATA @@ -2297,8 +2718,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) { @@ -2422,7 +2845,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; @@ -2438,19 +2863,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; } @@ -2461,7 +2885,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; @@ -2483,23 +2910,30 @@ void MQTTBridge::processPacketQueue() { 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_published = false; - if (_raw_enabled) { + if (_obs->mqtt_raw_enabled) { raw_published = publishRaw(&queued.packet_copy); } - 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; + bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published); + const MQTTPacketQueuePolicy::RetryDecision retry = + MQTTPacketQueuePolicy::retryDecision( + any_published, 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; } @@ -2507,13 +2941,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); @@ -2528,6 +2965,7 @@ void MQTTBridge::processPacketQueue() { #else // Non-ESP32: Use circular buffer if (_queue_count == 0) { + _queue_disconnected_since = 0; return; } @@ -2540,33 +2978,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; } @@ -2584,34 +3037,44 @@ void MQTTBridge::processPacketQueue() { 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_published = false; - if (_raw_enabled) { + if (_obs->mqtt_raw_enabled) { raw_published = publishRaw(&queued.packet_copy); } - 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; + bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published); + const MQTTPacketQueuePolicy::RetryDecision retry = + MQTTPacketQueuePolicy::retryDecision( + any_published, 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); @@ -2752,19 +3215,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 @@ -2899,6 +3368,54 @@ 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) { + // MeshRank slots reject non-packets by contract, so buildTopicForSlot + // returns false for them here and the slot is skipped. + 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 // WITH_MQTT_NEIGHBORS + // --------------------------------------------------------------------------- // Queue management // --------------------------------------------------------------------------- @@ -2942,27 +3459,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]; @@ -3181,8 +3711,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; } @@ -3200,6 +3728,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) { @@ -3501,6 +4036,15 @@ void MQTTBridge::optimizeMqttClientConfig(PsychicMqttClient* client, bool needs_ client->setKeepAlive(75); #endif + // QoS 1 retransmit timeout for unacked PUBLISHes (status messages). esp-mqtt's + // 1000 ms default resends a byte-identical duplicate every second whenever the + // broker's PUBACK takes >1s -- on a congested or recovering uplink this floods + // subscribers with exact copies of one /status message (observed 6 copies ~1s + // apart after an ISP outage; brokers may drop the session as spam). 15s allows + // one retry before the outbox entry expires (esp-mqtt outbox expiry is 30s), + // preserving at-least-once delivery while capping duplicates at one. + client->setMessageRetransmitTimeout(15000); + // Buffer sizing: 896 is the minimum safe size for JWT clients (CONNECT + 768-byte JWT). // On PSRAM boards, use a uniform size to reduce fragmentation from mixed allocations. // On non-PSRAM boards, use smaller buffers for non-JWT slots to reduce heap usage and @@ -3513,6 +4057,23 @@ void MQTTBridge::optimizeMqttClientConfig(PsychicMqttClient* client, bool needs_ client->setBufferSize(MQTT_CLIENT_BUFFER_SIZE); + // Bound how long a synchronous QoS0 publish (see publishToSlot) can block the MQTT + // task on a stalled/half-open socket before esp-mqtt aborts the write. Default is 10s; + // 2.5s lets a first stall resolve fast (write fails -> slot flips to disconnected -> + // subsequent packets skip it) without holding up publishing to the other slots. Mesh + // RX (Core 1) and the WiFi/TCP stack are unaffected by this block regardless. + client->setNetworkTimeout(2500); + + // Dormant safety net: cap the esp-mqtt outbox for any residual async QoS0 path. QoS0 + // packets now publish synchronously (store=false, no outbox), so this normally never + // engages, but it bounds internal-heap growth if a QoS0 message ever takes the async + // path. Non-PSRAM (outbox on internal heap) gets the tighter cap. +#if defined(BOARD_HAS_PSRAM) + client->setOutboxLimit(16384); +#else + client->setOutboxLimit(8192); +#endif + // Access ESP-IDF config to optimize additional settings esp_mqtt_client_config_t* config = client->getMqttConfig(); if (config) { @@ -3530,8 +4091,31 @@ void MQTTBridge::optimizeMqttClientConfig(PsychicMqttClient* client, bool needs_ } void MQTTBridge::logMemoryStatus() { - MQTT_DEBUG_PRINTLN("Memory: Free=%d, Max=%d, Queue=%d/%d", - ESP.getFreeHeap(), ESP.getMaxAllocHeap(), _queue_count, MAX_QUEUE_SIZE); + // QoS0 packets now publish synchronously, so the outbox stays ~0 and is only a sanity + // check (a non-zero total would mean the QoS1 status path is backing up or the dormant + // async cap engaged). The live signal is per-slot publish health: ok = cumulative + // accepted writes, err = cumulative failures (socket error / network_timeout on a + // stalled link). A rising err on a slot means that broker's uplink is dropping packets; + // ok climbing with err flat is healthy delivery. + char pub_detail[200]; + size_t pos = 0; + size_t outbox_total = 0; + pub_detail[0] = '\0'; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].client) { + outbox_total += _slots[i].client->getOutboxSize(); + if (_slots[i].enabled) { + pos += snprintf(pub_detail + pos, sizeof(pub_detail) - pos, "%ss%d=%lu/%lu", + pos ? " " : "", i + 1, + _slots[i].client->getPublishOk(), + _slots[i].client->getPublishErr()); + if (pos >= sizeof(pub_detail)) break; + } + } + } + MQTT_DEBUG_PRINTLN("Memory: Free=%d, Max=%d, Queue=%d/%d, Outbox=%u | pub(ok/err) %s", + ESP.getFreeHeap(), ESP.getMaxAllocHeap(), _queue_count, MAX_QUEUE_SIZE, + (unsigned)outbox_total, pub_detail); } // --------------------------------------------------------------------------- diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 64bf7ae7..f0c5c117 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -11,6 +11,8 @@ #include #include "helpers/JWTHelper.h" #include "helpers/MQTTPresets.h" +#include "helpers/MQTTLifecycle.h" +#include #ifdef WITH_SNMP class MeshSNMPAgent; // Forward declaration @@ -50,6 +52,14 @@ struct MQTTNodeInfo { bool repeat_when_nonzero = true; }; +// Periodic neighbors publication is PSRAM-only: it needs a persistent ~10 KB JSON +// buffer plus a second transient one while the mesh builds the table, and it keys +// off the mesh neighbor cache (sized by MAX_NEIGHBOURS). Every neighbors-specific +// member, method, and code block in this bridge is gated on WITH_MQTT_NEIGHBORS. +#if defined(BOARD_HAS_PSRAM) && defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0 +#define WITH_MQTT_NEIGHBORS 1 +#endif + /** * @brief Bridge implementation using MQTT protocol for packet transport * @@ -107,6 +117,8 @@ private: uint8_t reconnect_backoff; // 0..4 index into backoff table uint8_t max_backoff_failures; // consecutive failures at max backoff level bool circuit_breaker_tripped; // true = stop reconnecting until reconfigured + unsigned long connected_at_ms; // millis() of last successful connect (0 = not connected); + // gates the stability-based backoff reset in maintenance unsigned long last_reconnect_attempt; unsigned long last_log_time; // Throttle disconnect log messages unsigned long last_deferred_log_ms; // Throttle "connect deferred" log spam (Phase 1) @@ -212,6 +224,14 @@ private: // Pending slot reconfigure: set from CLI (Core 1), processed by MQTT task (Core 0) volatile bool _slot_reconfigure_pending[RUNTIME_MQTT_SLOTS]; + // Pending on-connect status publish: set from the onConnect callback (which + // runs on the esp-mqtt event task, NOT this bridge task), consumed by the MQTT + // task (Core 0). publishStatusToSlot() touches the shared status doc/buffer/ + // origin that publishStatus() also uses, so it must run only on the bridge + // task -- the callback just raises this flag. Same idiom as + // _slot_reconfigure_pending; a single-byte volatile store/load is atomic. + volatile bool _status_publish_pending[RUNTIME_MQTT_SLOTS]; + // CLI-requested forced NTP sync, marshalled onto the MQTT task (Core 0). // All NTP I/O (_ntp_client, configTime) must run on Core 0; the CLI thread // (Core 1) sets _ntp_force_requested and blocks in requestForcedNtpSync() @@ -243,6 +263,16 @@ private: volatile bool _ntp_estimate_ok; volatile uint32_t _ntp_estimate_epoch; + // Cooperative-shutdown handshake (Phase 5). The loop task (Core 1) raises + // _stop_requested through the lifecycle Coordinator; the MQTT task (Core 0) + // sees it, tears down its own clients on Core 0 (where the mbedTLS contexts + // live), sets _stop_acked LAST, and self-terminates. end() waits for the ack + // before freeing the queue/buffers. Plain volatile matches the existing + // NTP/reconfigure handshake idiom above; replacing all of these with a command + // channel / task notifications is explicitly deferred (see MQTT_OWNERSHIP.md). + volatile bool _stop_requested = false; + volatile bool _stop_acked = false; + // Timezone handling. // _timezone_storage is inline class storage (zero heap) that is reconfigured // via setRules() whenever the preferred timezone string changes. _timezone @@ -287,6 +317,26 @@ private: char _status_json_buffer[STATUS_JSON_BUFFER_SIZE]; #endif +#if defined(WITH_MQTT_NEIGHBORS) + // Persistent PSRAM copy of the neighbors-table JSON. The mesh (Core 1) builds + // the payload into its own transient buffer, hands it here via + // requestPublishNeighbors(), and the MQTT task (Core 0) publishes this copy. + // Allocated in allocateRuntimeBuffers()/freed in releaseRuntimeBuffers() like + // the other PSRAM buffers (nullptr if the allocation failed). + char* _neighbors_json_buffer; + size_t _neighbors_publish_len; + // Release/acquire handoff from the mesh loop (Core 1) to the MQTT task (Core 0). + // A second snapshot is dropped while the current one is still publishing. + std::atomic _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; @@ -297,6 +347,7 @@ private: // the MQTT memory-defrag work - persistent MQTT clients no longer churn // the heap, so gray-zone / critical-restart trackers are unnecessary. unsigned long _last_memory_check; + bool _memory_pressure = false; // Cached max-alloc verdict; re-sampled at most once per interval in publishPacket() so the heap walk isn't paid per-packet under pressure int _skipped_publishes; // Exposed via SNMP; count of publishes skipped when max_alloc is too low // Status publish retry tracking @@ -311,7 +362,6 @@ private: // Queue staleness tracking unsigned long _queue_disconnected_since; // 0 = has connected slots - static const unsigned long QUEUE_STALE_MS = 300000UL; // Flush queue after 5 min disconnected #ifdef WITH_SNMP MeshSNMPAgent* _snmp_agent; @@ -340,7 +390,7 @@ private: mesh::MillisecondClock* _ms; // For uptime // Topic building - enum MQTTMessageType { MSG_STATUS, MSG_PACKETS, MSG_RAW }; + enum MQTTMessageType { MSG_STATUS, MSG_PACKETS, MSG_RAW, MSG_NEIGHBORS }; bool buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size); bool substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size); @@ -362,6 +412,7 @@ private: void maintainSlotConnections(); // Maintain all slot connections (token renewal, reconnect) void maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted); bool createSlotAuthToken(int index); // Create/renew JWT token for a slot + unsigned long slotTokenLifetime(int index) const; // effective JWT lifetime (preset/default minus slot stagger), seconds bool publishToSlot(int index, const char* topic, const char* payload, bool retained = false, uint8_t qos = 0); bool publishToAllSlots(const char* topic, const char* payload, bool retained = false, uint8_t qos = 0); void publishStatusToSlot(int index); @@ -381,6 +432,11 @@ private: const uint8_t* raw_data = nullptr, int raw_len = 0, float snr = 0.0f, float rssi = 0.0f); bool publishRaw(mesh::Packet* packet); +#if defined(WITH_MQTT_NEIGHBORS) + // Publishes the pending _neighbors_json_buffer to every connected slot's + // neighbors topic. Runs on the MQTT task (Core 0) only. + bool publishNeighbors(); +#endif void queuePacket(mesh::Packet* packet, bool is_tx); void dequeuePacket(); bool isAnySlotConnected(); @@ -399,6 +455,32 @@ private: void getClientVersion(char* buffer, size_t buffer_size) const; void logMemoryStatus(); void refreshOriginFromPrefs(); + // begin()/end()-scoped PSRAM buffers. Each allocation is independent so a + // transient heap shortage degrades to the existing stack fallback instead + // of making the bridge unusable. + void allocateRuntimeBuffers(); + void releaseRuntimeBuffers(); + + // --- Cooperative lifecycle (Phase 5) --------------------------------------- + // The pure state machine, bounded stop timeout, and OTA barrier live in + // src/helpers/MQTTLifecycle.h and are host-tested by test/test_mqtt_lifecycle/. + // This nested Ops binds that spec to FreeRTOS/PsychicMqttClient. The + // Coordinator is owned and driven ONLY by the loop task (Core 1) from + // begin()/end(); the MQTT task (Core 0) communicates solely through the + // _stop_requested/_stop_acked flags above. Methods are defined in the .cpp. + class LifecycleOps : public MQTTLifecycle::Ops { + public: + explicit LifecycleOps(MQTTBridge* bridge) : _b(bridge) {} + uint32_t nowMs() override; + void startTask() override; + void deliverStop() override; + void releaseResources() override; + void onStopComplete(bool clean) override; + private: + MQTTBridge* _b; + }; + LifecycleOps _lifecycle_ops; + MQTTLifecycle::Coordinator _lifecycle; // Observer config (MQTT/WiFi/timezone/SNMP/alert), persisted to /mqtt_prefs. MQTTPrefs* _obs = nullptr; @@ -424,7 +506,7 @@ public: * "set mqttN.preset ". Handles teardown of old connection and * setup of new one. * - * @param slot_index Slot index (0-2) + * @param slot_index Slot index (0 to RUNTIME_MQTT_SLOTS-1) * @param preset_name Preset name: "analyzer-us", "analyzer-eu", "nz-analyzer", "meshmapper", "custom", "none" */ void setSlotPreset(int slot_index, const char* preset_name); @@ -434,7 +516,7 @@ public: * Configure custom broker settings for a slot. Only applies when the * slot's preset is "custom". * - * @param slot_index Slot index (0-2) + * @param slot_index Slot index (0 to RUNTIME_MQTT_SLOTS-1) * @param host Broker hostname * @param port Broker port * @param username MQTT username (empty for anonymous) @@ -451,9 +533,37 @@ public: void setBuildDate(const char* build_date); void storeRawRadioData(const uint8_t* raw_data, int len, float snr, float rssi); void setMessageTypes(bool status, bool packets, bool raw); + +#if defined(WITH_MQTT_NEIGHBORS) + // Single source of truth for the neighbors JSON size, used by both the bridge's + // persistent buffer and the mesh's transient build buffer. + static const size_t NEIGHBORS_JSON_BUFFER_SIZE = 10240; + + // Called by the mesh (Core 1) once a neighbor-discovery pass has built the + // table JSON. Copies it into the persistent PSRAM buffer and raises the + // publish-pending flag for the MQTT task; a request is dropped if one is + // already in flight or the buffer is unavailable. + void requestPublishNeighbors(const char* json, size_t len); + + // Periodic-neighbors schedule, reported by the mesh loop for `get mqtt.status`. + // The mesh owns the timer; the bridge only caches the summary so the wrap-safe + // millis math stays on the side that already has those helpers. + enum NeighborsPhase : uint8_t { + NBR_SCHEDULED, // waiting for the next publish; secs_until_next is valid + NBR_ACTIVE, // zero-hop refresh or scope queries in flight + NBR_DUE, // publish is due, waiting on the bridge/WiFi to come up + }; + void setNeighborsSchedule(NeighborsPhase phase, uint32_t secs_until_next); +#endif + int getConnectedBrokers() const; int getQueueSize() const; bool isReady() const; + /** True only after a CLEAN cooperative stop -- end() received the MQTT task's + * acknowledgment within the timeout. A timed-out/forced stop returns false so + * OTA flashing is withheld until a clean start/stop cycle. Mirrors + * MQTTLifecycle::mayBeginFlash(); read on the loop task (Core 1). */ + bool canFlashAfterStop() const { return _lifecycle.mayBeginFlash(); } static unsigned long getWifiConnectedAtMillis(); @@ -473,6 +583,11 @@ public: bool isSlotEnabledAndAttempted(int slot_index) const; const char* getSlotPresetName(int slot_index) const; static int getRuntimeSlotCount() { return RUNTIME_MQTT_SLOTS; } + /** Max slots that can be connected at once: 5 with PSRAM, 2 without (each + * WSS/TLS connection needs ~40KB for mbedTLS buffers). This is the number of + * usefully-configurable servers; RUNTIME_MQTT_SLOTS carries a spare for + * reconfiguration. Safe to call before begin(). */ + static int getMaxActiveSlots(); /** Resolved origin for MQTT JSON: node_name when mqtt_origin is empty, else mqtt_origin (with quote stripping). */ static void getEffectiveMqttOrigin(const char* node_name, const MQTTPrefs* obs, char* buf, size_t buf_size); @@ -502,6 +617,9 @@ public: /** True after this bridge has successfully set the RTC from NTP this boot. */ bool hasNtpTime() const { return _ntp_synced; } static void formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs); + /** On-demand publish-health + heap snapshot for `get mqtt.stats` (per-slot ok/err, + * outbox size, free/max heap, queue depth). */ + static void formatMqttStatsReply(char* buf, size_t bufsize); // Structured status used by the browser configuration page. Publish // counters are optional because not every supported PsychicMqttClient // version exposes them. diff --git a/src/helpers/esp32/WebConfigHtml.h b/src/helpers/esp32/WebConfigHtml.h index 5d01490e..73b1bb38 100644 --- a/src/helpers/esp32/WebConfigHtml.h +++ b/src/helpers/esp32/WebConfigHtml.h @@ -4,1110 +4,1181 @@ #include #include -const uint32_t WEBCONFIG_HTML_GZ_LEN = 17636; -const char WEBCONFIG_HTML_ETAG[] = "\"dccc1e1773ca0bbd\""; +const uint32_t WEBCONFIG_HTML_GZ_LEN = 18776; +const char WEBCONFIG_HTML_ETAG[] = "\"691a7dcb255c40be\""; const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = { - 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0x03,0xd5,0x7d,0x79,0x7f,0xdb,0x46, - 0x92,0xe8,0xff,0xfe,0x14,0x6d,0x38,0x2b,0x01,0x11,0x08,0x91,0xd4,0x61,0x99,0x14, - 0xa5,0x55,0x64,0x7b,0xe2,0x89,0x0f,0xad,0xa5,0x4c,0xb2,0xab,0xf1,0x38,0x20,0xd1, - 0x14,0x11,0x81,0x00,0x02,0x80,0xa2,0x64,0x5a,0xef,0xb3,0xbf,0xaa,0xea,0x6e,0xa0, - 0x71,0x90,0xa2,0x12,0xcf,0x6f,0xdf,0x9b,0x4c,0x22,0x02,0xe8,0xb3,0xba,0xba,0xae, - 0xae,0xaa,0x3e,0x7c,0xfa,0xf2,0xc3,0xe9,0xc5,0x7f,0x9f,0xbd,0x62,0x93,0x6c,0x1a, - 0x1c,0x3d,0x39,0xc4,0x3f,0x2c,0x70,0xc3,0xab,0x81,0xc1,0x43,0x03,0x5f,0x70,0xd7, - 0x83,0x3f,0x53,0x9e,0xb9,0x6c,0x34,0x71,0x93,0x94,0x67,0x03,0x63,0x96,0x8d,0x5b, - 0x07,0x86,0x7a,0x1d,0xba,0x53,0x3e,0x30,0x6e,0x7c,0x3e,0x8f,0xa3,0x24,0x33,0xd8, - 0x28,0x0a,0x33,0x1e,0x42,0xb1,0xb9,0xef,0x65,0x93,0x81,0xc7,0x6f,0xfc,0x11,0x6f, - 0xd1,0x83,0xcd,0xfc,0xd0,0xcf,0x7c,0x37,0x68,0xa5,0x23,0x37,0xe0,0x83,0x8e,0xcd, - 0xa6,0xee,0xad,0x3f,0x9d,0x4d,0x8b,0x17,0xaa,0xa1,0xd6,0xd8,0xcf,0x06,0xa3,0xe8, - 0x86,0x27,0xd8,0x53,0xe6,0x67,0x01,0x3f,0x7a,0xc7,0xd3,0xc9,0x69,0x94,0x70,0x76, - 0x1a,0x85,0x63,0xff,0xea,0x70,0x5b,0xbc,0x7e,0x72,0x98,0x66,0x77,0xf8,0xb7,0x97, - 0x44,0x51,0xb6,0x78,0xc2,0x58,0xab,0x35,0xbc,0xea,0x3d,0x1b,0xef,0x8e,0xf7,0xc7, - 0x07,0x7d,0x78,0x1a,0xb9,0x89,0x07,0xcf,0xe3,0x31,0x3e,0xf8,0xe1,0x75,0xef,0x59, - 0xc7,0xed,0xee,0xec,0xb4,0xf1,0x71,0x3a,0xcb,0x7a,0xcf,0xf6,0xf7,0x9f,0xef,0x1c, - 0xb8,0xf8,0x18,0xf8,0x21,0xef,0x3d,0xe3,0x5d,0xfe,0x9c,0xf3,0x3e,0x35,0xe5,0x8e, - 0x46,0xbd,0x67,0xdd,0xe1,0x73,0x8f,0xbf,0xe8,0x8b,0x47,0xd1,0x84,0x6c,0x2e,0xc2, - 0xd6,0xbc,0x17,0xde,0x1e,0x3d,0xf1,0x24,0xe9,0x3d,0xf3,0xf6,0x77,0xf7,0x76,0xf7, - 0xf0,0x71,0xee,0x26,0x61,0xef,0xd9,0xe8,0xe0,0xe0,0x79,0xc7,0x15,0xad,0x8d,0x26, - 0x7e,0x0c,0xed,0xf3,0x71,0x77,0xfc,0x5c,0x8c,0x46,0x8c,0x55,0x8d,0x4d,0x0e,0x60, - 0x74,0xe0,0x75,0x3d,0x8e,0xaf,0xd2,0x89,0xeb,0x45,0xf3,0x5e,0x9b,0x75,0xe2,0x5b, - 0xb6,0x03,0xff,0x26,0x57,0x43,0xd7,0xec,0xec,0xdb,0xdd,0x03,0x7b,0x77,0xcf,0x76, - 0xda,0x07,0x56,0xff,0xc9,0xfd,0x93,0xff,0x9c,0x72,0xcf,0x77,0x99,0x19,0x27,0x7c, - 0xcc,0x93,0xb4,0x35,0x8a,0x82,0x28,0x01,0xb0,0x4e,0xf8,0x94,0xf7,0x3c,0x37,0xb9, - 0xb6,0x16,0x15,0xf0,0x74,0xda,0x9d,0xbd,0xce,0xa8,0x00,0x0f,0x80,0xa4,0xd3,0x1d, - 0xe6,0x10,0xe2,0xfb,0x7c,0x38,0xee,0xe6,0x10,0x3a,0x18,0xbe,0x38,0x70,0x87,0x05, - 0x84,0xba,0xee,0xce,0xee,0x6e,0x57,0x83,0xd0,0xae,0xfb,0x62,0x77,0x4c,0x10,0x15, - 0x53,0xec,0xee,0x74,0xbd,0x1d,0x57,0x9b,0x62,0x67,0x17,0x7a,0xe8,0x96,0x66,0xb9, - 0xe3,0xee,0xee,0xef,0xed,0x2f,0x9f,0x65,0xdb,0xc6,0x7f,0x9c,0x5d,0x9c,0xe1,0xfd, - 0x93,0xef,0x17,0xc3,0xe8,0xb6,0x95,0xfa,0x5f,0xfc,0xf0,0xaa,0x37,0x8c,0x12,0x8f, - 0x27,0x2d,0x78,0xd3,0x9f,0xba,0xc9,0x95,0x1f,0xf6,0xda,0xf7,0x4f,0x10,0x7f,0x17, - 0xad,0x39,0x1f,0x5e,0xfb,0x59,0x2b,0xe3,0xb7,0x19,0x96,0xe6,0x2d,0xd7,0xfb,0x7d, - 0x96,0x66,0xbd,0x4e,0xbb,0xfd,0x1f,0xf7,0x4f,0x86,0x91,0x77,0xb7,0x18,0x03,0x92, - 0xf6,0x3a,0x7b,0xf1,0xed,0x76,0xc7,0xd9,0xdd,0x63,0xe9,0x5d,0x9a,0xf1,0x69,0x6b, - 0xe6,0xdb,0x2d,0x37,0x8e,0x03,0xde,0x12,0x2f,0x6c,0xe3,0x9c,0x5f,0x45,0x9c,0xfd, - 0xfc,0xc6,0xb0,0x3f,0x46,0xc3,0x28,0x8b,0xec,0xd4,0x0d,0xd3,0x56,0xca,0x13,0x7f, - 0xdc,0x1f,0xba,0xa3,0xeb,0xab,0x24,0x9a,0x85,0x5e,0xef,0xc6,0x4d,0x4c,0x04,0xaa, - 0xd5,0x27,0xb0,0xcb,0x67,0x00,0xa3,0xd5,0x8f,0x5d,0xcf,0x83,0xf1,0xc2,0x40,0xb3, - 0x2c,0x9a,0xf6,0x5e,0xb4,0xe3,0xdb,0x3e,0x62,0xf5,0x38,0x88,0xe6,0xad,0xdb,0xde, - 0xc4,0xf7,0x3c,0x1e,0xc2,0xc8,0x3b,0x34,0x26,0x1a,0x6f,0xaf,0xf3,0x1c,0x0a,0xd1, - 0xe3,0x9c,0xfb,0x57,0x93,0xac,0xb7,0xbf,0x87,0x93,0xeb,0xea,0x45,0x76,0xeb,0x45, - 0xfa,0x34,0xe3,0x2c,0x81,0x21,0x8e,0xa3,0x64,0xda,0x9b,0xc5,0x31,0x4f,0x46,0x6e, - 0xca,0xfb,0x01,0xcf,0x32,0x00,0x56,0x1a,0xbb,0x23,0x84,0x9d,0xd3,0xde,0xe3,0xd3, - 0xd2,0x50,0x61,0x89,0x2d,0x05,0xc7,0x6e,0x17,0xc0,0x0f,0x0b,0x01,0x23,0xc5,0x4e, - 0x7b,0x63,0x3f,0x49,0x33,0x5c,0xd4,0xc0,0x5b,0x88,0x22,0xad,0x2c,0x8a,0x11,0xdc, - 0xee,0x42,0x6f,0x03,0xb0,0xc0,0x82,0x0a,0x40,0x31,0x78,0xb2,0xf0,0xfc,0x34,0x0e, - 0xdc,0xbb,0xde,0x38,0xe0,0xb7,0x7d,0x37,0xf0,0xaf,0xc2,0x96,0x0f,0x10,0x4d,0x7b, - 0x23,0xa0,0x0d,0x3c,0xe9,0x5f,0xb9,0x71,0x0f,0x7b,0x50,0xf0,0xe9,0x75,0xb0,0xd7, - 0xce,0x3e,0xbc,0xa9,0xc1,0x15,0xd1,0xd3,0xea,0xe7,0x0b,0x4e,0x70,0x44,0x34,0x49, - 0xa3,0xc0,0xf7,0x98,0x28,0x83,0xf8,0x04,0xc0,0x8e,0x52,0xa0,0x2f,0x51,0xd8,0x4b, - 0x33,0x7f,0x74,0x7d,0xd7,0xa7,0x71,0xf6,0xbf,0xc0,0x4a,0x78,0xfc,0x16,0xfa,0x53, - 0xc3,0x63,0xe9,0xcd,0xd5,0x02,0x87,0xd6,0x0b,0xa3,0x90,0xdf,0x3f,0x71,0x26,0x48, - 0xca,0x16,0x53,0x98,0x1a,0x11,0x2a,0x9c,0x9c,0x78,0xc7,0x3c,0xff,0x46,0x07,0x3b, - 0x8c,0xb2,0x0e,0xb8,0xf9,0x04,0xe6,0x46,0xd0,0xe5,0xd0,0xe0,0x3c,0x71,0xe3,0x7c, - 0x89,0xe5,0x02,0x8b,0xa5,0xc9,0x5f,0xf2,0x20,0xf0,0xe3,0xd4,0x4f,0xa1,0x97,0xa1, - 0xeb,0x5d,0x71,0x05,0xd7,0x80,0x8f,0xb3,0x9e,0x3b,0xcb,0xa2,0x7e,0x3e,0xb8,0xbe, - 0xd6,0x79,0xa7,0x61,0xcd,0x15,0xfc,0x70,0xcf,0xbc,0x40,0xe8,0x09,0x30,0x25,0xae, - 0xe7,0xcf,0xd2,0xde,0x8b,0x17,0x8d,0x00,0x85,0x0d,0x6a,0xd5,0xa6,0xa1,0x06,0xe3, - 0x00,0x91,0x9f,0xc5,0x0b,0xad,0xd6,0xb3,0xf1,0x0e,0x3f,0xf0,0x76,0x64,0x8d,0x67, - 0x07,0xee,0xde,0xa8,0xdd,0x5e,0x8b,0xe4,0x2c,0x6d,0x71,0xc7,0xed,0x8e,0x3b,0xbb, - 0xaa,0x45,0xde,0x1e,0xee,0xee,0x8d,0x60,0x8b,0x4f,0x5d,0x3f,0x04,0x60,0xdc,0xca, - 0x65,0xd8,0xdf,0x45,0x14,0x51,0xfb,0x9b,0x11,0x64,0x72,0x84,0xd9,0x47,0xfc,0x74, - 0x10,0x39,0x16,0x2b,0x51,0x66,0x09,0xae,0x94,0x01,0x45,0x0b,0xab,0x37,0xdd,0x27, - 0x5a,0x23,0xa8,0x92,0xa8,0x26,0x1e,0xd4,0x36,0xc9,0x11,0x71,0x97,0x86,0x91,0xb9, - 0xc3,0xb4,0x8c,0xf4,0x88,0xe1,0xbb,0xcb,0xa1,0xaf,0xfa,0xda,0xad,0xad,0x59,0xa7, - 0x98,0x73,0x43,0x27,0x6c,0x38,0x83,0x57,0xa1,0xc0,0xde,0x8e,0x9a,0x62,0x5b,0xef, - 0x86,0xd0,0xa6,0x86,0xa4,0x44,0xf2,0xfc,0x70,0x02,0xb4,0x2b,0xd3,0x91,0x6a,0xc7, - 0xd9,0xab,0xa2,0x55,0xbb,0x40,0x2b,0xa0,0x44,0xac,0x3e,0x44,0xa4,0x4f,0xa3,0x59, - 0x92,0x42,0x0f,0x71,0xe4,0xe3,0x86,0x2e,0x8f,0xce,0x81,0x01,0x2e,0x59,0x93,0x1a, - 0x81,0x5c,0x06,0x67,0x68,0x71,0xbc,0xa8,0x80,0x61,0x87,0xc0,0x30,0x06,0x11,0x65, - 0xc8,0x83,0x1c,0xdc,0xc3,0x20,0x1a,0x5d,0xeb,0x53,0xea,0x36,0x4e,0xa9,0xdc,0xd6, - 0xae,0x6c,0xca,0x99,0xc0,0xf8,0xf5,0x1d,0xde,0xa1,0xca,0x4b,0x88,0x23,0x51,0x3e, - 0x1a,0x85,0x1f,0xc6,0xb3,0xec,0x32,0xbb,0x8b,0xf9,0x00,0xf7,0xf6,0x27,0x5b,0x7b, - 0x11,0xbb,0x69,0x3a,0x07,0x80,0x95,0x5e,0x86,0xb3,0xe9,0x90,0x27,0xa5,0x57,0x1c, - 0xd0,0x3d,0xf8,0x64,0xa7,0x3c,0xe0,0x23,0x62,0xcc,0x02,0xeb,0x91,0x4f,0xe5,0x0b, - 0x70,0x80,0x64,0xb1,0x2d,0xa7,0xd3,0xb4,0x7e,0xfb,0x95,0xd1,0x0a,0xa0,0x56,0x81, - 0x4f,0x3c,0xd8,0x42,0x76,0xbd,0x64,0x53,0x48,0x9e,0x5c,0xdd,0x17,0x07,0xc8,0xae, - 0x66,0x19,0xb1,0x6b,0xa2,0x95,0x8c,0x6d,0x7f,0xcf,0x8e,0x06,0xd8,0x6d,0x8f,0xa5, - 0x00,0x8e,0x94,0xf9,0x1f,0xce,0xd9,0x97,0x28,0x9a,0xb6,0xa2,0xb0,0x35,0x8e,0x46, - 0xb3,0x94,0x99,0xa9,0xef,0xf1,0xb9,0x7b,0x07,0xac,0x72,0x94,0x44,0x41,0x00,0x72, - 0x1f,0x1b,0xb9,0x71,0xe6,0xdf,0x70,0x96,0x4e,0x38,0xcf,0x2c,0xf6,0xfd,0xb6,0x00, - 0x61,0x8f,0x6a,0x48,0x18,0x88,0x87,0x85,0x1c,0x42,0x95,0xb9,0xe8,0xa8,0xd2,0x66, - 0xf8,0x0f,0xf2,0x0c,0x41,0x75,0xa6,0xfe,0xad,0x09,0x9d,0xa4,0x20,0x36,0xd8,0x45, - 0x0d,0xd6,0xdd,0xfb,0x0f,0x9b,0x18,0x62,0xec,0x26,0xc0,0x78,0x2c,0xb9,0x6e,0x8e, - 0xe7,0x27,0xd9,0x9d,0xec,0x54,0x3c,0x34,0x75,0x8a,0xd2,0x1b,0xe2,0x61,0x12,0xcd, - 0xeb,0x7b,0x9b,0xf8,0x23,0x7e,0x3a,0x02,0x3c,0x95,0xbb,0xb1,0xcc,0x40,0xd2,0xf9, - 0x83,0x6c,0x10,0xe5,0x12,0x7f,0x7c,0xd7,0x92,0x42,0x73,0x8f,0x78,0x48,0x6b,0xc8, - 0xb3,0x39,0x07,0xa6,0x51,0x63,0x92,0x2f,0x90,0x33,0x53,0xcb,0x0c,0x4a,0x86,0x6c, - 0xb8,0x7c,0x0f,0x34,0x6e,0x6b,0xad,0xaa,0x2f,0x71,0x1e,0x25,0x67,0x58,0xd9,0x64, - 0xea,0x06,0xfd,0x07,0x77,0x01,0xee,0xf2,0xab,0x60,0x91,0xb3,0xd9,0x84,0x07,0x2e, - 0x2e,0x6a,0x5f,0x4c,0x7a,0x17,0x89,0xe8,0x44,0xf4,0xd6,0x25,0x01,0x45,0x63,0xb1, - 0x50,0x91,0x11,0xf0,0x8b,0xea,0x40,0x30,0xa2,0x60,0x96,0xf1,0x7e,0x84,0x82,0x49, - 0x76,0x07,0x64,0x4c,0xdb,0x01,0xb2,0x21,0xfa,0xad,0x58,0x40,0x03,0xd5,0x81,0x66, - 0x67,0x0d,0x4d,0xfa,0x21,0x30,0x9d,0x32,0x5d,0x5c,0x89,0xe8,0xc4,0x29,0x09,0x55, - 0x44,0x43,0x4e,0x67,0x2f,0xed,0xcb,0x6e,0x5a,0xfc,0x06,0x16,0x27,0xd5,0x67,0x32, - 0xeb,0x0d,0x39,0x08,0x59,0x7c,0xa1,0x56,0xce,0x30,0xfa,0xf5,0x41,0x48,0x6a,0xd1, - 0x27,0xbe,0x8e,0x3f,0xe4,0xf4,0x0e,0x0a,0x38,0xd1,0xef,0x12,0xaf,0x05,0x4d,0xa0, - 0x3c,0xb4,0x3d,0x00,0x40,0x65,0x64,0x3a,0x3c,0x7b,0xc0,0x70,0x47,0xd7,0xdc,0xdb, - 0x9a,0xd5,0x69,0xae,0x90,0xc9,0x9a,0xca,0xaa,0xf1,0xd3,0xd0,0xba,0x1d,0x24,0x69, - 0x92,0x76,0x0f,0xb3,0x30,0x47,0x2b,0x3f,0x44,0x58,0xb5,0xd6,0x45,0x5f,0x4d,0xb8, - 0xdb,0xcf,0x99,0x06,0x2c,0xc2,0x32,0xda,0xd5,0x24,0xc4,0x02,0x89,0xd2,0xd1,0xbd, - 0xb3,0x5f,0x17,0x6a,0x6a,0xdc,0xa7,0xdf,0x38,0xf1,0x7e,0x85,0x80,0x10,0x69,0xd4, - 0xa7,0x09,0x72,0xc9,0x68,0xb1,0x86,0x74,0x54,0xad,0xd7,0x03,0xf0,0xb8,0xc3,0x80, - 0x7b,0x0b,0x85,0xb8,0xce,0x9e,0x1a,0x91,0xc7,0xc7,0xee,0x2c,0xc8,0xca,0xdd,0x4c, - 0x17,0x35,0xee,0xa4,0xe6,0xb8,0x2f,0xe9,0x3b,0x8a,0x32,0xd0,0x71,0x15,0xf2,0xb5, - 0x7d,0xbd,0x4a,0x06,0x44,0x72,0x78,0xd0,0x2c,0x03,0xea,0x02,0x26,0x42,0x14,0x24, - 0x51,0x10,0x91,0x41,0x13,0xa7,0x55,0xed,0x75,0xf2,0x01,0x38,0xd1,0xb5,0x0e,0x92, - 0x65,0xd4,0x35,0xba,0xb6,0x58,0xa7,0x42,0x5c,0x4b,0x30,0x8b,0x10,0x64,0xa2,0x45, - 0xd0,0x89,0xd7,0x69,0x12,0x8a,0x3d,0xd0,0x26,0x96,0x40,0x12,0xe6,0xde,0xf0,0xa1, - 0x9b,0x14,0x9b,0x7e,0xec,0xdf,0x72,0x4f,0x6c,0xb2,0x76,0x3f,0x21,0xb0,0xc0,0xc6, - 0x17,0x7c,0xbe,0x10,0xff,0xbb,0xed,0x07,0x54,0x0b,0xdc,0xad,0xcd,0xb2,0xa2,0x86, - 0x93,0xb8,0x56,0x84,0x94,0xc0,0xd0,0x82,0x91,0x49,0x8f,0x5b,0x8c,0x87,0x37,0x66, - 0xea,0x8e,0x41,0xd5,0x4c,0xb8,0xdb,0x22,0xda,0x23,0x05,0x0d,0xcb,0xea,0xab,0x25, - 0x25,0xb9,0x6c,0x99,0x2e,0xd4,0xa5,0x15,0x90,0x53,0x73,0xd2,0x49,0x85,0xe5,0x14, - 0xdf,0x80,0xe8,0x57,0xc9,0xbc,0xf6,0xd1,0x49,0x63,0xc9,0x8c,0xf0,0x65,0x00,0xfa, - 0xfe,0x63,0x84,0xe0,0xaa,0x42,0x56,0x15,0x43,0x05,0x9e,0x62,0xb3,0xd0,0x51,0xb6, - 0x96,0x92,0x77,0xf0,0x50,0x2b,0xcd,0xf3,0x89,0xfd,0x20,0xd0,0xdf,0xb7,0x6b,0xec, - 0xec,0x79,0x7b,0x2d,0xc4,0x5f,0x53,0xf9,0x79,0x84,0xee,0xbc,0xcb,0xa7,0x72,0x80, - 0xdf,0x76,0xaf,0x50,0x8b,0x63,0x90,0x08,0x6d,0xf1,0x13,0xa0,0x3b,0xfa,0xa6,0xfb, - 0x86,0x5a,0x9d,0xbb,0x7e,0xb6,0x4e,0xab,0x24,0xfb,0xac,0x6e,0x56,0x89,0x47,0x99, - 0x1f,0xf0,0x42,0xf9,0xb9,0x4a,0x7c,0xaf,0x8f,0xff,0x69,0x01,0x2a,0xc0,0x1b,0xd0, - 0x8c,0xa1,0xd2,0x6c,0x1a,0xa6,0x20,0x2e,0xc4,0xdc,0xcd,0x4c,0x54,0xe3,0x5a,0x63, - 0x18,0x8c,0x0d,0xf2,0x12,0x28,0x7b,0x66,0x07,0xd5,0x3c,0xbb,0x33,0x4e,0x60,0xb3, - 0xe4,0xa2,0x95,0x68,0x77,0x19,0x79,0x5e,0x85,0xb8,0xb4,0x43,0xbb,0x79,0x13,0x8d, - 0xa2,0xce,0x52,0xb9,0xa9,0x89,0xbe,0xfe,0x65,0x64,0xa1,0x61,0x94,0xf0,0xfc,0xa0, - 0xc9,0xc2,0x23,0xca,0xa5,0x0b,0x6a,0xde,0xe3,0xa3,0x28,0x71,0x89,0xbe,0xd5,0xec, - 0x00,0x4b,0x84,0xb3,0x91,0x1b,0xde,0xb8,0xe9,0xa2,0x2e,0x43,0xed,0x21,0xc9,0x2f, - 0x4d,0x19,0x3a,0x0b,0x79,0xb6,0xbe,0x99,0x46,0x6b,0x73,0x2d,0x95,0x93,0xb4,0x90, - 0x12,0xdb,0xaf,0xd2,0x50,0x8d,0xaf,0xaf,0xb4,0xe9,0x54,0x38,0x3d,0xc1,0x46,0x30, - 0x2d,0x24,0xf8,0x62,0x1e,0xbd,0xc0,0xcd,0x4d,0x54,0xe5,0x36,0xdb,0xa2,0x00,0x10, - 0x9a,0x54,0x09,0xe9,0x55,0xbd,0x70,0x3d,0x53,0x4d,0x83,0x91,0x47,0x36,0x9d,0x02, - 0x3e,0x05,0x8b,0xfa,0x6a,0x80,0x9e,0xf4,0x86,0x98,0x38,0x4b,0x61,0x61,0x60,0xf2, - 0x21,0x0f,0x98,0xf9,0xfe,0xc3,0x05,0x83,0x27,0x6c,0x1f,0x00,0x6f,0xf5,0x58,0x36, - 0xe1,0xa4,0x41,0x9d,0xbe,0x3f,0x11,0xba,0x11,0x83,0xb9,0x81,0x64,0x96,0xb2,0x38, - 0x89,0xae,0x12,0x77,0x3a,0x05,0x24,0x18,0x01,0xf4,0xd8,0x30,0x98,0x25,0xa6,0x65, - 0x43,0x75,0x8f,0xcd,0x27,0x3c,0xa4,0xaa,0xd7,0xfc,0x6e,0x18,0x01,0x17,0x63,0x7e, - 0xca,0x66,0x31,0xb5,0x24,0x74,0xae,0x94,0x11,0x5b,0x54,0x3d,0xa5,0x2c,0x1a,0x8f, - 0xe1,0x0b,0xc7,0x7a,0x11,0x36,0x77,0xcd,0x79,0x4c,0x4d,0x90,0xd2,0x05,0x25,0xc7, - 0x3e,0x0f,0x00,0xf6,0x7e,0xea,0x83,0x6c,0xe3,0xb0,0x13,0x39,0x64,0xa0,0x0b,0x62, - 0xd3,0x30,0x04,0x08,0x03,0x6d,0x09,0x94,0xa6,0xe0,0x8e,0xc1,0xba,0xf3,0x04,0x1b, - 0xc0,0xc6,0xce,0xcf,0xdf,0xbc,0x94,0x0d,0x00,0x53,0x4e,0xb1,0x52,0x36,0x71,0x33, - 0x76,0x35,0x73,0x61,0x9b,0x64,0x9c,0x7b,0x2d,0xd9,0x30,0xa8,0x8a,0x00,0x12,0x60, - 0x91,0x19,0x77,0x3d,0x07,0xf5,0x40,0x07,0xe1,0x43,0x7d,0x3d,0x9a,0x59,0x2d,0x65, - 0xe8,0x2b,0x6d,0x37,0xbd,0x96,0x34,0x71,0x0a,0x7b,0x42,0xde,0x3d,0x72,0xcf,0x16, - 0x5a,0x08,0xd7,0xda,0x17,0xfb,0x6a,0x0b,0x17,0x96,0xc1,0x6f,0x6c,0x90,0xad,0x51, - 0x34,0xb6,0x5b,0x19,0xf0,0x33,0xfc,0x09,0xe0,0x01,0x66,0x8c,0x56,0x33,0xb9,0xdd, - 0x77,0xf7,0x6e,0x26,0x05,0x5a,0x97,0xac,0x66,0x6d,0xe4,0x93,0x4c,0x98,0xce,0x60, - 0x05,0xe2,0x06,0xa3,0xd5,0x7e,0x9d,0x63,0x6b,0xe5,0x91,0x9a,0x8a,0x7d,0xa4,0x3a, - 0xab,0xf1,0xdd,0x6e,0xd3,0xc2,0xd0,0x12,0x16,0x8d,0x34,0x5a,0x88,0x94,0xb6,0x12, - 0xc1,0x6e,0xae,0x4a,0x77,0x28,0x9c,0xed,0xef,0x2a,0x5d,0x2a,0x57,0x89,0x08,0xae, - 0xf4,0x0b,0x39,0xcd,0xaf,0x66,0x0b,0xbe,0x58,0xb9,0xc8,0xb7,0xdb,0xa8,0xff,0x5d, - 0x97,0xd9,0x98,0xb4,0x8b,0xac,0x6d,0x12,0x5b,0xa1,0x95,0x14,0x7a,0xac,0xae,0xaf, - 0x75,0x9b,0x35,0xc9,0x7e,0x61,0xe7,0x7c,0xd1,0xbe,0x99,0xab,0x89,0x0b,0xd9,0x4f, - 0xb5,0x84,0x62,0x9c,0xdc,0xc1,0x55,0x88,0x28,0x0d,0x57,0x4d,0x76,0xaf,0xdd,0x7c, - 0x24,0xf1,0x90,0xf8,0xb9,0x44,0x89,0xd3,0x08,0xad,0x7c,0x93,0x4b,0x5b,0x02,0x0b, - 0xe5,0xb0,0x9a,0x65,0xd5,0xd8,0x0f,0x25,0x1f,0xea,0xea,0x46,0x81,0x6e,0xa1,0x15, - 0xee,0xac,0xda,0xe3,0xb0,0xda,0x4d,0xe6,0x9f,0xaa,0x56,0xec,0x86,0xfe,0x54,0xb0, - 0xc8,0x84,0x75,0x52,0x86,0x4d,0x80,0x0c,0xec,0x87,0x63,0x3c,0x67,0xe4,0xfd,0x26, - 0x5d,0xea,0xfe,0xc9,0x7f,0x02,0xc9,0x1c,0x03,0x5d,0x05,0x0a,0x9b,0x2c,0xb2,0x68, - 0x51,0xe0,0x51,0x12,0x65,0x80,0x44,0xe6,0xce,0x7e,0xdb,0xe3,0x57,0xd6,0x3d,0xcc, - 0x03,0x24,0x27,0x3a,0x47,0x5a,0x54,0xc5,0xa9,0x92,0xa9,0xa5,0xd8,0x30,0x88,0xa5, - 0x24,0xf1,0xc2,0x6f,0x4d,0xc1,0x87,0x96,0xae,0x6f,0x6a,0x62,0x6e,0xbf,0xb4,0xf5, - 0xd6,0x30,0x05,0x75,0x2b,0x7a,0x63,0x7b,0x1d,0x06,0x4a,0x7d,0xaf,0x66,0x90,0xd7, - 0x37,0x8d,0x32,0x52,0x83,0xf5,0x07,0x4a,0x4a,0x31,0x46,0xdb,0x18,0x1a,0xa6,0x90, - 0xf2,0xb5,0xfe,0xe1,0x07,0x7c,0xe7,0x0b,0x1d,0x3f,0x9f,0xfa,0x53,0x3c,0xfc,0x05, - 0x8e,0x81,0xcc,0x35,0xca,0x78,0x5d,0x71,0xae,0x51,0xc9,0xb5,0x24,0xc4,0x83,0x8a, - 0x15,0x8d,0xe4,0xbc,0x26,0xeb,0xf2,0xe1,0xb6,0x3c,0x4c,0x3e,0xdc,0x96,0x87,0xdf, - 0x78,0x60,0x28,0x8f,0xc2,0x79,0x72,0x04,0x94,0xe2,0x30,0xbd,0xb9,0x12,0xa6,0xda, - 0x81,0xd1,0xdd,0x37,0x98,0x58,0x68,0xf1,0x1b,0x8f,0xaf,0x7f,0x88,0x6e,0x07,0x06, - 0x99,0x27,0x77,0xe1,0xff,0x06,0x43,0x51,0x77,0x60,0xe0,0xf4,0x0c,0x96,0x66,0x49, - 0x74,0x8d,0xe7,0xe5,0x39,0x5a,0xab,0x77,0x2d,0xd5,0x62,0xfe,0x02,0x97,0x6f,0xe4, - 0xc6,0x03,0x83,0x26,0x67,0x60,0xd7,0xd0,0xf9,0xc8,0x4f,0x46,0xc0,0x45,0x47,0xd0, - 0x47,0x07,0xca,0x8e,0xee,0xc4,0xdf,0x04,0x6a,0x3a,0x5d,0xd5,0x59,0xbd,0x79,0x39, - 0x80,0x6d,0xd9,0x4a,0xec,0x66,0x13,0xe6,0x0d,0x8c,0x77,0xcf,0x9d,0xe7,0x0c,0xfe, - 0x75,0xf7,0xd9,0x3e,0x6b,0xcb,0x7f,0x0e,0x9c,0xfd,0x77,0x9d,0x7d,0x67,0xa7,0xf4, - 0xa1,0x23,0x3f,0xec,0x3a,0x2f,0x18,0xfc,0xeb,0x76,0xf0,0x88,0x30,0xaf,0xd2,0xd9, - 0x75,0xba,0xef,0x3a,0x2f,0x9c,0x4e,0xe5,0x5b,0x47,0x7e,0x13,0x1d,0x03,0x70,0x6f, - 0xae,0xe8,0x87,0xe7,0xdf,0xb0,0x11,0x20,0x65,0x3a,0x30,0xe8,0x9c,0x4d,0xcd,0x6e, - 0xd2,0x61,0x3e,0x0c,0x6b,0xd2,0x42,0xb7,0x02,0x23,0x3f,0xf1,0x87,0xc5,0xe8,0xc8, - 0x12,0x58,0x53,0x14,0x49,0x67,0x43,0xe3,0x08,0xb6,0x0b,0x40,0x29,0x83,0x75,0xdd, - 0x98,0x10,0x66,0xf5,0x0f,0xb7,0xa1,0x88,0xe8,0x4d,0xfd,0x20,0x8b,0xa7,0xec,0x8e, - 0x4e,0xa4,0x18,0x62,0x9e,0x21,0xdb,0xa1,0x37,0xc6,0x11,0x0c,0x0e,0x8a,0xa9,0x85, - 0xc7,0xa5,0x3e,0xc4,0xe3,0xa8,0xa3,0x27,0x4f,0x0e,0x9f,0xb6,0x5a,0x6c,0xa0,0xfd, - 0x8f,0xbd,0xfd,0xf0,0xb7,0x37,0xef,0xcb,0xaf,0x5a,0x2d,0x74,0x44,0xc0,0xa1,0x44, - 0x21,0x35,0x7c,0xd3,0x0a,0x22,0x40,0x2f,0x23,0x9f,0x27,0x76,0x59,0x9d,0x3c,0xca, - 0x2b,0xf9,0xdc,0xbb,0x47,0x27,0x1e,0x50,0x0e,0x46,0xf5,0x60,0x18,0x5d,0xb5,0x58, - 0x8c,0x70,0x72,0x60,0xd4,0x28,0xc8,0xb2,0x63,0x0a,0x85,0xd2,0x40,0x31,0x8c,0xa3, - 0x57,0x48,0xbd,0x41,0x1c,0x03,0x09,0x31,0x8c,0x3c,0xbe,0x99,0x32,0x97,0xba,0x51, - 0xc7,0x14,0xcc,0x44,0x29,0x30,0x05,0x80,0x33,0x14,0xcd,0x84,0x2c,0x18,0x25,0x2c, - 0xe1,0x53,0xd8,0x81,0xec,0xf4,0xed,0x1b,0x51,0xc1,0x72,0x0e,0xb7,0x63,0x39,0x24, - 0xa4,0x97,0x34,0x4b,0x1a,0x6b,0x0b,0x1f,0xe5,0x34,0xca,0xf3,0x1b,0x03,0x5c,0xe9, - 0xb4,0x06,0x5b,0x54,0xa5,0xe3,0x39,0xcc,0xf9,0x4c,0xf6,0x7e,0xb8,0x4d,0xdf,0x55, - 0x65,0xa8,0x4e,0x96,0x4a,0x46,0x27,0x24,0x86,0x1a,0xa3,0xa1,0x75,0x86,0xd5,0xe9, - 0x2c,0x70,0x14,0x81,0x82,0xc9,0x33,0x28,0x06,0x4a,0x02,0xea,0xa7,0xad,0xa2,0x38, - 0x7e,0x27,0xc1,0xf6,0x28,0x47,0x03,0x6a,0x5c,0x98,0xe5,0x72,0x64,0xc8,0x60,0x7d, - 0x44,0x4f,0x80,0x4d,0x53,0x3f,0xd3,0xfb,0xc1,0x8f,0x47,0xe7,0x40,0xd6,0x18,0xae, - 0x86,0xa8,0xd8,0x34,0x47,0xc5,0x24,0xf4,0xba,0xf0,0xce,0xd0,0x7b,0x3e,0xdc,0x46, - 0x10,0x69,0x58,0x09,0xd8,0x26,0x70,0xa5,0x11,0xbf,0xce,0x5f,0x5d,0xfc,0x7c,0xc6, - 0x7e,0x79,0xf3,0x3f,0x27,0x1f,0x5f,0x3e,0x88,0x66,0x73,0xff,0x0b,0x62,0xd1,0x4a, - 0x3c,0x43,0x5a,0x2a,0xc6,0x37,0x0a,0xdd,0x16,0x3d,0x1d,0xbd,0x11,0x1a,0xc4,0x2f, - 0xfe,0x6b,0x1f,0x84,0x76,0x14,0x0b,0x00,0x27,0xa2,0x78,0x16,0x1f,0xb3,0x37,0x19, - 0x54,0x8c,0x52,0x60,0x91,0xfe,0x98,0xdd,0x45,0xb3,0x84,0xc5,0x13,0xc4,0x8d,0x34, - 0x00,0xa5,0x21,0x75,0xc4,0x94,0x5c,0x36,0x49,0xf8,0x78,0x60,0x3c,0x33,0x00,0x6f, - 0x46,0x81,0x3f,0xba,0x86,0xf5,0x8a,0xe2,0x0f,0xb3,0xcc,0xb4,0xfa,0x09,0xcf,0x66, - 0x49,0xc8,0xc6,0x6e,0x90,0x12,0xd9,0x23,0xfc,0xad,0xeb,0x50,0xc6,0xd1,0x87,0x98, - 0x23,0x80,0x45,0x2f,0xc3,0x24,0x9a,0xa7,0x3c,0x39,0xdc,0x76,0x8f,0x40,0x4b,0x11, - 0x2a,0x8a,0xe8,0x1e,0x75,0x21,0x9e,0xa6,0x4e,0xb1,0xa9,0xb5,0xd9,0x91,0x50,0x09, - 0xf0,0xf6,0x69,0x86,0x69,0x16,0x77,0x72,0x68,0x44,0x21,0xae,0x83,0xaf,0x7d,0xeb, - 0x56,0x5f,0xec,0x54,0x5f,0xec,0xca,0x17,0xa2,0xab,0x27,0x1a,0xd1,0x99,0x7f,0xe9, - 0x18,0x1a,0x1d,0xaa,0x6f,0x62,0xb1,0x8d,0xcf,0x61,0x3c,0x40,0xfa,0x36,0xa6,0xc0, - 0xff,0xa2,0xac,0x4f,0x20,0x2e,0xf6,0xf3,0x5f,0xde,0xd1,0x72,0x28,0xb0,0xf0,0x63, - 0x1f,0xe4,0x85,0xf8,0xce,0x38,0x3a,0x15,0x64,0xb0,0xd8,0xe5,0x08,0x3e,0x82,0x1c, - 0x2d,0x2f,0xe8,0xa9,0xb0,0x2d,0xae,0x41,0x3a,0x60,0x3e,0x2c,0x2d,0x50,0xc3,0x84, - 0xbb,0xa3,0x09,0xad,0xff,0xbb,0xff,0xba,0xb8,0x60,0x00,0x74,0xe0,0xcf,0x69,0xb1, - 0xc3,0x97,0x6c,0xe3,0xa3,0xf7,0xb2,0x25,0xa4,0xcf,0xcc,0x44,0xb5,0xcf,0xaa,0xef, - 0x60,0xad,0x26,0x2c,0x68,0xbe,0xfc,0xd2,0x1e,0x68,0x14,0x25,0x2b,0xbb,0x5d,0xec, - 0x22,0xcf,0xcd,0xdc,0xd6,0x35,0x3a,0x90,0x8d,0x7d,0xd0,0xdb,0x7d,0x2f,0x9f,0xb0, - 0x78,0xa0,0xad,0xef,0xc6,0x7e,0x06,0x32,0xc7,0x17,0xa8,0x05,0x8a,0xad,0xa2,0x07, - 0x09,0xea,0xa6,0xf2,0x8d,0x82,0x30,0x69,0x2b,0xe5,0x3e,0x6b,0x44,0x00,0xe6,0x3f, - 0x52,0x84,0x40,0x7c,0x44,0x9c,0x9e,0x46,0x48,0x0b,0xa3,0x79,0x08,0x7b,0x1c,0xe5, - 0x76,0x27,0x4e,0xe8,0xef,0x4b,0x61,0xe3,0x37,0x2d,0x0d,0xf1,0x23,0x40,0xe3,0x73, - 0x00,0xac,0xb9,0x29,0xc7,0xb9,0x69,0x01,0xf1,0x80,0x17,0x55,0xca,0xa1,0x28,0x40, - 0x99,0x2c,0x35,0xc2,0xfa,0xb1,0x24,0xb2,0x04,0xb8,0x06,0x2a,0x89,0x60,0x69,0x5e, - 0x25,0x3c,0x04,0x37,0x8e,0xde,0x72,0xf7,0x86,0xb3,0x61,0xe0,0x86,0xd7,0xc4,0x00, - 0xd0,0x4e,0x81,0xbb,0x53,0x62,0x8f,0xc3,0xba,0xce,0xee,0x06,0x60,0x58,0x98,0xc6, - 0xfd,0xbf,0xfd,0xf8,0x45,0xbd,0x4f,0x01,0x0a,0xc1,0x9d,0xb3,0xf6,0xb4,0xde,0x23, - 0x7a,0x22,0xfe,0xac,0x9e,0x57,0x19,0x19,0x48,0x1e,0x40,0x77,0xc1,0x80,0x87,0x57, - 0x28,0x27,0xed,0x74,0x56,0xcd,0x45,0x61,0x0c,0x56,0x6b,0x89,0xd9,0x9d,0x83,0x9e, - 0x02,0x13,0x12,0x44,0x0f,0xa4,0xff,0x09,0x19,0x52,0x80,0xf2,0x88,0x0d,0x00,0xa2, - 0xff,0x2c,0xc5,0xf7,0xa9,0x7b,0xc5,0xd3,0xfa,0x6c,0xf4,0x9f,0x0d,0x4c,0x24,0x47, - 0x84,0xf9,0x97,0xbf,0x45,0x66,0x17,0x16,0xff,0x3d,0x8c,0xbf,0xc7,0x3e,0x82,0xfc, - 0x19,0xe9,0x38,0xd0,0x48,0x55,0xba,0x75,0xea,0xbd,0x0e,0x89,0xe9,0x16,0x24,0x46, - 0xf6,0xf3,0xcd,0x68,0x0c,0xad,0x92,0x58,0x59,0x10,0x70,0x41,0xab,0x22,0x7a,0x11, - 0x01,0xec,0x12,0x46,0x9c,0x07,0xf6,0x01,0x1e,0x20,0x31,0x14,0xb0,0x23,0xd8,0x3f, - 0x19,0xca,0x60,0xa9,0xc3,0xce,0x00,0x0a,0x04,0x62,0xd8,0x2c,0xf0,0x56,0x10,0x21, - 0xd0,0xbc,0xa0,0x28,0x01,0x1d,0xb6,0xd4,0x1a,0x64,0xe6,0x23,0xbf,0x42,0x06,0x27, - 0xda,0xa8,0xe3,0x89,0x38,0xb9,0x57,0x8b,0x9c,0xc4,0x04,0xff,0x89,0x1b,0x5e,0x21, - 0x93,0xf9,0x42,0xb0,0x38,0xe7,0x81,0x89,0xd4,0xd0,0x22,0xc9,0x8e,0xca,0xaf,0x81, - 0x2f,0x09,0xe8,0x9d,0xb3,0x9c,0x75,0xaf,0x81,0xcc,0x17,0xbf,0x02,0xcf,0x9c,0x03, - 0x50,0x4c,0xef,0x87,0xa9,0xb5,0x1a,0xa3,0x85,0x0b,0x48,0x81,0xd3,0x19,0x90,0x72, - 0x90,0xab,0x06,0x46,0xeb,0x05,0xa1,0x36,0x20,0x75,0x3b,0xa7,0x5b,0x85,0x75,0xa0, - 0xd3,0x6d,0x97,0x88,0x66,0x7d,0xe3,0xbe,0x13,0x5e,0xb4,0x2c,0xe0,0x57,0x00,0x66, - 0x31,0x1e,0x58,0x5e,0x1f,0x16,0x70,0x78,0x07,0xa4,0x9e,0x60,0xb9,0x31,0xf5,0xdc, - 0x74,0xd2,0x67,0x74,0x04,0x2c,0x57,0x24,0x99,0x05,0x4d,0xb8,0xde,0x20,0x3b,0x00, - 0x3a,0x83,0x58,0xa8,0x96,0x59,0x18,0xf7,0xf0,0x4c,0x60,0x33,0x63,0x68,0x7e,0xe2, - 0xde,0x31,0x3b,0x9d,0x44,0x20,0x32,0xc0,0xee,0x38,0xfa,0x09,0xf9,0xb6,0x94,0xc9, - 0xf2,0x3a,0xb0,0x09,0x8e,0x6c,0x86,0xaa,0x39,0x60,0x01,0x79,0x84,0xd9,0x88,0x27, - 0x21,0xbe,0xe2,0xad,0x6c,0x46,0x56,0x41,0xc2,0x9c,0x46,0xc9,0x82,0x6c,0x10,0x27, - 0x71,0x5c,0x95,0x2d,0x40,0x66,0xbe,0x71,0xc3,0x11,0x0c,0x8e,0x7b,0x7e,0x16,0x91, - 0xec,0xe0,0x2c,0xd9,0xb7,0x15,0xbe,0xb4,0x5c,0x28,0x14,0xfc,0xa0,0xbc,0xa7,0x3b, - 0x80,0x49,0x3f,0x80,0xae,0x59,0x13,0x05,0x1b,0xa8,0x41,0x99,0xf7,0xe4,0xc8,0x85, - 0x78,0xd9,0x0a,0x89,0xae,0x95,0xdb,0xde,0xc9,0xe9,0x05,0x12,0xa3,0x72,0x0f,0x55, - 0x7d,0xa6,0x4c,0x39,0x76,0xfe,0x1c,0xe5,0xd8,0x29,0x28,0x87,0xe8,0x51,0x23,0x1c, - 0x8d,0x78,0xfe,0x61,0x28,0x44,0x04,0xc9,0xf8,0x23,0xd0,0xf4,0x41,0x23,0x78,0x0c, - 0x05,0x9f,0xfe,0x91,0x65,0x8e,0xa8,0xd7,0xcc,0xc3,0x57,0x61,0xf8,0x8f,0xd1,0x5c, - 0xc8,0x36,0x91,0x1a,0x87,0xa0,0x40,0x63,0xc4,0x71,0x3f,0x83,0xfd,0x3d,0x46,0xec, - 0x89,0x67,0x43,0xc0,0xc6,0x09,0x62,0x2a,0xd1,0xf3,0x6d,0x10,0x35,0xaf,0x81,0x08, - 0xe5,0x64,0x9d,0xbd,0x09,0x51,0x10,0x27,0xa3,0x11,0xe0,0x3f,0x91,0x37,0xb5,0x31, - 0x00,0xcf,0x0b,0xe5,0x27,0x8b,0x62,0x7f,0x04,0xdd,0x25,0x38,0xd4,0x89,0x20,0x76, - 0x58,0x09,0x08,0x1c,0xa0,0x26,0x28,0xd9,0x50,0x6e,0xee,0x67,0x42,0x84,0x0a,0x15, - 0x47,0x5b,0x9f,0x09,0xbe,0x39,0xb9,0x38,0x51,0x3b,0x73,0x04,0xd5,0x1f,0x0d,0x49, - 0x1f,0x1e,0xca,0x0c,0xb1,0x0e,0x55,0x74,0xd9,0x77,0x47,0xb0,0x6f,0xd2,0x1c,0x25, - 0x97,0x9a,0xa4,0x1f,0x43,0x6f,0xde,0x03,0x43,0xe0,0x29,0x10,0x00,0x3f,0x41,0x73, - 0x0d,0x4d,0xc0,0x66,0xdc,0xb9,0x72,0x70,0xf7,0xbf,0x7c,0xf5,0x1e,0xf7,0xba,0xc3, - 0x7e,0x46,0x60,0xe2,0x9e,0x46,0x58,0x32,0x34,0x39,0xa4,0xeb,0x03,0xe8,0xc3,0x3c, - 0x84,0x55,0xa6,0x05,0x1d,0xe1,0x29,0xc7,0xe3,0x51,0x0d,0x1b,0x28,0x41,0x68,0x7f, - 0x77,0x6d,0xe1,0xf1,0x71,0x82,0xd3,0x87,0x58,0xe2,0x93,0x42,0xa5,0xfd,0xdd,0x16, - 0xc2,0x1e,0x38,0xe7,0xad,0x36,0x05,0x16,0x49,0x65,0x0a,0x5b,0x76,0x43,0x5c,0x7a, - 0xc4,0x1c,0x44,0xca,0x51,0x30,0xf3,0x04,0xb0,0x10,0xdd,0xd8,0xdf,0x7f,0xb9,0x48, - 0x51,0x58,0x47,0x5c,0xf7,0x47,0x5c,0xd2,0xdb,0x14,0x56,0x0b,0xc1,0x2d,0x45,0xf8, - 0x40,0x70,0x57,0x1c,0x8a,0x3f,0x2d,0x24,0xff,0xc7,0x82,0x98,0xbc,0x10,0x57,0x43, - 0x97,0x8a,0x54,0xc0,0x2b,0xdf,0x3d,0x76,0x23,0xd7,0x40,0x05,0x44,0x1c,0x94,0x92, - 0x46,0x00,0xe0,0x3e,0xa4,0xc9,0x01,0xfb,0xd0,0x34,0x1b,0x00,0x5b,0x1a,0x01,0x11, - 0x52,0xc0,0x69,0x9c,0x30,0x12,0x3a,0xa1,0xd3,0xd4,0x68,0x9b,0x52,0x26,0x82,0x28, - 0x4b,0x8d,0xa3,0x7f,0x07,0xab,0xe8,0xfe,0x79,0x56,0x51,0x6e,0x68,0x17,0x1a,0xfa, - 0xc8,0xd1,0xa8,0xf8,0x18,0x9e,0xb0,0xfb,0x58,0x9e,0x90,0x33,0x28,0xea,0xaa,0x45, - 0x61,0x35,0x86,0x60,0x14,0xbb,0x9a,0x88,0x49,0x5f,0xd9,0x86,0x3b,0x8d,0xfb,0x0c, - 0x1d,0x4e,0x96,0x41,0x56,0x34,0xb3,0x0e,0x68,0x85,0x68,0x71,0xee,0xde,0xe0,0x0a, - 0x27,0x7c,0x18,0xc1,0x92,0x14,0x14,0x15,0x10,0x26,0x52,0x67,0x8f,0xa0,0x88,0x24, - 0x92,0x00,0x5f,0x44,0xc5,0xd9,0x65,0x0c,0x54,0x9d,0xb9,0x37,0x80,0x8a,0xe8,0x90, - 0xc5,0xf0,0x10,0x28,0xb1,0x41,0xac,0x09,0x91,0x10,0xa1,0xf8,0x39,0xe7,0xc3,0x99, - 0x0f,0x50,0x45,0x8a,0xd4,0x47,0xf2,0x8e,0x1f,0xae,0xd4,0x07,0x7c,0x8b,0x9a,0x72, - 0xca,0x39,0x72,0x11,0xe6,0x7a,0x5e,0xd9,0xca,0xf0,0x78,0x4c,0x28,0x83,0x12,0x8d, - 0xd3,0x4d,0xcc,0xfe,0xaf,0x09,0x12,0x08,0x7c,0xbd,0x55,0x32,0x00,0x01,0x14,0xb9, - 0x69,0x11,0x34,0xb9,0x5c,0x24,0x01,0xd1,0x25,0xa8,0xb3,0xca,0x88,0x05,0x7d,0x94, - 0x2d,0x58,0x1a,0x9e,0xc5,0x25,0x56,0x52,0x3a,0x21,0xd2,0x4e,0x41,0xf0,0x84,0x0c, - 0xea,0x3f,0x46,0x92,0x6b,0xd2,0x57,0xb0,0x91,0x06,0x01,0x8f,0xf4,0x87,0xd5,0x26, - 0xb4,0x77,0x27,0x6f,0xde,0xb3,0x93,0xb3,0xb3,0x07,0xcd,0x67,0x6e,0x1c,0xaf,0xb6, - 0x9d,0xa1,0x63,0xbb,0x00,0x0b,0xfd,0x2a,0xeb,0x80,0x44,0x13,0x81,0x6b,0x90,0x6c, - 0x57,0xb2,0x3a,0xd5,0x34,0xbf,0x7a,0x25,0x24,0xa4,0x20,0xbd,0xd7,0x45,0xbe,0x4a, - 0x39,0x54,0xe8,0x8d,0x23,0x61,0x45,0x5a,0x55,0x0e,0x85,0x9e,0x14,0x37,0x2e,0xfc, - 0x79,0x40,0xe7,0xcc,0x84,0x34,0xba,0x8e,0xc0,0xf8,0x9e,0xc4,0x93,0x87,0x04,0xc4, - 0xf7,0x9a,0x42,0xff,0x58,0x3d,0x7e,0x29,0xaf,0x22,0xeb,0x11,0x55,0xa5,0x83,0x90, - 0x65,0x2a,0x7f,0x31,0x8a,0xb7,0x40,0x23,0xb2,0x99,0xd7,0x3c,0x92,0xaa,0xfe,0x05, - 0xd4,0x02,0x91,0x8e,0x43,0xcb,0x6e,0x78,0x57,0x19,0xc6,0xd2,0x1e,0xa2,0xf0,0xea, - 0x11,0x5d,0x44,0xe1,0xf2,0x2e,0x1e,0xa6,0x8f,0xb5,0xb5,0x78,0x1b,0x7d,0x74,0xa5, - 0xca,0x5d,0x3a,0xd5,0xc0,0x33,0x2f,0x06,0x5b,0x56,0x3b,0xd8,0x10,0xda,0x06,0x3a, - 0x69,0x19,0x47,0x82,0x10,0x20,0xa5,0xc3,0xf8,0xb6,0x3b,0x79,0xd4,0xb1,0x6c,0x51, - 0x05,0x55,0x7e,0x9d,0xf0,0x3f,0x66,0x3c,0x1c,0xdd,0xd9,0x6c,0xe8,0x86,0x9e,0x8c, - 0xe0,0x3c,0x7f,0x4d,0x66,0x93,0xd3,0x8f,0x6c,0x3a,0x03,0x09,0x10,0x24,0xe9,0xd1, - 0x44,0x08,0x35,0xa4,0xdd,0xf3,0x5b,0x97,0x9c,0x43,0x72,0xee,0xce,0xe6,0x09,0x00, - 0x0c,0xb4,0xd1,0x60,0xc6,0x59,0xe6,0x5e,0x93,0x2c,0x93,0x73,0xf2,0xf1,0x98,0xa8, - 0x38,0xc8,0x91,0x6c,0x06,0x22,0x76,0xa0,0xf9,0xac,0x20,0x7b,0xf7,0xdd,0xc0,0x59, - 0x89,0x1b,0x0f,0x2d,0x56,0x3e,0x07,0x66,0xbe,0xfb,0xf1,0x8b,0xf5,0xf0,0x92,0x25, - 0x57,0x50,0x1d,0x2a,0xa9,0x45,0x6b,0x3b,0xed,0x76,0x47,0x6a,0xe9,0x9d,0xbd,0xb6, - 0x54,0xd3,0xbb,0x7b,0xed,0xf6,0x9a,0xe8,0xf2,0x83,0x02,0x1d,0x33,0xaf,0xd7,0x1e, - 0xc1,0x70,0xae,0xf5,0xaf,0xba,0x7f,0x2e,0x3b,0xaf,0xf7,0xfd,0x97,0x40,0x74,0x1e, - 0x27,0xdc,0xc5,0x73,0x51,0xa0,0xc2,0x23,0x22,0xaf,0x15,0x49,0xb0,0x30,0xb7,0xe4, - 0xe3,0x4b,0xb1,0x7a,0x44,0x92,0xdc,0xd1,0xde,0xe1,0xb6,0xfc,0xa5,0xde,0xec,0xd7, - 0xde,0x3c,0xaf,0xbd,0x39,0xa8,0xbd,0x79,0x51,0x7b,0xd3,0x69,0xd7,0x5f,0x75,0xea, - 0xaf,0xba,0xc5,0x2b,0x65,0xe7,0x59,0x6b,0x65,0x4e,0x23,0x9a,0x35,0x48,0x15,0x7c, - 0x9d,0x39,0x8f,0x92,0x6f,0x38,0xe7,0xc6,0x81,0xfe,0x69,0x6b,0xd3,0x37,0x34,0x32, - 0xfd,0x09,0x92,0xa4,0xd8,0xf3,0x32,0x6a,0xb2,0x0a,0x09,0x35,0xda,0xde,0xcd,0x27, - 0x79,0xe2,0x27,0x99,0x0f,0xf2,0x7d,0x19,0x1f,0x57,0x4e,0xd2,0x1d,0x17,0x1b,0x46, - 0xed,0x97,0x76,0xd9,0xe6,0xdf,0x68,0xf1,0x73,0xc7,0x2d,0x6f,0xd4,0x68,0xed,0x5b, - 0x31,0xd2,0xdd,0xc2,0x4a,0xf9,0x2b,0xf3,0x78,0xe0,0xde,0x01,0x81,0x4c,0xd7,0xe3, - 0x07,0xc9,0x2d,0x55,0x68,0x1a,0xad,0x24,0x2d,0x6b,0x6f,0xee,0xd2,0x98,0xf6,0x3a, - 0x5d,0x1d,0x49,0xc4,0xa8,0x1e,0x01,0xc0,0xec,0xc1,0x71,0xad,0x85,0x33,0x95,0x63, - 0xb9,0x12,0xf3,0x3e,0x80,0x92,0x82,0xed,0x0c,0x8f,0x4e,0x4f,0x5e,0x32,0x93,0xec, - 0x86,0x21,0x13,0x81,0x28,0x8c,0x6c,0x12,0x53,0x3f,0xb3,0x50,0x26,0x3f,0xf4,0x8f, - 0x4e,0x27,0x6e,0x88,0x2e,0x6e,0x30,0x07,0xff,0xc6,0xcf,0xee,0x60,0x4e,0x99,0x90, - 0xdb,0xc4,0xd9,0x9c,0x38,0xac,0x2f,0xb6,0xab,0xc6,0x0c,0xb3,0xab,0xc0,0x28,0x4f, - 0x97,0x6c,0x9e,0xc3,0xe8,0xb6,0x98,0xf0,0xc8,0x05,0x2c,0x3e,0x9c,0x41,0x43,0xb3, - 0xa3,0x9c,0x1d,0xae,0x35,0x8f,0xce,0xbe,0x36,0x11,0xc0,0x00,0xe0,0xac,0x68,0xff, - 0x64,0x57,0x2e,0x1d,0x13,0xe3,0xd8,0xcf,0x7f,0xed,0x74,0xf7,0x6f,0x41,0xfc,0x1e, - 0x71,0x1f,0x55,0x50,0xb2,0x33,0x7d,0x93,0x61,0x13,0x53,0x77,0x92,0x5b,0xec,0xec, - 0x4f,0x8e,0xbf,0xdb,0xde,0xd5,0x97,0xe2,0xf5,0xab,0x77,0x4c,0xcd,0x42,0x0e,0xff, - 0xd5,0x2d,0x08,0xe8,0xa8,0xa3,0x8f,0x13,0x14,0xc5,0x79,0xe8,0xb5,0xa6,0x91,0x37, - 0x03,0xfd,0xea,0xed,0xfb,0x93,0x6f,0x38,0x8d,0x31,0x9f,0xfe,0xb5,0xa9,0xec,0x74, - 0xf5,0xa5,0x20,0xef,0x72,0x39,0x85,0xd7,0x51,0x32,0x47,0xaf,0x58,0x12,0x4a,0x00, - 0xb5,0xc6,0x63,0x7f,0xe4,0xb0,0x0f,0x20,0x6f,0x0c,0x84,0xbd,0x3a,0x6c,0x91,0xed, - 0xcf,0x4c,0x41,0xea,0x08,0x94,0x55,0x31,0x25,0xd9,0x46,0x19,0x11,0x53,0x74,0x78, - 0xf8,0x16,0x93,0xa5,0x81,0xad,0x3d,0x45,0xfd,0xc4,0x54,0x53,0xa8,0x50,0x9f,0xd2, - 0xa6,0xbe,0xbf,0xfb,0xb0,0x2c,0x4c,0x26,0x7e,0xd7,0xc3,0x58,0x1f,0x66,0x4e,0x35, - 0x73,0xed,0x6a,0x5a,0x4a,0x15,0x1c,0xf2,0x57,0x04,0xb1,0xad,0x4a,0x07,0x76,0x1f, - 0xa0,0xab,0x47,0x6d,0x00,0x31,0x08,0x76,0x6b,0xd0,0xd4,0x5c,0x4a,0x0b,0xa2,0xc8, - 0xcb,0x07,0x3a,0x49,0xd2,0xf5,0x06,0x3a,0xc6,0x6a,0xce,0xea,0xe1,0x76,0xf6,0x0f, - 0xd6,0x1b,0xae,0xcd,0x38,0xe8,0xa0,0x6c,0x67,0x23,0x24,0xc1,0x15,0xea,0x35,0x59, - 0x95,0xd6,0xe3,0x8e,0x9a,0xf8,0x4d,0x63,0x6c,0xd5,0x78,0x26,0xcd,0x18,0xa5,0x0f, - 0xa1,0xa7,0xc3,0x53,0x9c,0xae,0x96,0xc6,0x7f,0x8c,0x62,0x40,0xdc,0x29,0x1a,0x29, - 0xd0,0x2c,0x46,0xed,0x2a,0xc4,0xd6,0x24,0xea,0xb1,0xc0,0xfb,0xd4,0x61,0x6f,0xe5, - 0x29,0x10,0xc8,0xdd,0x29,0x25,0x65,0x71,0xc9,0x91,0xdb,0x4f,0xef,0x58,0x88,0x0e, - 0x6b,0x43,0x90,0xd9,0xd3,0x3e,0xa8,0x04,0x11,0x43,0xb7,0x6e,0x21,0x9a,0xc7,0x6e, - 0x92,0xa1,0xa7,0x78,0x71,0x46,0x1a,0x01,0xf8,0xe1,0x99,0xdc,0x05,0x9c,0xb5,0xf5, - 0xb4,0xee,0xc1,0x83,0xd8,0xf9,0xce,0xbd,0x95,0xb3,0x98,0xd0,0xe4,0xd7,0x5e,0x70, - 0x58,0xd7,0xca,0x22,0x97,0xf6,0x42,0xe3,0x1a,0xcb,0x55,0xdd,0xdf,0x75,0x98,0x3c, - 0xbc,0x67,0xf0,0xfb,0x11,0x28,0x8a,0xa3,0x95,0x08,0xfa,0xf8,0xe1,0x4a,0x1c,0xfd, - 0x16,0xa3,0x3e,0x70,0x56,0xa3,0xe5,0x0a,0x11,0x81,0x16,0x45,0x9b,0xce,0x2c,0x4c, - 0x47,0x51,0x8c,0xc7,0x1d,0x7f,0x72,0x15,0x1c,0xd5,0x42,0x7d,0x62,0x7f,0xe6,0x10, - 0xf3,0x04,0x34,0x53,0x3c,0xcd,0x01,0x25,0x55,0x9c,0xda,0xa4,0xe2,0x80,0x25,0x8c, - 0xd4,0x39,0x09,0xf5,0xe6,0x30,0x0d,0x2e,0x0f,0x9a,0xba,0x1f,0x26,0xa4,0xdd,0xbd, - 0x7d,0x8d,0x62,0xc2,0x2e,0xd3,0xe4,0x8c,0x25,0xc7,0xce,0x85,0x72,0x1f,0xc5,0x8e, - 0x28,0x9e,0xeb,0x06,0x62,0xc7,0x49,0x3b,0xf8,0x07,0x24,0x83,0x65,0x2d,0x40,0x7d, - 0x07,0x88,0xf9,0x53,0x20,0x57,0x47,0xef,0xc4,0x8f,0xa5,0xe5,0x60,0x57,0xa3,0x8a, - 0x02,0x05,0xe5,0xaf,0x65,0x25,0xd3,0x2c,0xf1,0x71,0x20,0xe7,0xf4,0xb7,0xae,0x6b, - 0xac,0x00,0xfd,0xcb,0x04,0xe3,0xf8,0x05,0x1e,0x28,0xd8,0x8b,0x13,0xde,0x00,0x95, - 0xc3,0x3b,0x06,0x04,0x2c,0xb9,0x2b,0xb9,0x10,0x4e,0x60,0x05,0x1c,0x26,0xfa,0x02, - 0x3a,0x33,0x60,0x63,0x8e,0xf4,0x46,0xf0,0x3a,0x5c,0xc4,0x80,0x06,0xeb,0x29,0xf1, - 0xce,0x83,0x2e,0x62,0xa0,0x78,0x2b,0x1c,0x2b,0x1a,0xad,0x54,0x64,0x22,0x7b,0xec, - 0x11,0xe7,0x1b,0x3a,0x1b,0xcc,0xee,0xfe,0xea,0xb1,0xe6,0xff,0x33,0xa7,0x99,0xea, - 0xfc,0xb2,0x8f,0xce,0xed,0x95,0xf3,0x4b,0x87,0xfd,0x40,0x1e,0x3b,0x83,0x6f,0x79, - 0x0c,0xf9,0xbf,0x7e,0xfa,0xf8,0x17,0x4e,0x08,0xff,0x9d,0x07,0x83,0xab,0x56,0x75, - 0xf9,0xa1,0x1f,0x72,0x53,0xea,0x75,0x33,0xad,0x9c,0xfd,0x01,0xca,0xc9,0x03,0x31, - 0xeb,0x2f,0x9d,0xdb,0xad,0x79,0x5c,0xf7,0x67,0xf4,0xfb,0x33,0x81,0x85,0xb0,0x77, - 0x57,0x4b,0x28,0x17,0xda,0x01,0x1d,0x48,0xd6,0x1c,0xd1,0x1a,0xc4,0xf2,0x59,0x1c, - 0x44,0xae,0x97,0xaa,0x93,0x16,0x72,0xb6,0x6a,0xe1,0x59,0xfb,0x84,0x6c,0x4e,0x49, - 0x34,0x65,0x2e,0xfa,0x7d,0x5e,0xe3,0x2e,0xc0,0xc0,0xb1,0xdf,0x01,0xd8,0x74,0x12, - 0x08,0xa5,0x95,0x18,0xe2,0xac,0x52,0x08,0x0a,0x15,0x80,0x3c,0xcb,0xc4,0xc9,0xbf, - 0xd4,0x03,0xce,0x78,0xe2,0x83,0xa4,0x35,0x42,0x1f,0xa6,0x20,0x9b,0x6c,0x93,0x85, - 0x9c,0xc9,0x96,0xa5,0xcf,0x17,0x1d,0x4f,0x7f,0x13,0x49,0x9f,0x20,0x2d,0x5a,0x7d, - 0x8c,0x46,0x53,0x4c,0x40,0xc2,0x5a,0x11,0x61,0x39,0x89,0x77,0x6e,0x8a,0x34,0x36, - 0x05,0x76,0x38,0x9a,0x08,0xcf,0x3c,0xd0,0x59,0xa4,0x6b,0x83,0x92,0x01,0x01,0x0b, - 0xa2,0xf9,0xb7,0x9b,0x85,0x1c,0xc1,0x9f,0x9b,0xc6,0x89,0xe7,0xb1,0xc4,0x9d,0x33, - 0x11,0x66,0x23,0x67,0x71,0x12,0xd0,0x81,0x36,0x28,0x58,0x14,0x74,0x38,0x83,0x29, - 0xe0,0x4e,0xa1,0x32,0x6a,0x45,0xb0,0xd2,0x37,0x5e,0x0e,0x68,0xf2,0xcf,0x4d,0xe2, - 0xa3,0xd0,0xde,0xbd,0xca,0x62,0x80,0x9a,0x89,0x67,0xf0,0x8a,0x4d,0xa2,0x6f,0x9c, - 0x34,0x58,0x4b,0x4b,0xf6,0x37,0x1c,0xfa,0xed,0xb7,0x50,0x1a,0x1f,0x12,0x6d,0x2f, - 0xa4,0xe1,0x25,0xd3,0xa7,0xba,0xda,0x1e,0xaa,0x06,0x98,0xdd,0x3e,0x5a,0xee,0xc1, - 0x93,0xb1,0x13,0x58,0xfa,0x8b,0x5f,0x97,0x95,0x90,0xc2,0x32,0x52,0x39,0x29,0x72, - 0x2b,0xfb,0xc9,0x52,0x89,0xa6,0x89,0x1a,0x57,0xd6,0xa9,0x50,0x91,0x10,0x05,0xd3, - 0xc7,0xc8,0xfe,0xe7,0x82,0x4c,0x28,0x0d,0xf3,0x11,0xaa,0xb4,0x60,0x95,0x65,0xcd, - 0xb4,0xa3,0xa4,0xe4,0xf6,0x5f,0x3f,0x0d,0x5a,0xe5,0xe1,0xe0,0xc6,0xf1,0xfa,0x2e, - 0x0e,0xb5,0x96,0x2f,0xd0,0x00,0x2b,0x94,0xd3,0xf3,0xf7,0xef,0xce,0xd6,0x38,0xfe, - 0xbb,0x38,0x93,0x4e,0xe4,0xeb,0xb2,0xe1,0x30,0x8b,0x97,0x70,0xdd,0x38,0x70,0x47, - 0x7c,0x12,0x05,0x20,0xef,0x62,0x44,0x41,0x14,0x60,0x59,0x10,0xb6,0xae,0x56,0xb2, - 0x5f,0x11,0xe6,0x02,0x1b,0x17,0x03,0x9d,0x14,0xbb,0x19,0x05,0xb0,0x3f,0x1f,0xe2, - 0xac,0xeb,0x1c,0x93,0x20,0x40,0x28,0x72,0xd9,0x3c,0xfb,0x70,0xfe,0xe6,0xd7,0x75, - 0x44,0xc4,0x4c,0x56,0x59,0x67,0x96,0xef,0xce,0x2f,0x9e,0xbf,0x7b,0x79,0x61,0xbf, - 0xdb,0x71,0xba,0x4e,0xdb,0x7e,0xd7,0xe9,0x38,0x1d,0xe7,0xa1,0x33,0xa6,0x06,0x2d, - 0xab,0x23,0x84,0x27,0x31,0xb6,0x9f,0x2f,0x4e,0x29,0xea,0xbb,0xf0,0xa0,0x5d,0x6d, - 0x03,0x96,0xe3,0x75,0x44,0x1d,0x75,0x6c,0x80,0xd1,0x65,0xc2,0x96,0xb2,0xbb,0xae, - 0x69,0xba,0x44,0x47,0x11,0x7f,0x18,0x08,0xaf,0x61,0x96,0x53,0x50,0x60,0x91,0xb0, - 0x35,0xf1,0x6c,0xce,0x4f,0xf0,0xe0,0xe0,0x1b,0x10,0xcc,0x34,0x9c,0xc6,0x6b,0x53, - 0x4b,0x6d,0x6f,0xe3,0xe0,0x40,0x24,0x9b,0xce,0x42,0xd2,0x17,0x1e,0x5c,0x54,0xec, - 0xc7,0xc9,0x2b,0x2c,0x11,0xfd,0xd7,0xd6,0x6e,0xe8,0x60,0xff,0xb1,0xda,0x0d,0x45, - 0x74,0xa8,0xb8,0xb7,0x28,0x5c,0x2d,0x95,0xa1,0x19,0xfd,0x0a,0x25,0x2d,0xaa,0x95, - 0x08,0xd0,0x0b,0x1f,0x9b,0xa2,0x89,0xfc,0xb8,0x96,0xa8,0x24,0xf9,0xd4,0xcc,0xd1, - 0x2c,0x8a,0x4a,0x1b,0x99,0x44,0x73,0x97,0x1c,0x50,0x07,0x41,0x66,0x77,0xc9,0xa4, - 0x2d,0xde,0xf2,0x79,0x11,0x1f,0xf0,0xda,0x47,0x8f,0x7a,0x68,0x1e,0xdf,0xbe,0x39, - 0x13,0xd2,0x1d,0x1d,0x0e,0x27,0xd1,0x0c,0x77,0x67,0x24,0xb8,0xa4,0x38,0xda,0xc5, - 0x01,0xa4,0xa0,0x27,0x3a,0xeb,0xc4,0x0c,0xfc,0xaf,0x85,0x9d,0x10,0x1d,0xfd,0xff, - 0x21,0xee,0x44,0x0d,0xf4,0xdf,0x10,0x78,0xf2,0x57,0xe2,0x4d,0xd6,0x34,0x4e,0xb5, - 0xbb,0xc5,0xb1,0xda,0x19,0x99,0x2e,0x85,0x87,0xd9,0x03,0x26,0x18,0xd1,0x35,0x96, - 0x27,0x9f,0xa8,0x06,0x2b,0x8b,0xb4,0xb0,0xcc,0xa6,0xcb,0xe4,0x0d,0x8a,0x8b,0x05, - 0xd5,0x01,0x69,0xbb,0xcc,0xde,0x85,0x16,0xd9,0x39,0x3a,0xb8,0xa2,0x3f,0x59,0x38, - 0xba,0xb3,0x96,0x5a,0x67,0xdc,0xdb,0xc2,0xfb,0xde,0x1c,0xba,0x98,0xd5,0x41,0x2f, - 0xdd,0x70,0xce,0xbb,0x2e,0x07,0x7e,0xe7,0xa2,0xe0,0x10,0xe2,0xd1,0xea,0x9a,0x27, - 0xab,0x4b,0xb0,0x6b,0x99,0x83,0xa1,0x17,0x7d,0x24,0xaf,0x10,0x93,0x7c,0x0c,0xc9, - 0x3f,0x24,0x24,0x0b,0x40,0x0d,0x6d,0xd6,0x6b,0x58,0x06,0x3f,0xc2,0x46,0x6f,0x95, - 0x23,0x62,0xbc,0xe8,0x2d,0xbd,0x26,0xe7,0x34,0x0c,0xa7,0x84,0xdf,0x35,0xdf,0xb7, - 0x75,0x29,0xa6,0x70,0x71,0x7a,0x2c,0xc9,0x7c,0x49,0x59,0xc8,0x25,0x57,0x11,0x51, - 0x7e,0xd0,0x4c,0x0b,0x08,0x9d,0x36,0x8d,0xc8,0xcd,0x64,0x00,0xbb,0x1e,0xe1,0xbe, - 0xd7,0xae,0xe5,0xf1,0xa0,0x4c,0x0a,0x95,0x14,0x1e,0x6d,0xe3,0xe8,0x01,0xaf,0x1a, - 0x4a,0x93,0x24,0xfd,0xc8,0xe8,0xe7,0x9f,0xc1,0x8a,0xd7,0x09,0xe7,0xa8,0x73,0xc4, - 0xcc,0xfc,0xe9,0x07,0x8b,0xba,0x3a,0x14,0x69,0x7f,0xc4,0xb4,0x62,0x37,0xb9,0xc6, - 0x14,0x26,0x71,0x11,0x96,0x4e,0xf6,0xcd,0x6d,0x51,0xa8,0xec,0xd6,0xe5,0xa7,0x9c, - 0x8c,0x7e,0xb9,0x3b,0x41,0x63,0x6b,0x78,0x66,0xc0,0x57,0x35,0xf7,0xe0,0x51,0x08, - 0x8a,0x7b,0x62,0xe1,0xea,0x27,0x21,0x7a,0xf4,0x61,0xa3,0x1c,0x8b,0xf5,0xa4,0x20, - 0xbb,0x76,0x40,0x13,0x6e,0x67,0x22,0x11,0xec,0x8e,0x67,0xce,0x0a,0xdc,0xd2,0x5c, - 0x08,0x0f,0xb7,0xf5,0x68,0xef,0x94,0x22,0xe1,0xdc,0x44,0x38,0x0c,0xea,0xa2,0x8d, - 0x48,0xb4,0x26,0x26,0xa6,0x1e,0xa8,0xc5,0x61,0xfe,0xaa,0x45,0x31,0xa7,0xc3,0xa3, - 0x92,0x10,0x93,0xc6,0x86,0x26,0xe5,0x34,0xef,0x29,0x96,0x4e,0xb5,0x7d,0x83,0x44, - 0x3f,0xc9,0x40,0x59,0x12,0x3b,0x14,0x1f,0x4a,0x4e,0x7d,0x0d,0x4d,0x4c,0x8b,0x71, - 0x55,0x36,0x21,0xbe,0x22,0x59,0x80,0xa7,0xb9,0x93,0xa8,0x88,0x98,0xd2,0x3c,0x05, - 0xd5,0x8e,0x23,0x10,0xf8,0xd5,0xe4,0x45,0x3d,0x36,0x8d,0x50,0x1f,0x16,0x89,0x7f, - 0xe6,0x13,0x1f,0xa4,0x30,0xd4,0x7b,0xb5,0xdc,0x3f,0xe4,0x6e,0x45,0xa7,0xe1,0x7e, - 0x56,0x07,0x5d,0x9e,0xb6,0xa6,0x70,0x52,0xa3,0xfc,0x35,0xf4,0xb2,0xe6,0x76,0x29, - 0x73,0xf1,0x88,0x68,0x83,0xe1,0x5d,0x1e,0x6d,0x28,0xa0,0x5a,0xe1,0xb2,0xba,0xfc, - 0xb8,0x02,0xb6,0x7f,0x85,0xd3,0x92,0xe4,0x44,0xac,0x36,0x4b,0x66,0x9c,0x96,0x24, - 0xad,0xb1,0xd9,0x7f,0x53,0xdf,0x14,0x8c,0x4d,0x7d,0x43,0xbf,0xa7,0xf8,0x90,0x77, - 0x5b,0x0e,0x88,0xce,0x61,0x8a,0x87,0xdc,0x00,0x16,0x7c,0xb7,0xd4,0x5d,0x37,0xcf, - 0x2f,0x44,0xd2,0x51,0x05,0x5b,0xe9,0x50,0x5e,0x97,0xa3,0xf3,0x2d,0x23,0x71,0x44, - 0x27,0x6d,0x98,0xb8,0x46,0x92,0x36,0xfa,0x79,0x54,0xc2,0x24,0x95,0xfa,0x37,0x8e, - 0xe2,0x16,0x9d,0x27,0x8a,0xcc,0x31,0x35,0x04,0x91,0xef,0x45,0x43,0xfc,0xd6,0xc7, - 0xa4,0x21,0x05,0x5a,0xd4,0x94,0x9d,0x5d,0xb4,0x14,0x57,0x13,0x82,0x69,0x89,0x24, - 0xca,0xe6,0x10,0x2d,0xb8,0xb1,0x9e,0xdb,0x86,0x02,0xa8,0x33,0x3f,0x9c,0xf1,0x7a, - 0x28,0x7a,0x9e,0x79,0x22,0x0a,0x1a,0x08,0x10,0x92,0x1f,0xca,0x2e,0xa3,0xd2,0xbd, - 0x38,0x2f,0xf2,0x9c,0xff,0x22,0xb7,0x6a,0x35,0x65,0xfc,0xf8,0xca,0x2a,0x28,0x60, - 0xe0,0x1f,0x5d,0x00,0x31,0xc7,0xa8,0x19,0xa5,0xb5,0x9a,0x11,0x69,0xb1,0xa7,0xc8, - 0xf9,0x03,0x7c,0x63,0xa9,0xb8,0x38,0x90,0x33,0xd1,0xf0,0x4b,0x06,0x65,0x1f,0x4f, - 0xc7,0x42,0x40,0x1f,0xe7,0x70,0x98,0x54,0xf5,0xa6,0x46,0x47,0xea,0x06,0x2a,0xf9, - 0x66,0xcc,0xdc,0xf4,0x9a,0x7b,0x36,0x90,0x02,0x15,0xbb,0xf7,0x73,0x8a,0x29,0x01, - 0xb2,0x09,0x2e,0xd4,0x1b,0x84,0x4d,0xc8,0x49,0x71,0x63,0xdb,0xf8,0xf5,0x3c,0x83, - 0x75,0x93,0x8a,0x03,0x6a,0x6e,0x43,0x8c,0xb5,0x93,0x48,0x02,0x73,0xd1,0xa6,0x45, - 0x71,0xfd,0xe7,0xee,0xd8,0x4d,0x7c,0x14,0xfb,0x4f,0x27,0x49,0x44,0x09,0x26,0xc4, - 0xd8,0xc5,0xb5,0x20,0x4e,0xb5,0xd2,0xdf,0x22,0x54,0xda,0x81,0x94,0xd6,0x67,0xf0, - 0x5c,0x05,0xbe,0x13,0x62,0xcc,0x12,0x20,0x18,0x93,0x2c,0x8b,0x7b,0xdb,0xdb,0x9d, - 0x17,0x5d,0xa7,0xb3,0x7f,0xe0,0xec,0x3a,0x1d,0xa2,0xba,0x79,0x9b,0x20,0x87,0x05, - 0x4b,0x62,0x0e,0x1a,0x6c,0x65,0x98,0x8d,0xd6,0xa8,0x4c,0x50,0xd9,0x25,0x61,0xe6, - 0x2a,0x15,0x49,0xeb,0x1c,0x43,0x17,0x09,0x22,0x7a,0x68,0xbd,0xc3,0xfe,0x5b,0xcf, - 0x53,0x00,0xec,0x32,0x01,0x5c,0x4a,0x31,0x3e,0x12,0x0f,0xcf,0x7c,0xe5,0xd5,0x5a, - 0x68,0x5a,0x5c,0x04,0x41,0x16,0x3a,0x99,0x5c,0x65,0x89,0x78,0x36,0xe6,0x78,0xc3, - 0x36,0x30,0xee,0x27,0xb9,0xf1,0x6f,0xb8,0xd0,0xda,0x64,0x0e,0x37,0xcc,0x65,0x54, - 0x1c,0xa7,0x55,0xf2,0x84,0xac,0xf6,0xcc,0x6f,0x2f,0xf5,0xcc,0xf7,0xa2,0xd1,0x6c, - 0x8a,0xa4,0xe8,0x8a,0x67,0xaf,0x02,0x8e,0x3f,0x7f,0xb8,0x7b,0xe3,0x99,0x9b,0x72, - 0x33,0x6e,0x5a,0x0e,0x01,0xf1,0x2d,0x10,0x17,0x07,0xd3,0x83,0xdc,0x70,0x73,0x13, - 0xf3,0x3f,0x6d,0xae,0xe9,0xca,0x0f,0xeb,0x3b,0xa4,0xc8,0x07,0xe9,0xc2,0xaf,0x31, - 0x62,0x8d,0x5e,0x48,0x6f,0xe5,0xb5,0xc8,0x84,0x28,0xab,0x13,0x0a,0x09,0x8c,0x1a, - 0x35,0xcb,0x93,0x50,0x10,0xf1,0xd8,0xd9,0x2d,0xd2,0x52,0xed,0xec,0x8a,0x13,0x27, - 0x8d,0x85,0x54,0xc9,0x07,0xc5,0x33,0x30,0xca,0x9e,0x56,0xea,0x57,0x86,0xca,0x08, - 0xf9,0xb9,0x94,0x84,0x26,0x27,0x1b,0xb1,0x5e,0x7e,0x9a,0x5e,0xe5,0x03,0x69,0x08, - 0xc3,0x2e,0x91,0x36,0xe3,0xa8,0xc8,0xb7,0x12,0x2f,0x41,0x58,0x2d,0x0f,0x55,0x77, - 0xbf,0x9e,0x81,0xae,0x34,0xd6,0x51,0x34,0x0b,0x33,0x63,0x19,0xe0,0x01,0xaf,0xfc, - 0x38,0x3b,0x7a,0x62,0x60,0x8c,0x8c,0x3c,0x4b,0xee,0x3f,0x81,0xd1,0xb1,0xf3,0x57, - 0xef,0x2f,0xde,0xbc,0x7f,0xf5,0x76,0x60,0x7c,0x2f,0xff,0x27,0x3f,0xa4,0xd9,0x60, - 0x81,0xe7,0xd3,0x3d,0xc3,0xb0,0x29,0x4a,0xd2,0xeb,0xd1,0xf2,0xdb,0x13,0x37,0x7d, - 0x07,0x32,0x9f,0x7c,0x02,0xf2,0x9f,0xf6,0xda,0xf6,0x68,0x7c,0xd5,0x0b,0x67,0x41, - 0x60,0xe3,0xa9,0x69,0x6f,0x71,0x6f,0x53,0xde,0x75,0xfc,0x21,0xa2,0xbc,0xd3,0xde, - 0xe5,0x27,0x3b,0x24,0x49,0x0f,0x4a,0x93,0xb8,0x88,0x46,0xb7,0x04,0x1e,0x80,0x62, - 0x64,0xbd,0x05,0x8a,0xb5,0x54,0x06,0x05,0x52,0xf8,0x71,0x6f,0x23,0xa7,0xbb,0x00, - 0x80,0x70,0xcc,0xbf,0x2d,0x9e,0x44,0x8d,0xfb,0xfe,0x93,0x27,0xe3,0x59,0x28,0x8c, - 0x1b,0xdf,0x99,0xa9,0xb5,0x90,0xe8,0x99,0x63,0x38,0xc8,0x29,0xc9,0xdd,0x39,0x69, - 0x67,0x51,0x02,0x05,0xee,0xb5,0xf2,0x7a,0x85,0x93,0x24,0x71,0xef,0x80,0x35,0x47, - 0x59,0x84,0x0c,0xdc,0x49,0x03,0xa4,0x5b,0x23,0x17,0xa4,0xb2,0xe6,0xb6,0x50,0x5e, - 0x4b,0x2d,0xbd,0x3d,0x62,0x89,0xe6,0xd4,0x5a,0x20,0xcc,0xb2,0xc1,0x77,0xa6,0xf1, - 0x4c,0x70,0x49,0xab,0x9f,0x39,0xb8,0x55,0x4f,0xe5,0x95,0x49,0x53,0x78,0x2e,0xf6, - 0x17,0x30,0x11,0xd3,0xc0,0xcd,0x05,0xe5,0xc8,0x8c,0x89,0x73,0x43,0x35,0x2a,0x73, - 0x3e,0x4f,0xb0,0xea,0xe7,0xc9,0x00,0xc0,0xa6,0xde,0xaa,0xee,0x4c,0x6b,0x91,0xd5, - 0x77,0xa9,0x6c,0xe8,0xde,0xee,0xee,0xb7,0xdb,0xfa,0xe0,0x40,0x86,0xd1,0x66,0x8b, - 0xe7,0xf9,0xe1,0x15,0xbc,0x80,0x7a,0x64,0x9f,0x34,0xb7,0x2f,0x37,0x0e,0x8f,0x8c, - 0x4f,0xdb,0x57,0x76,0xde,0xc1,0x48,0x15,0x5f,0x18,0x1b,0x46,0xcf,0x20,0x2b,0xb1, - 0x61,0x1b,0x87,0xf8,0x1b,0x74,0x67,0xf8,0x79,0x84,0x3f,0xaf,0xf0,0xe7,0xa6,0xb1, - 0x09,0x3f,0xff,0x98,0x45,0xf0,0x70,0x7f,0x39,0xfa,0x74,0x0f,0x7d,0x17,0x9d,0xbb, - 0xb1,0x6f,0x62,0x58,0xa8,0x0d,0xfa,0x32,0x0c,0x02,0xb0,0x12,0x7f,0x0c,0xf0,0x3f, - 0x5f,0xbf,0x2e,0xee,0x31,0x2d,0x9f,0x3f,0x36,0x9f,0xe2,0x33,0x80,0x1c,0xc4,0x4d, - 0xab,0xf8,0x89,0x84,0x1c,0x94,0x0a,0xa0,0xc8,0x46,0x9f,0x6d,0x6f,0x53,0xe6,0x4b, - 0xfa,0x90,0xa2,0x65,0x15,0xb6,0xc8,0x8f,0x17,0x17,0x67,0xec,0xe4,0xec,0x0d,0xfb, - 0xdb,0xab,0x8b,0x94,0xb9,0xa3,0x24,0x02,0xb2,0x9c,0x02,0x6d,0x86,0x9e,0x53,0xd1, - 0x32,0xb5,0x96,0x09,0x00,0x6e,0x6c,0x48,0x7e,0x7a,0x32,0x8c,0x12,0x5a,0x12,0xcc, - 0xa2,0xc9,0x13,0x1a,0x16,0xc3,0x1e,0x2e,0x80,0xfa,0x16,0x59,0x3a,0x45,0x04,0x26, - 0x8a,0xd1,0x2e,0x1b,0x73,0x3c,0x98,0x53,0x96,0x36,0xf4,0x77,0xb8,0x11,0x69,0x12, - 0x5c,0x60,0x74,0xae,0xa7,0x59,0xda,0xfa,0xaa,0x31,0x17,0x7b,0xc1,0x10,0x4f,0x62, - 0xec,0x2e,0xf6,0xb4,0x99,0x02,0xe5,0xcb,0x92,0x3b,0x72,0x07,0xa3,0x10,0xb7,0x14, - 0x25,0x6e,0xa4,0xf2,0x54,0x0b,0xd1,0x67,0x94,0x05,0x03,0x34,0xae,0x55,0x06,0x69, - 0x5a,0x7d,0x9a,0x0b,0xa6,0xf1,0x71,0x83,0x01,0x94,0x92,0x3f,0x45,0x7f,0xcd,0x68, - 0x82,0xa5,0x68,0x18,0x26,0xe0,0x85,0x0e,0x0a,0xca,0x87,0x78,0x0f,0xff,0x2a,0x9a, - 0x8e,0xd3,0xd3,0x16,0xca,0xc1,0x80,0xe8,0xa2,0x25,0x05,0x22,0x00,0x68,0x22,0x4f, - 0x3f,0x07,0x83,0xc1,0x6e,0xbb,0x63,0x2d,0x10,0xeb,0xde,0x62,0x42,0x24,0x18,0x60, - 0x06,0x12,0xc0,0x9c,0x2c,0x83,0xaf,0x92,0x04,0xf6,0x9c,0x81,0x24,0x03,0x50,0x92, - 0xea,0xca,0x9e,0x12,0xe7,0xf7,0x14,0xc7,0x56,0xe9,0xe1,0x77,0x6b,0x81,0x78,0x90, - 0x38,0xd1,0xf5,0xc6,0x86,0xea,0xe3,0xe9,0x60,0xd0,0x6d,0x77,0xad,0x6a,0xb3,0xbf, - 0x63,0xd2,0xbe,0x28,0xf9,0xfa,0xd5,0x34,0x08,0x03,0x8c,0x2d,0x55,0xc1,0xca,0x99, - 0xd4,0xef,0xf7,0x62,0x8a,0x74,0x73,0x56,0x8e,0x8d,0x71,0x04,0xdb,0x94,0x66,0x89, - 0x29,0xdf,0xf2,0x3d,0x91,0x23,0xe9,0x62,0xca,0x41,0x2c,0xf2,0x7a,0xc6,0xd9,0x87, - 0xf3,0x0b,0xc3,0x16,0x59,0xc2,0xd2,0xde,0xc2,0x90,0xfb,0xb7,0x75,0x01,0x04,0x02, - 0x30,0x1f,0x43,0x6c,0x64,0xb4,0xf8,0x36,0x4e,0xc7,0xb8,0xa7,0x06,0x7b,0x7f,0x3f, - 0xff,0xf0,0xde,0x49,0x69,0x87,0xf9,0xe3,0x3b,0x93,0x3a,0xa9,0xee,0x87,0x38,0xb8, - 0x7b,0xcd,0x61,0xb0,0x09,0xaa,0x69,0x08,0x56,0x20,0x46,0x9b,0x97,0xca,0x4e,0xf6, - 0x09,0x18,0x30,0x60,0xd7,0x2b,0x40,0xf3,0x02,0x38,0x3c,0x90,0xf0,0xe7,0x81,0xb6, - 0xef,0xb3,0xe8,0xea,0x2a,0x80,0x7d,0x4f,0xaa,0x96,0xfd,0xd4,0x84,0x57,0x48,0x87, - 0x37,0xde,0x93,0xd1,0x1f,0x2a,0x39,0xd8,0x28,0x20,0x06,0xbe,0xb6,0xac,0x1c,0x1e, - 0x02,0xcd,0xc8,0x15,0xe8,0x14,0x74,0x75,0xa2,0x56,0x35,0x57,0x46,0x4b,0xee,0xcc, - 0xbc,0x98,0x95,0xff,0x5a,0x63,0x08,0x66,0xa7,0x7b,0xf0,0xb5,0xbb,0xb7,0x2f,0x7b, - 0x85,0x76,0xe0,0x8b,0x64,0x19,0x96,0x00,0x7a,0x1f,0xad,0x3e,0xb0,0x4b,0x3e,0x80, - 0x02,0xfa,0xe3,0xc5,0xbb,0xb7,0x20,0xa6,0x62,0x30,0xa7,0x74,0xd2,0xc5,0xdd,0x75, - 0xc7,0x5e,0x9d,0x9f,0xed,0x74,0x59,0x82,0x06,0x64,0xda,0x9a,0x63,0x3f,0x99,0xce, - 0xdd,0x04,0x6d,0xd4,0x78,0xf2,0x97,0xa2,0xfc,0x44,0xe9,0x4a,0xfc,0x4c,0x34,0xe6, - 0x8e,0xb2,0x19,0x6c,0xb4,0x3b,0xf4,0x5d,0xc2,0xdc,0x36,0x64,0x7a,0x18,0x26,0xbe, - 0x77,0xc5,0xfb,0xb8,0x59,0xa3,0x04,0xa4,0x76,0x17,0x5a,0x1e,0xce,0xfc,0xc0,0x4b, - 0x99,0x20,0x9d,0xa2,0x58,0xe2,0x52,0x53,0x19,0xec,0x71,0xd1,0x18,0x62,0x36,0x6e, - 0xf6,0x91,0xd8,0x83,0xd2,0x5f,0x8a,0xe4,0x37,0x3c,0x71,0x0c,0x3d,0x24,0x10,0x98, - 0x51,0x80,0xd6,0xd4,0x91,0x70,0x45,0x4b,0xc8,0x85,0x3b,0x04,0xa8,0x6e,0x3e,0xd3, - 0xee,0xcb,0xb9,0x2c,0x05,0x04,0xc2,0x2a,0xf7,0x01,0x28,0xb2,0xac,0x25,0xff,0x2a, - 0x4a,0x4e,0x30,0x23,0x1e,0x22,0x7c,0xa3,0xac,0x2a,0xcb,0x20,0x70,0xe7,0xa5,0x30, - 0x43,0xc4,0x03,0x45,0x28,0x05,0xd6,0x03,0x65,0xaa,0x26,0x9c,0x07,0x7b,0xd5,0x92, - 0x53,0x59,0x25,0x26,0x67,0x3c,0x26,0x55,0x15,0x79,0xae,0x8e,0xfd,0xab,0x99,0x88, - 0xfd,0x15,0x47,0x13,0x50,0xad,0x88,0xf8,0x8d,0xa4,0xba,0xf6,0xf6,0xe4,0xbd,0x63, - 0x68,0xdd,0x17,0x89,0x7f,0x2a,0xdd,0x8b,0x34,0x40,0xd0,0xa5,0xc8,0x65,0x13,0xca, - 0x34,0x37,0x85,0xab,0x89,0xde,0x8a,0x96,0xb1,0xa3,0xd2,0x8c,0x08,0x86,0x2e,0x95, - 0xd5,0x83,0xa7,0x2d,0xc7,0x87,0x59,0x26,0x88,0xbb,0xd0,0x65,0x25,0xe3,0x46,0x2d, - 0x90,0xba,0xa1,0x19,0x0a,0x1c,0xb6,0x1c,0x25,0x9a,0x6b,0x64,0x5b,0x46,0x98,0xdf, - 0x23,0xed,0xc2,0x24,0xd3,0xad,0xfc,0x7f,0x8c,0xc4,0x66,0xed,0xf9,0xfb,0xed,0x82, - 0xb6,0x08,0x53,0x2f,0x52,0x0a,0x24,0x68,0xc6,0x36,0xfc,0x77,0x5b,0xba,0xaa,0x54, - 0xc9,0x6d,0x2a,0x09,0x0a,0xac,0x2d,0x8a,0x77,0x83,0x94,0xfe,0xf4,0x71,0xa9,0x49, - 0xca,0x83,0x17,0xf8,0xa3,0x5f,0x6c,0xdb,0xc1,0xd3,0xa7,0x50,0x08,0x7e,0xf4,0xe5, - 0x26,0x1f,0x20,0x97,0x8e,0xdd,0xa1,0x1f,0xf8,0x99,0xcf,0x81,0x9b,0xb7,0xf1,0x8b, - 0x90,0xee,0xe0,0x5b,0x82,0xd1,0x7e,0x53,0xfe,0x99,0x9e,0xf1,0x23,0xf5,0x87,0x10, - 0x90,0x99,0x1f,0xcb,0xd0,0x4e,0x1d,0x7c,0xf9,0xf5,0xab,0xa1,0x94,0x30,0x43,0xaf, - 0x80,0x79,0x20,0xab,0xe5,0x91,0x2c,0x6c,0x19,0xec,0x7b,0x20,0xff,0xa9,0x33,0x9e, - 0xe7,0x3f,0x29,0x57,0x76,0x3f,0x67,0xa7,0xc3,0x81,0x68,0x42,0xa4,0x80,0xb4,0xfa, - 0xc3,0x06,0xc9,0xa9,0xc0,0x6c,0x41,0xab,0x04,0x4c,0x06,0xa0,0x55,0xa0,0xfa,0x66, - 0x58,0x8b,0x61,0x05,0xc1,0x30,0x77,0x9f,0x51,0x6a,0x4a,0x88,0x72,0xa2,0xf8,0x3d, - 0xfa,0x99,0x57,0xeb,0x00,0xf6,0x1a,0x82,0x11,0x56,0x58,0x40,0x5f,0xe7,0x8e,0x05, - 0xbc,0x8f,0x8b,0x35,0x94,0xc2,0x33,0xee,0x48,0x8c,0xc5,0xd4,0x19,0xbc,0x94,0xd2, - 0x0a,0xf1,0xfa,0xfe,0xde,0xea,0xe9,0x8f,0x82,0xe8,0x57,0x56,0x3f,0x2e,0x56,0x5f, - 0x16,0x1d,0xc4,0xea,0xd7,0xd7,0xaf,0x97,0x9f,0x0a,0x48,0x64,0x35,0x50,0x20,0x55, - 0xfc,0x85,0xa2,0xd1,0x4d,0xc9,0xd8,0xc9,0xab,0x1e,0x99,0x77,0x8e,0x3d,0x25,0xa9, - 0xa0,0x28,0xb4,0x28,0xc2,0xc2,0xef,0xc5,0xb0,0x2a,0xf3,0xe1,0x85,0x98,0xc1,0x1d, - 0xe9,0x10,0x09,0x32,0x80,0x14,0x23,0x16,0xcb,0x70,0xc1,0xd0,0x8e,0x39,0xf1,0x76, - 0x05,0x50,0xc4,0x5b,0x42,0xce,0x02,0xf2,0x6d,0xf4,0x35,0xd1,0x08,0x77,0x88,0xdd, - 0x6d,0x93,0x7c,0xac,0x84,0x83,0x62,0xff,0xe0,0xa0,0xcd,0x1b,0x6b,0x71,0x69,0x3c, - 0x53,0x99,0x3d,0x6d,0xfc,0x29,0xb3,0x2f,0xd2,0x6f,0x0c,0x25,0xff,0x54,0xe7,0xd0, - 0xbe,0x87,0xc3,0x83,0xff,0x36,0x13,0x4d,0xe8,0xe9,0x3b,0x68,0x79,0x39,0xea,0xcd, - 0x62,0xe0,0x0f,0x1c,0x4d,0xb6,0x3f,0x80,0xb2,0xa8,0x4b,0xef,0x1a,0x24,0x09,0xaa, - 0x66,0x31,0x38,0xab,0x46,0x1d,0xe8,0x7d,0x85,0x3c,0x20,0xd4,0xb4,0x14,0x9e,0x16, - 0x8e,0xeb,0x15,0x5a,0x21,0xdf,0x52,0x90,0x0c,0x48,0x0a,0x2a,0x31,0x66,0x21,0xff, - 0xf3,0x1b,0x5a,0x09,0x7e,0x53,0xb3,0x58,0x2a,0xf1,0x61,0x98,0x85,0x83,0xa2,0x65, - 0x34,0x44,0xc3,0xd6,0xca,0x42,0x47,0xdd,0x9b,0x33,0x40,0x0b,0x6a,0xbf,0x28,0x81, - 0xb9,0x06,0x2a,0xab,0x46,0x3b,0x5c,0x08,0x9e,0x02,0xcf,0x25,0xc8,0xff,0x82,0x10, - 0x46,0xd8,0xd3,0x24,0x89,0x2d,0xd4,0x91,0x66,0xaf,0x18,0x12,0x1e,0x67,0x5a,0x0e, - 0x1d,0xf3,0x81,0x8c,0x66,0x41,0xdd,0xba,0xd4,0xfb,0xa0,0xd0,0x2a,0x37,0x60,0xda, - 0x53,0x22,0xa8,0xfd,0x7b,0xef,0xf7,0xfb,0xe6,0xf6,0x6e,0x25,0x7a,0x97,0xe0,0x44, - 0x0a,0x74,0xbe,0xe1,0x6e,0x1d,0x14,0xac,0x01,0x43,0x61,0xb1,0x73,0x5a,0x5c,0x01, - 0xa5,0x36,0x6e,0x04,0x62,0x79,0x53,0xc9,0xed,0xb8,0x0a,0xf0,0xb7,0x4e,0x2e,0x40, - 0x8b,0x54,0xa6,0x72,0xd3,0x18,0xfd,0xc6,0x4d,0x09,0xd4,0xaf,0x3e,0xde,0x95,0x0b, - 0xab,0x6d,0x47,0xea,0xc7,0xa0,0x8d,0x06,0xff,0xe6,0x48,0x5d,0x9c,0x17,0x2e,0x48, - 0x24,0xcf,0xd7,0x1f,0xde,0x01,0x02,0xd4,0x08,0x96,0x0e,0x0d,0x31,0x00,0x9d,0xc0, - 0x34,0x0c,0xf9,0xbe,0xbe,0x37,0x84,0x98,0xc1,0xd0,0x55,0x95,0x6d,0xb3,0x21,0xfa, - 0x31,0x2c,0xe1,0xa3,0xa3,0xf1,0xd5,0x3f,0xdc,0xc0,0xbc,0xb6,0x16,0x28,0x06,0x4e, - 0xdd,0x18,0x94,0xbc,0xdf,0x80,0x8a,0xfc,0x46,0xce,0xbe,0x99,0x94,0x5a,0x64,0xee, - 0x33,0x11,0xf6,0x2e,0x30,0xcd,0x16,0xee,0x10,0xc8,0x20,0xc7,0x57,0x72,0xa3,0x8c, - 0x06,0xe2,0xd1,0x9e,0xda,0xc9,0x60,0xe4,0x90,0xb8,0x61,0xff,0x01,0xbf,0x90,0x97, - 0x82,0x0a,0x2c,0xac,0x21,0x68,0xea,0x98,0xc3,0x4b,0x14,0xa9,0xa4,0xc4,0x6c,0x4e, - 0x07,0xd7,0x0e,0x05,0xdf,0x9b,0xdb,0xff,0xc2,0xc2,0xe6,0x65,0xa7,0xf5,0xe2,0x93, - 0xf5,0x4f,0xc7,0xfc,0xe7,0x7c,0xcb,0xfa,0x6e,0x1b,0x84,0x6b,0xb2,0x37,0xa4,0x03, - 0xf3,0x0f,0x47,0xb2,0xd9,0xcb,0x4f,0xd6,0xe5,0xd6,0xf4,0xb2,0xf3,0xa9,0xd5,0xf9, - 0x44,0xfa,0x35,0x16,0xb8,0x19,0xa4,0x97,0xd3,0xcb,0xee,0xa7,0x4f,0x4a,0x39,0xba, - 0x19,0x0c,0xd0,0x4c,0x73,0x6c,0x18,0x3d,0x69,0x0e,0xb8,0x21,0xe4,0x11,0xee,0xa7, - 0x38,0x71,0xc2,0x24,0x74,0xdb,0x16,0xd9,0x1c,0x7a,0xf9,0x3e,0xc0,0xc7,0x3e,0x7d, - 0xc0,0xd4,0x0a,0xbd,0xb2,0x51,0x21,0x71,0xe0,0xa5,0x25,0x3f,0xc3,0x86,0xac,0x7f, - 0x8e,0x42,0xc9,0xef,0xa8,0x4c,0x76,0x5b,0x2f,0x92,0xdd,0xca,0x06,0xdc,0x71,0xfd, - 0xa3,0x3b,0xd6,0xab,0xab,0x48,0xdb,0x5a,0x31,0xf9,0x41,0x36,0x94,0x2d,0x2b,0x96, - 0xa9,0x62,0x45,0x93,0x18,0x33,0x5a,0x4c,0x16,0x9e,0x8e,0xd1,0x9d,0xb1,0x47,0x7e, - 0x0e,0xa2,0xb5,0x52,0x78,0x66,0x51,0x54,0xbc,0xd0,0x4b,0x6b,0xe3,0xac,0x86,0x42, - 0x16,0xd5,0xe0,0xe5,0xe7,0x95,0x55,0x45,0x60,0xa1,0xd6,0x0f,0xbd,0x68,0x2e,0x5c, - 0x04,0x4f,0xd5,0xa6,0x4a,0x9f,0x3e,0xc3,0x27,0xab,0xa9,0xbc,0x8a,0x5e,0x5a,0x5e, - 0xed,0xb3,0x28,0xd1,0x5c,0x3b,0x0f,0x12,0x5a,0x51,0x5f,0x95,0xd1,0x5b,0xd0,0x83, - 0x6b,0x8a,0x19,0xe2,0xdb,0xcf,0xe2,0xad,0x56,0xb6,0x1a,0x03,0x58,0x47,0x0d,0x2a, - 0xf0,0x59,0x15,0xa8,0x8f,0xf4,0xc1,0x16,0xc4,0x60,0x57,0xb4,0x53,0xb8,0x0d,0xa9, - 0xba,0x73,0x7a,0xec,0x17,0x5f,0x91,0x36,0x17,0x1f,0xe1,0x49,0xff,0x96,0xfb,0xb0, - 0x68,0x25,0xd4,0x3b,0xad,0x1b,0x3d,0xee,0x44,0x95,0xfc,0x43,0xbe,0xe8,0x17,0x25, - 0x28,0x3e,0xa3,0xf8,0x8e,0x8f,0xd5,0x46,0xa4,0xf0,0x5f,0x14,0x12,0x2f,0x6a,0x38, - 0x5d,0x72,0x07,0x2f,0x4a,0xcb,0x37,0xcd,0xc8,0x96,0x7b,0x5e,0x17,0xe5,0xe1,0xa9, - 0xb9,0x69,0x6d,0x9f,0xff,0x01,0x0f,0xda,0x97,0x44,0xff,0x92,0xdc,0xae,0xe8,0x6a, - 0xd9,0xba,0xfd,0xe1,0x14,0x4b,0x55,0x94,0x46,0xcf,0xd3,0xa2,0x61,0x78,0xaa,0xc1, - 0x97,0x22,0x44,0x34,0xf0,0xe2,0xb3,0xd6,0x80,0x08,0xa6,0x28,0xbe,0xd3,0xb3,0x4e, - 0xba,0x94,0xe7,0xa7,0x36,0x31,0xf9,0xaa,0x5f,0x2a,0xa0,0x5c,0x2d,0x6b,0xc3,0x56, - 0x05,0x3e,0x8b,0x02,0x3a,0xa2,0x91,0xa7,0xa3,0xb6,0x6a,0xf0,0x58,0x03,0x6c,0xc5, - 0x4b,0xb1,0x5c,0xfa,0x73,0xfe,0xa1,0x6c,0xe7,0x43,0x99,0x4b,0xb7,0x0c,0x07,0xc8, - 0xe7,0xa4,0x75,0x09,0x85,0xee,0xc0,0xa1,0x23,0xec,0x81,0xe6,0x7e,0x29,0xcd,0x35, - 0x64,0x7a,0x12,0x37,0x4b,0x56,0x16,0xa9,0xf8,0x4e,0xac,0xb0,0xd4,0x41,0x8a,0x87, - 0x4c,0xd0,0xac,0x7d,0xb3,0xb2,0x8b,0xa2,0xed,0x81,0x79,0x83,0x9f,0xa2,0x50,0xa8, - 0x63,0x24,0xcb,0xa8,0x96,0x07,0x37,0xa5,0xb6,0x89,0xaa,0x9e,0x46,0xd3,0x61,0x24, - 0x74,0x5e,0xe4,0x74,0xf1,0x00,0x58,0xde,0x77,0x20,0x9c,0x5c,0xca,0xa4,0x1e,0x9f, - 0x8c,0x25,0xe6,0xb4,0xf8,0x52,0xb3,0x8f,0x41,0xb9,0x81,0xea,0x45,0x58,0xc8,0xe4, - 0xa4,0x62,0x07,0x33,0xc5,0x6c,0x81,0xe8,0xbf,0x15,0x3b,0xc3,0xb9,0xfc,0x91,0x8e, - 0xe5,0x8f,0x51,0x52,0x1a,0x11,0x8a,0x16,0xa7,0x24,0x65,0x88,0x11,0x69,0xd6,0x45, - 0x21,0xe0,0x08,0x11,0xa4,0xa6,0x8c,0x8f,0x0a,0x75,0x0c,0x04,0x85,0xc1,0x08,0xf5, - 0x68,0xdc,0xee,0x38,0x99,0x54,0x5e,0x5e,0x3b,0x10,0xb6,0xf2,0xaa,0x2d,0x6d,0x41, - 0x86,0xac,0x73,0x64,0xfe,0x20,0x91,0x15,0x0e,0xe1,0x20,0x8c,0xeb,0x1f,0xf2,0x5c, - 0x78,0x52,0x46,0x2c,0x40,0x74,0xbd,0x14,0x42,0xf2,0xcc,0x16,0xc1,0x7a,0x3d,0xd0, - 0x80,0x75,0x6d,0xdf,0x0c,0x72,0x09,0xa9,0x2f,0x4b,0xc9,0x11,0x5f,0x5e,0x7f,0x82, - 0x55,0xd2,0x56,0xbd,0x5f,0xb2,0x58,0x2a,0xa5,0x87,0x66,0x64,0xe4,0x95,0xb1,0x8b, - 0xc0,0x1d,0x0e,0xa8,0x2c,0xa6,0xa4,0x07,0x81,0xd0,0x19,0xc3,0x77,0x92,0x9f,0x26, - 0x03,0xf8,0xb6,0xb1,0x01,0xff,0xa9,0x9c,0xe8,0x18,0x74,0xf7,0xa4,0x41,0x96,0xb4, - 0xd1,0xc4,0x1a,0x4d,0x4a,0xf6,0x33,0x65,0xeb,0xd4,0x27,0xbb,0x02,0x1f,0x72,0x24, - 0x93,0xfb,0x53,0x4a,0x6a,0x15,0x2c,0x59,0x3d,0x1f,0xd5,0xa1,0x04,0x86,0x68,0x61, - 0xa0,0xe3,0x69,0xdf,0x1d,0xff,0x08,0xe4,0xca,0xb4,0x9a,0xd6,0x12,0xc6,0x65,0xe2, - 0x8c,0xfd,0x41,0xa7,0xef,0x1f,0x0e,0x72,0x63,0x4a,0xdf,0xdf,0xda,0x42,0x45,0x03, - 0x50,0x31,0x9d,0xe0,0x8a,0xbe,0x46,0x47,0x9c,0xd4,0xfc,0xae,0xbc,0xe2,0xb6,0x8f, - 0xc6,0xef,0xa6,0x42,0xc5,0xea,0x43,0x19,0x81,0x00,0x15,0x7d,0xb3,0xc1,0x54,0x3e, - 0x75,0x93,0xeb,0x97,0x38,0x2f,0x13,0x16,0xdc,0x2e,0x68,0x04,0xee,0xcf,0x62,0xb5, - 0xad,0x05,0x88,0x50,0xc0,0xab,0x99,0xc2,0x53,0x78,0x27,0xcc,0x1f,0xda,0x8b,0xc1, - 0xcd,0xbd,0xda,0xfc,0x56,0xa3,0x05,0x5b,0xc0,0xcf,0xbe,0xc6,0xf3,0x71,0x55,0x4f, - 0x19,0x8e,0xaf,0x91,0x1e,0x80,0x2c,0x68,0xe9,0xa0,0xab,0x0d,0xff,0xfe,0x89,0x3a, - 0xbd,0x79,0xfb,0x66,0x33,0x65,0xbf,0x79,0xb3,0xec,0x6e,0x74,0x37,0x0a,0xf8,0x6f, - 0x8c,0x4c,0x90,0x19,0xc9,0xec,0x68,0x16,0xc4,0xab,0xec,0x59,0x0b,0xcf,0xdc,0xf1, - 0x3a,0x1e,0x36,0x4f,0xfc,0x0c,0x93,0x42,0x88,0x84,0x34,0x9f,0x45,0x3e,0x15,0xe6, - 0xa6,0xd8,0x5e,0xa7,0xdd,0xde,0x36,0xbd,0x91,0xd5,0xea,0x38,0xec,0x5c,0x1a,0x83, - 0xd1,0x86,0x88,0x4e,0xed,0x80,0x2c,0x28,0xfd,0x63,0x3f,0x8c,0x3a,0x62,0x43,0x8e, - 0x57,0x62,0xb3,0xdf,0xdc,0xf1,0x6f,0xec,0x2a,0x3f,0xcb,0x17,0x1e,0xf8,0xd8,0x9a, - 0x48,0x6f,0x18,0x25,0x69,0x61,0xa5,0x4e,0xb8,0x9b,0xe2,0x99,0xc0,0x10,0x3d,0x31, - 0xe6,0xd2,0x23,0xc3,0x45,0x77,0xa2,0x28,0x54,0x97,0xb5,0x8d,0xf1,0xa0,0x17,0x7b, - 0x8e,0xc4,0x81,0x13,0x65,0xdd,0x1e,0x3b,0xda,0x89,0x82,0x84,0x8b,0xa2,0x81,0x9c, - 0xf4,0x72,0x91,0xf6,0x86,0xf6,0xc6,0x53,0x9e,0x9b,0xdc,0x45,0x09,0x77,0x3c,0x88, - 0xdd,0x24,0xe5,0xaf,0xd1,0x1f,0xd2,0xcc,0x4f,0x1f,0x44,0x92,0x1d,0x3c,0x80,0xa0, - 0x9d,0x20,0x08,0x70,0x49,0xb3,0xf3,0xd3,0xd7,0x74,0xed,0x92,0x09,0x72,0xf8,0xc6, - 0x86,0x3b,0x3e,0x1a,0xb4,0x8f,0x8d,0xff,0xc3,0x8c,0x2d,0xf3,0x9d,0x9b,0xc1,0xf6, - 0xc3,0x4b,0x6c,0x4c,0x80,0x1a,0x80,0xcd,0x1d,0x6f,0x75,0x2c,0x6b,0xbb,0xd3,0xb6, - 0xb6,0x8c,0xff,0xd0,0xc0,0x64,0xf4,0x04,0x0f,0xca,0x4f,0x56,0xeb,0xd6,0x08,0xf2, - 0x44,0xae,0x1b,0x23,0x68,0x6e,0xc1,0x80,0xdf,0x38,0x19,0x9d,0x07,0xf7,0x73,0x86, - 0x92,0x53,0xa6,0xe2,0x20,0x8f,0xb2,0x47,0x7a,0x33,0x61,0x20,0xe0,0x02,0x94,0x29, - 0x33,0x85,0x25,0x87,0xdd,0xa4,0x68,0x84,0x63,0xe9,0x04,0xcf,0x12,0x40,0xa7,0x4b, - 0xc9,0xff,0x26,0xbd,0x0b,0x47,0x8a,0x60,0x14,0x20,0xd9,0xdc,0xd2,0x7b,0xd8,0xda, - 0x34,0x1a,0x4f,0x68,0x22,0x3a,0xbe,0x8a,0x9e,0x0e,0x80,0x88,0x59,0x82,0xfe,0x45, - 0x76,0xce,0x5e,0x73,0xca,0x50,0x6c,0xaa,0x12,0x41,0xcd,0x0b,0xe2,0x3e,0xa3,0xbd, - 0xa8,0x6c,0x6a,0x25,0xf2,0x23,0xa7,0x27,0x54,0xba,0x12,0x59,0x59,0x8f,0xce,0xc1, - 0x20,0xa3,0xe5,0xbb,0xef,0xe6,0x69,0xbe,0xb1,0x05,0xe5,0x6a,0x18,0xb5,0xcc,0x81, - 0x88,0x84,0x3d,0x1f,0x4a,0x3a,0x50,0x66,0x6d,0xa4,0xc5,0x39,0xf0,0x11,0xf9,0x25, - 0xb8,0x65,0x9a,0x78,0x0c,0x35,0x40,0x67,0x38,0x86,0xf7,0x8c,0xa4,0x59,0x4e,0x01, - 0x37,0x36,0x52,0x47,0x78,0x53,0xa7,0x8e,0x08,0xd7,0x2d,0x31,0x9f,0xe9,0xa0,0xd5, - 0xe9,0x7f,0x3c,0x79,0xf9,0xe6,0xc3,0xe7,0xb3,0x8f,0xaf,0xce,0x5f,0x5d,0x9c,0xd7, - 0x67,0x16,0x03,0x61,0xc3,0x05,0x18,0x21,0x3c,0x5e,0xfd,0x61,0x8a,0x1e,0x05,0x74, - 0x62,0x0b,0x86,0x6b,0x4d,0x07,0xfe,0x7d,0xc1,0xad,0x24,0xad,0x9f,0x22,0x02,0x4b, - 0x82,0x3f,0xb5,0x7a,0xa6,0x9a,0x5f,0x89,0x0e,0x1d,0x1b,0x38,0x1f,0xa3,0x97,0x6a, - 0xdb,0x42,0xc8,0x54,0xf7,0x65,0xa3,0x46,0x85,0x28,0x29,0x9c,0x0d,0x07,0x1f,0x86, - 0x18,0xf0,0xea,0x20,0xa2,0x99,0x79,0xb3,0x72,0xaa,0xf9,0x9e,0x8c,0xe3,0x7f,0x88, - 0xdb,0x13,0x07,0x4f,0xbf,0x33,0x95,0xa5,0x51,0xb7,0x18,0xca,0x04,0x1f,0x69,0xed, - 0x6c,0x47,0x7a,0xa8,0x5a,0x0d,0x4b,0x4b,0x2e,0x01,0x76,0x78,0xd4,0x86,0xdd,0x9a, - 0x77,0x50,0xaa,0xd9,0x0a,0x2b,0xd6,0x9b,0x70,0xcb,0x0c,0x81,0xe4,0x76,0x8e,0x0d, - 0xe9,0x3c,0x0a,0x3b,0x56,0xb9,0x91,0x1a,0x65,0x16,0xa1,0x79,0xaf,0x2e,0x74,0xd9, - 0xa7,0x66,0xbc,0x11,0xee,0x11,0x86,0x70,0x70,0x05,0x3d,0xd1,0x6a,0x30,0xcf,0x10, - 0x87,0x5a,0x66,0x91,0x49,0x15,0x43,0xfb,0x31,0x9b,0x06,0xa6,0x6f,0x8f,0x6d,0x14, - 0x22,0x6d,0x8a,0x1c,0xb0,0xf1,0x08,0x48,0x93,0xb7,0x36,0x4b,0x11,0x08,0x2c,0x1d, - 0xb7,0x36,0xb7,0xc6,0xb0,0x6f,0x55,0xe8,0xc1,0xe6,0x16,0xfd,0xdd,0xda,0x6c,0x0c, - 0x7d,0xd8,0xdc,0xc2,0xbf,0x50,0xbc,0x14,0xc9,0xb6,0xb9,0xe5,0x6f,0x6d,0x3a,0xa2, - 0x9d,0xbf,0x90,0x5f,0x7a,0x73,0xcb,0xc4,0xc1,0x1e,0x6f,0xd6,0x43,0xdb,0x36,0xb7, - 0xf0,0x2f,0x0e,0x0a,0x9d,0x75,0x36,0x7b,0x9b,0x9b,0x56,0xfe,0x50,0x02,0xba,0x26, - 0xd7,0xa5,0x92,0x2b,0x23,0xfe,0x4c,0xa2,0x14,0xfd,0x4e,0xf0,0x15,0xd1,0x7c,0x7c, - 0xae,0x90,0xfd,0x09,0xc0,0x4e,0x59,0x5d,0x57,0x8a,0x1a,0xf9,0xd6,0x26,0x57,0x8d, - 0xcd,0xc6,0x60,0x0a,0x8c,0xef,0x06,0x5e,0xd4,0xca,0x03,0x21,0x36,0xfb,0x95,0x23, - 0x85,0x86,0x5d,0x0a,0xf4,0x07,0x5a,0xdc,0xaa,0x35,0x09,0x34,0x36,0x1d,0x99,0x31, - 0x19,0x97,0x2c,0x5c,0xaa,0xca,0x8b,0xa2,0x13,0xb5,0x87,0x9b,0xdb,0x19,0x01,0x47, - 0x8f,0xa6,0xc6,0x91,0xf8,0xeb,0x38,0x4e,0x75,0x74,0x08,0x02,0xac,0xa6,0xfb,0x20, - 0xc3,0xbc,0xe5,0x62,0xe3,0x4f,0x1c,0x8c,0x4f,0x43,0x90,0xb4,0x62,0xab,0x5c,0x1a, - 0xbd,0xab,0x86,0x47,0x08,0x7f,0x46,0x05,0x85,0x3b,0x22,0x2d,0x53,0x63,0x05,0x2d, - 0xe2,0xa6,0x74,0xd1,0x45,0x53,0xb0,0xad,0xc0,0x32,0x01,0x3e,0x84,0x01,0x4d,0x72, - 0xb3,0x12,0x61,0x92,0x77,0x53,0xdd,0x13,0x46,0x16,0x5d,0x73,0x3c,0xb1,0xc8,0xa3, - 0x77,0x6c,0xe3,0x64,0x34,0x42,0x4f,0x45,0xf5,0xc5,0xb0,0x96,0x56,0x9e,0xa5,0x98, - 0x0f,0x6c,0xca,0xa1,0x14,0xc5,0x4a,0xd9,0xc6,0xcf,0xc5,0x9b,0x15,0xf5,0xb4,0xce, - 0xb4,0x9f,0x67,0xc5,0xcf,0x15,0x75,0x45,0x4c,0x40,0xd1,0xa3,0x88,0x75,0x25,0x54, - 0x7e,0xa0,0xd3,0x28,0xc1,0x12,0x32,0xba,0x11,0xba,0x13,0xcf,0x2b,0x6a,0x50,0xc4, - 0x79,0xd1,0xd3,0x05,0xa5,0xab,0x57,0xb7,0x57,0x63,0x0b,0x45,0xa4,0x66,0xda,0x63, - 0x0b,0x34,0xa6,0xdc,0xb3,0x85,0x70,0x61,0x85,0x1f,0x48,0x13,0xf0,0x0f,0xc2,0xf1, - 0x7e,0x45,0x37,0xee,0xcc,0xf3,0x79,0x38,0xd2,0xa0,0xf8,0xf7,0x5f,0x2e,0x98,0xf6, - 0x36,0xcf,0x8d,0xde,0xd2,0x2e,0xe1,0x41,0xd8,0x6f,0x23,0xf4,0x98,0x38,0xe8,0x2a, - 0x10,0x49,0xed,0x7f,0xc1,0x6f,0x10,0x30,0xda,0xa1,0x33,0x22,0x73,0x85,0x1e,0x57, - 0xa5,0x7f,0xac,0x81,0xac,0x51,0x7a,0x59,0x35,0x10,0x05,0x3c,0xea,0x1f,0x50,0xc3, - 0x65,0xe5,0x4a,0x0a,0x40,0xfa,0x96,0x90,0x4e,0x0b,0x4f,0xe9,0x02,0xde,0x72,0x2b, - 0x02,0x67,0x5f,0x05,0x03,0xfc,0xd6,0xdc,0x52,0x13,0x8e,0x63,0x8b,0x8a,0x4b,0x02, - 0xa6,0x0d,0x54,0x33,0xca,0x92,0x20,0x3e,0x79,0x7c,0x4c,0xe6,0xea,0xfe,0x03,0xb4, - 0x05,0xc6,0x26,0x28,0x06,0xb0,0x2f,0x22,0x1c,0x58,0x31,0x2e,0x3c,0x5e,0x42,0xce, - 0xbd,0x74,0x20,0x0b,0x28,0x5a,0x71,0xac,0x7e,0xf4,0x30,0x32,0xec,0x18,0xfe,0x75, - 0xa8,0x5c,0x4f,0xd0,0xb9,0xbc,0x32,0x72,0xd2,0x77,0x6e,0x3c,0x10,0x38,0xd0,0x13, - 0x6d,0x0d,0x06,0x6a,0xd3,0xa9,0xed,0x53,0x7c,0xc0,0x37,0xb8,0xa6,0xc6,0xd7,0xaf, - 0xf9,0x3b,0xd9,0x95,0x38,0xa6,0xca,0x4f,0xa4,0xd6,0xaa,0x22,0xf6,0x4a,0x6f,0x49, - 0x53,0x80,0xff,0xf5,0x4f,0x84,0xf3,0xf5,0xd7,0x0a,0x1b,0x6b,0x5f,0xc8,0xc0,0x50, - 0x92,0x55,0xc4,0x9c,0x1b,0x24,0xc9,0xb1,0xc6,0x24,0xe6,0x4d,0x8b,0x6e,0x38,0xc0, - 0x77,0x8d,0xad,0x31,0x61,0xcc,0xdc,0x9a,0x2f,0xf7,0x19,0x92,0x9d,0x5c,0x8e,0x3f, - 0x69,0x5a,0xe8,0x0a,0xed,0x40,0x0a,0x25,0x0f,0xaa,0x07,0xf6,0xb4,0x41,0x41,0xd8, - 0xd8,0x30,0xa7,0x25,0x5b,0x46,0xf3,0x01,0x8b,0xc0,0x32,0x71,0xc2,0x42,0xf3,0xbc, - 0xd4,0x35,0x6d,0x5b,0xd3,0xa8,0x1b,0x8e,0x82,0x89,0x2b,0x37,0x29,0xe2,0xf8,0xc1, - 0xa6,0xc3,0x19,0x29,0x59,0x0b,0xf9,0xb1,0x2a,0x07,0xa1,0xbf,0xe1,0x36,0xba,0x8c, - 0xcf,0x82,0xe5,0x02,0x11,0x5a,0x3e,0x5e,0x47,0xe8,0xe4,0x65,0x47,0xd7,0xf6,0x34, - 0xbd,0xca,0x61,0xd0,0x68,0x48,0xc1,0x6d,0x0b,0x1f,0x2a,0xbb,0x16,0xc8,0xdd,0x60, - 0xb5,0x59,0x05,0x4a,0x58,0xf0,0x6f,0xc9,0xb0,0x22,0x0e,0xb5,0xf2,0x15,0x1a,0x81, - 0x12,0x9b,0x71,0xe9,0x4a,0x6e,0x62,0x40,0x99,0xb4,0xdd,0x8d,0xc4,0x9a,0x63,0xd2, - 0x70,0x99,0xbc,0x1a,0xf4,0xc4,0xe8,0xfa,0xd8,0x88,0xae,0x41,0xa4,0xa4,0x63,0xc3, - 0xfe,0xa8,0xec,0x1c,0x9b,0x5e,0x61,0xc5,0x86,0x21,0x11,0xcf,0xc4,0xa3,0xeb,0x38, - 0xe6,0xa1,0x77,0x8a,0x97,0xdc,0x9a,0xa3,0xb2,0x30,0xaa,0x47,0x3f,0x41,0x9b,0x89, - 0x6b,0x8f,0x86,0x39,0x50,0x60,0x35,0xa7,0x6e,0x2c,0x6d,0x68,0xa0,0xa1,0x9c,0xa2, - 0xb3,0x2d,0xce,0x5f,0x52,0x2e,0xbc,0x49,0x3d,0x01,0x52,0x29,0x6e,0x2b,0x82,0x51, - 0x63,0x0c,0x23,0x3a,0x1c,0xca,0xcf,0xea,0x03,0x7a,0x6e,0xe0,0x4f,0x3f,0x91,0x2a, - 0xa5,0xf3,0x44,0xfa,0x8d,0xfa,0x29,0x5e,0xd6,0x91,0xca,0x0b,0xd5,0x48,0xd1,0xa7, - 0x58,0x2a,0x50,0xe7,0x67,0xa1,0x0f,0xd3,0x51,0x5a,0x10,0x26,0xe8,0x0e,0x19,0x70, - 0x1e,0xd0,0x88,0x11,0x87,0x50,0xd7,0xa0,0x7b,0x17,0x61,0xfc,0x4e,0x75,0x03,0xe6, - 0xca,0x42,0x8a,0x3e,0x9c,0x39,0x8a,0xb9,0xf6,0x50,0xdb,0x80,0xb1,0x3b,0xd8,0x2e, - 0x70,0x16,0x20,0x0a,0xcb,0xee,0x5a,0xc7,0x79,0xed,0x4b,0xf7,0x13,0xee,0x72,0xa2, - 0x67,0xc7,0xed,0x5e,0xc7,0xea,0x75,0x0b,0xed,0x2d,0x1e,0xd6,0x2b,0x0f,0xf5,0xca, - 0xc3,0x25,0x95,0x95,0x85,0xd4,0x6d,0xc5,0xc3,0xaf,0x5f,0x5d,0x87,0x6e,0x9b,0xe2, - 0xa7,0x98,0xfa,0x28,0xe1,0xd0,0x84,0x3c,0x4c,0xae,0x6d,0x0f,0x50,0xd2,0xc5,0x6a, - 0xa0,0x61,0x48,0xb7,0x1a,0x15,0xae,0x05,0x91,0x77,0x37,0xc0,0x42,0x3d,0x51,0x50, - 0x2d,0x1a,0x66,0x8f,0x43,0x8f,0x0e,0x5a,0x18,0x3c,0xc9,0xa0,0x8d,0x90,0x70,0xca, - 0xab,0xe5,0x06,0x18,0x92,0x8e,0xd2,0x8e,0x9b,0xf8,0xb0,0x48,0x1e,0xe5,0x3b,0xcb, - 0x83,0xdd,0x1d,0x26,0xfc,0xf7,0xa5,0x9b,0xe0,0x18,0x23,0xd5,0x5d,0x96,0xce,0x48, - 0x3e,0x1a,0xcf,0x02,0x36,0xa4,0x2c,0xe7,0xd2,0x59,0x57,0x78,0x0c,0xb2,0xdf,0x23, - 0x50,0xb7,0x8a,0x2b,0x80,0x94,0x30,0x2f,0xa2,0xe1,0xdc,0xd0,0x13,0x8d,0xe5,0x77, - 0xac,0x02,0x61,0x93,0xde,0x88,0x61,0x8a,0x37,0xdf,0x8b,0xa4,0x8a,0xc5,0xed,0xb5, - 0x4c,0xde,0xf4,0x4c,0x8d,0x65,0xf3,0x48,0xda,0x9e,0x1c,0x79,0xdc,0x4b,0x48,0xbb, - 0xb1,0x41,0x7f,0x1c,0xe1,0xcf,0x6f,0xa1,0x8b,0xab,0x1e,0xac,0x1e,0x4a,0x4c,0x2e, - 0x3e,0x50,0x4c,0x76,0xf1,0xde,0x42,0xe0,0xc9,0xda,0xc2,0x6f,0x40,0xb4,0x5e,0xc2, - 0x2a,0x51,0x54,0x2a,0xa0,0xb0,0xba,0xa0,0x1d,0x3e,0xd5,0xea,0xe5,0xfa,0xda,0x7b, - 0x99,0xc1,0x09,0x6f,0xcf,0xc0,0x63,0x2b,0xe5,0x5c,0x7b,0xaf,0xeb,0x8f,0xe4,0xfa, - 0x51,0x71,0xfb,0x78,0xc2,0x98,0x76,0xac,0x2f,0xad,0xde,0xc2,0xe5,0xb6,0xa6,0x1b, - 0xc6,0x51,0x10,0x7c,0x24,0x62,0x67,0x8e,0x86,0x76,0x1b,0xfe,0xa1,0xd8,0x3b,0xe1, - 0x3f,0xb1,0x86,0x7f,0xd0,0xa0,0xec,0x1f,0xd4,0x34,0x26,0xe1,0x31,0x80,0xe6,0x81, - 0xa1,0x35,0x1a,0x9a,0x14,0xa1,0x40,0xef,0xf4,0x09,0xe5,0xfe,0xdf,0xe8,0x75,0xc2, - 0xa6,0x2e,0xfa,0x90,0xde,0xe0,0x75,0x23,0x21,0x5e,0x5a,0x83,0x4e,0x30,0x0c,0xed, - 0x73,0x57,0x13,0xb2,0x24,0xc2,0x7e,0x89,0xa3,0x30,0xe5,0x6c,0xee,0xc2,0xc6,0x87, - 0xb9,0xaa,0x1b,0x76,0x6f,0xb8,0x6a,0x4b,0x26,0x79,0x03,0x5c,0xe2,0x63,0x40,0x48, - 0x40,0xd8,0x20,0x10,0x36,0xc1,0x93,0xb3,0xcd,0x94,0x3c,0x21,0x2d,0x10,0x03,0x71, - 0xfe,0xe2,0x3a,0x2a,0x4c,0x8e,0x44,0x80,0xc0,0x6c,0x6d,0xc0,0x52,0x03,0xbc,0xa6, - 0x44,0xf2,0xf7,0x2a,0x8c,0xe4,0xf0,0x73,0x86,0x49,0xbe,0xaf,0xd7,0xdc,0xeb,0xe9, - 0x79,0x1c,0x70,0x6a,0x30,0xf6,0x6e,0xbb,0x9b,0x77,0x80,0x93,0xb3,0x73,0x34,0x27, - 0x9c,0xa7,0x34,0x10,0x43,0xa4,0x4f,0x11,0x6e,0x06,0xee,0x39,0xd8,0xda,0x89,0x16, - 0xe8,0x29,0x83,0x75,0x26,0x44,0x7d,0xe5,0x85,0x81,0xfe,0x58,0xfa,0xe8,0xe2,0x49, - 0x13,0xe6,0x8f,0xa0,0xbd,0xc4,0x31,0x2d,0x29,0x11,0x43,0x91,0x74,0x17,0x49,0x22, - 0xb6,0x26,0xbc,0xdf,0x53,0x06,0x4b,0x82,0x3b,0x61,0x86,0x09,0xb0,0x0c,0xc2,0x0b, - 0x99,0xe8,0x8e,0x60,0x68,0x08,0xf7,0x09,0x0a,0x64,0x85,0xcd,0x2c,0x6e,0x08,0x00, - 0xe5,0xd8,0xd1,0x1d,0xb8,0x75,0x48,0x00,0x13,0x49,0x6d,0x1f,0x01,0x65,0xd3,0xec, - 0x85,0xd3,0x12,0xbc,0x1c,0xe0,0x7f,0xd0,0xe9,0x90,0x3e,0x0e,0xe8,0xbf,0xd2,0x07, - 0x31,0x6f,0x0a,0x9d,0x5e,0xcc,0x87,0x90,0x46,0xa1,0x9b,0x86,0x39,0xa2,0xa7,0x3e, - 0x59,0xe8,0xc4,0x46,0x71,0xc5,0xa9,0x18,0x5a,0x7e,0x3c,0x1b,0xfd,0x7d,0x59,0x69, - 0x6a,0x39,0x8a,0xa0,0x53,0x1a,0xb9,0x9f,0xc0,0x86,0x82,0xc9,0xf9,0xe3,0x3b,0x60, - 0x84,0x14,0xa6,0x9b,0x7b,0xad,0x89,0x9b,0xfb,0x34,0x07,0x1a,0x04,0x2d,0xb9,0xb2, - 0x89,0xa3,0x9b,0xea,0x59,0xd2,0xb6,0xc0,0x18,0xc3,0x5e,0x48,0xc7,0xfe,0xde,0x6e, - 0xbb,0xdd,0xbe,0x7f,0xd0,0x7f,0x9f,0xb6,0x0d,0xf2,0x52,0xf4,0x90,0x23,0x8a,0xdc, - 0x10,0x3d,0x50,0x45,0x3b,0x01,0x69,0xb1,0x3d,0x6d,0xe8,0xa7,0xbc,0x7d,0x8a,0xb6, - 0xd1,0x65,0xcf,0x23,0xe9,0x59,0xd9,0xf1,0x70,0x3b,0x40,0xe5,0x5e,0x8e,0x83,0x2c, - 0xf0,0xaf,0x79,0x70,0x27,0x57,0x19,0xa3,0xe7,0x6e,0x68,0xfe,0x08,0x3c,0x34,0x9e, - 0xa3,0x7d,0xde,0x05,0x06,0x8a,0x42,0x05,0x92,0x37,0x3c,0xf2,0xd4,0xda,0xca,0x36, - 0x15,0x86,0xa1,0xf7,0x52,0x80,0x26,0xf2,0x9c,0xa8,0x6b,0xc0,0x1b,0x06,0x7e,0x2c, - 0x6b,0xc1,0xe8,0x68,0xf8,0x87,0xbb,0xeb,0xcd,0xb6,0x40,0xad,0xad,0x8e,0x5c,0xf2, - 0x7b,0xfb,0x79,0x75,0xce,0x4c,0x62,0x51,0xbf,0x10,0xac,0x84,0xaa,0xb6,0x9c,0x40, - 0x6a,0x78,0x45,0x6e,0xf8,0x50,0x3f,0x1d,0xb4,0x6d,0x32,0x3c,0x0a,0x1e,0xa5,0x17, - 0x41,0x67,0x16,0x21,0x09,0x92,0x6f,0x4f,0x83,0xe3,0x61,0x56,0x32,0x96,0x46,0xd7, - 0x83,0xed,0x7f,0x7d,0xf8,0x69,0xdb,0x17,0x9c,0xdc,0xcf,0x28,0xca,0xe7,0x2e,0xb7, - 0x7c,0x52,0xac,0xcd,0xb5,0x45,0xbd,0x6e,0x6d,0x69,0x6f,0x7d,0x62,0x0e,0x88,0x15, - 0xc2,0x04,0x9a,0xb7,0x4a,0x5f,0xb7,0x05,0x09,0xa8,0x35,0x6b,0xe9,0xc3,0x56,0xa4, - 0x5f,0xfc,0xef,0xbb,0xdc,0x8a,0xc9,0x1e,0x63,0x94,0x5e,0x72,0xa4,0xa6,0xa0,0x9a, - 0xcb,0xf9,0x4b,0xa2,0xbc,0x36,0x4b,0x5d,0x92,0xf9,0x5e,0xcc,0x4c,0xea,0xaf,0xc5, - 0x84,0x41,0x18,0x2f,0x0b,0xd1,0x28,0x9a,0x7e,0xf8,0xc9,0xe8,0xa9,0xc9,0x15,0xf1, - 0x51,0xff,0x7a,0x95,0x24,0x66,0x94,0x58,0xc7,0x97,0x3d,0xfb,0xd3,0xf1,0x3f,0xd3, - 0xef,0xb7,0x7d,0x34,0x36,0x54,0x8f,0x19,0xf3,0x30,0x0a,0xba,0x90,0xc5,0x5a,0xae, - 0xf2,0x68,0x40,0xb3,0x74,0x0a,0xb3,0x00,0x12,0x93,0xd4,0x36,0x15,0xad,0x95,0x25, - 0x28,0x8d,0x58,0x37,0x43,0x5d,0xb8,0x69,0xa6,0x16,0x10,0x07,0x99,0x4d,0xb1,0x45, - 0x57,0x5c,0x09,0xb1,0x54,0x9d,0xad,0xaa,0x63,0x84,0x24,0x67,0xed,0xc5,0x79,0x6d, - 0xf9,0x58,0x99,0x91,0x86,0x2b,0xd3,0x85,0x18,0x17,0xf9,0xfd,0x5c,0xa9,0x0a,0x5e, - 0x45,0x41,0x20,0x17,0x85,0x10,0xb1,0x3d,0x79,0xad,0x78,0x7e,0x2d,0xec,0x47,0x25, - 0xeb,0x88,0x90,0x5a,0x37,0xcb,0x1d,0xfd,0x91,0x88,0xd1,0x35,0xc3,0x39,0x73,0x72, - 0xc5,0x81,0x9c,0xc8,0x19,0xd4,0x12,0x02,0x37,0xf7,0xf2,0xcb,0xb9,0x0c,0xc1,0xe0, - 0xd0,0x24,0x24,0x6f,0x23,0x15,0x3d,0x5e,0x7e,0xf8,0xe9,0x13,0x53,0xd1,0xa3,0x44, - 0xba,0xd4,0xe8,0x4b,0x1b,0x50,0xa3,0xcd,0x82,0x2a,0x53,0x45,0xa3,0x38,0x9e,0x2d, - 0xcf,0xbd,0xb0,0x5d,0x9f,0x47,0xe3,0x4c,0x9d,0x86,0x3e,0x52,0xf8,0x28,0x11,0x82, - 0x42,0xa0,0xc0,0x8c,0x22,0x3e,0x12,0x31,0x05,0x0b,0xf2,0x88,0xec,0xe5,0x50,0x54, - 0x62,0x02,0xc6,0x9a,0x5d,0x87,0x91,0xba,0x58,0x97,0xae,0x62,0x97,0x77,0xe7,0xa8, - 0xb6,0x4e,0xce,0x44,0x62,0x48,0x45,0x16,0xcd,0x31,0x20,0xd8,0x44,0x9d,0x60,0x92, - 0x7c,0x21,0xa4,0x0a,0x87,0xd1,0xed,0xac,0x48,0xd0,0x70,0xd5,0x5a,0xd4,0xa4,0xb0, - 0x65,0xa9,0xb6,0xd0,0xd4,0x95,0xea,0x22,0x87,0xb8,0x9b,0x67,0xce,0x41,0xf2,0x09, - 0x31,0x02,0x23,0xc3,0x94,0xcc,0x2e,0x26,0x79,0x70,0xf2,0x59,0x03,0x49,0x3c,0xec, - 0xb6,0xd7,0xa7,0x9f,0x40,0x39,0x75,0xe6,0xdc,0x40,0x3f,0x25,0xd0,0xea,0xa7,0xcf, - 0xd5,0x25,0x21,0x4f,0xcc,0x84,0xb7,0xf0,0x8c,0x8e,0x09,0x57,0x28,0x95,0xf8,0x5c, - 0x9d,0x9b,0x82,0xc6,0x3b,0x1c,0xf2,0x44,0x5c,0x33,0x47,0x73,0xc2,0xfd,0x9d,0x36, - 0x30,0xcd,0x87,0x1d,0x30,0xd6,0x76,0x92,0xa8,0x7b,0x47,0xf4,0x35,0x57,0x88,0xc2, - 0x4d,0x22,0x77,0x89,0xd0,0x8e,0xce,0xd7,0xf2,0x25,0xf8,0x96,0xce,0x0b,0x6b,0xf8, - 0x27,0x2c,0x77,0x0e,0x68,0xf2,0xb1,0x6d,0x88,0x4f,0xa1,0x90,0xa7,0x6d,0x3a,0x5e, - 0x5d,0x62,0xba,0x28,0xfc,0x95,0x11,0xe8,0xb9,0x4b,0x3b,0x1d,0x77,0x91,0xfa,0xaf, - 0x9f,0x24,0xd5,0xf7,0x60,0x73,0x6c,0x80,0xdc,0xec,0xaf,0x85,0x1c,0x95,0x45,0xc2, - 0xcd,0x57,0x2d,0xb7,0x18,0x29,0x45,0x54,0xe1,0xf5,0x6e,0x4d,0x2e,0xf0,0x14,0x83, - 0xd3,0x6c,0x55,0x1a,0x16,0x46,0xa5,0xc2,0xac,0x22,0x93,0x7c,0x08,0xd3,0x8a,0x6e, - 0x58,0xf9,0x4e,0x75,0xc3,0x54,0x91,0xfa,0x9a,0xdd,0x5a,0x8b,0xdb,0x06,0xf6,0x00, - 0x85,0xed,0x5b,0x20,0x29,0x43,0xb9,0x52,0x97,0xea,0xb8,0x55,0x04,0x8d,0xd9,0xe2, - 0x92,0x38,0x5b,0xde,0x01,0xd7,0x60,0x7a,0xca,0x84,0x38,0x9b,0xb5,0x8c,0xad,0x6c, - 0x39,0xff,0xc9,0x00,0x6c,0xc3,0x1c,0x31,0x32,0xd9,0x19,0xcc,0x43,0x7b,0x49,0x11, - 0x1f,0xd4,0x8d,0x25,0x92,0xa2,0xe0,0x6f,0x53,0x4a,0xbd,0x69,0x16,0xc5,0xea,0x45, - 0xa3,0x09,0x8b,0x32,0xf0,0x2e,0x59,0xfe,0xf1,0x34,0xfb,0x39,0xc6,0xf8,0x23,0x32, - 0xed,0x0e,0xc8,0x71,0x80,0x32,0x14,0x99,0xe9,0xf6,0xc1,0x3e,0x4a,0x95,0xf6,0xa4, - 0xf4,0xf6,0x3f,0xe8,0xed,0xf6,0x0e,0x46,0x35,0xdb,0xd3,0xf2,0x27,0x7c,0xb9,0xbd, - 0xdf,0xd6,0x5d,0xb6,0xbc,0xa3,0xf6,0xb1,0xb7,0x65,0x78,0xcc,0xd8,0x9a,0x6c,0x19, - 0x13,0xa3,0x67,0x4e,0xe0,0x0d,0xfe,0x84,0x37,0xd3,0x2d,0x63,0x6a,0xf4,0xe8,0xbf, - 0xa5,0xf0,0x6d,0xc0,0x1a,0xd3,0xb7,0x87,0x76,0x1e,0x26,0xbd,0x59,0x4d,0xeb,0x84, - 0xd9,0x09,0x8f,0xe4,0x89,0x8f,0x8f,0x47,0x40,0x9b,0x5b,0x43,0x71,0xfa,0xb3,0xb9, - 0x65,0xa6,0xc7,0x9b,0xec,0x30,0x85,0x5f,0xe2,0xb8,0xa6,0x72,0x7c,0xa7,0x1b,0xa9, - 0x34,0x58,0x2e,0x74,0x30,0x22,0xf1,0x54,0xbf,0x53,0xe1,0xb8,0x29,0x42,0xe3,0x31, - 0xfe,0xfb,0x8d,0x74,0x70,0x34,0xf3,0x52,0xf6,0x4e,0xbb,0x1c,0xe3,0xad,0xb5,0xb5, - 0x10,0x5e,0x45,0x45,0x13,0x20,0x57,0xa0,0xb9,0x2b,0x6f,0xa5,0xfc,0xb1,0xd2,0x5d, - 0xfb,0xfe,0xbe,0xac,0x6d,0xa9,0x56,0x6b,0x51,0x65,0xcb,0x83,0xca,0x28,0x84,0xaa, - 0x41,0xcb,0x10,0x91,0x88,0x2a,0x2b,0x57,0x2d,0x68,0x20,0x08,0x84,0x9d,0xc4,0x71, - 0x1c,0xa3,0xc4,0x25,0x56,0xd6,0x33,0x0a,0x59,0xfa,0x7a,0x38,0xd0,0xfc,0x50,0x52, - 0x07,0xb3,0x09,0x7c,0x1e,0x27,0x9c,0x6f,0x63,0xee,0x39,0x4d,0x4c,0x13,0xc9,0xb9, - 0xf4,0x00,0x3e,0x29,0x45,0x10,0x22,0x18,0x3f,0xc7,0xa8,0x4c,0x01,0x31,0x10,0xa8, - 0xea,0xcc,0x62,0x11,0xc9,0x66,0x59,0x5b,0xa5,0x72,0x3f,0x88,0x64,0x70,0x86,0x0d, - 0x65,0x30,0x31,0xdc,0xe7,0xe9,0xcd,0x36,0x7a,0xbf,0xc0,0x08,0xa3,0xd7,0x78,0xcb, - 0x9d,0xd9,0xb5,0x6c,0xe3,0x1f,0x46,0xa5,0x5a,0x9e,0xe0,0xcb,0xb0,0xaf,0x87,0xb6, - 0xf1,0xd3,0x0f,0xec,0x7b,0xcc,0xc3,0x09,0xe8,0x59,0x1f,0x3d,0xbc,0x17,0x83,0xaf, - 0xb4,0x41,0x97,0x55,0x04,0x41,0x34,0x32,0xec,0x86,0x4a,0xe8,0xaf,0x8d,0x1f,0x45, - 0x55,0xec,0xa2,0x3a,0x06,0x2d,0x33,0x98,0x61,0xa7,0x0e,0xe5,0x5b,0xb0,0x0d,0xef, - 0x87,0x69,0xb5,0xe0,0x5b,0xa0,0xab,0xec,0xe3,0xf9,0xf9,0x1b,0x2c,0x96,0x80,0x9c, - 0x46,0xa5,0x60,0xc4,0xe7,0xef,0x3f,0x52,0xb8,0x5d,0x1a,0x26,0x95,0x2a,0xea,0x72, - 0xaf,0x8b,0x5f,0x0b,0x10,0x2a,0xff,0xaa,0x1a,0x0c,0x55,0xe1,0x8f,0x5a,0xe1,0x04, - 0x86,0xbf,0xac,0xfc,0x99,0x4c,0xf6,0x8b,0xe5,0xa1,0x24,0x1f,0xdd,0xd8,0x68,0x4b, - 0xa6,0xa1,0x40,0x3d,0xf8,0xb9,0xa4,0xc2,0x05,0x55,0x40,0x7b,0x42,0x75,0x39,0xe8, - 0x4e,0x84,0x8f,0xbf,0x6e,0x5f,0xe4,0x4d,0x7e,0x26,0x57,0x70,0x90,0xb3,0xb7,0xc5, - 0x14,0xa1,0x92,0x78,0x55,0xa9,0xfa,0xd2,0xc7,0xf3,0xfe,0x4a,0x5d,0x8f,0x5e,0x96, - 0x2b,0x8b,0x77,0x95,0xda,0x17,0xbf,0x32,0x50,0x5e,0x66,0x1c,0x6b,0x66,0xb7,0x9f, - 0xe9,0x77,0x5e,0x44,0x73,0x0c,0x3c,0x96,0x4b,0x8e,0x41,0xca,0x79,0x05,0xe4,0x08, - 0xb2,0x4a,0xcf,0xa8,0x2e,0x1a,0x09,0xe6,0x6a,0xd1,0x90,0x69,0x7c,0xce,0x57,0xae, - 0x5a,0xf4,0xcd,0x19,0x96,0xf1,0xe3,0xaf,0x5f,0x8d,0x96,0x12,0x90,0xe3,0x59,0x3a, - 0xa1,0xfe,0x91,0x77,0x20,0x46,0x01,0x9e,0x02,0x89,0xd2,0xdf,0x0a,0x84,0x91,0x88, - 0xa3,0xc4,0x0a,0xcc,0x25,0x47,0x0e,0x87,0x5a,0x8e,0x3a,0xcb,0xd6,0x1b,0x02,0x6a, - 0x53,0x29,0x24,0x52,0xcf,0x15,0xa5,0xf4,0x06,0x2b,0xae,0xae,0x85,0xc7,0x51,0x30, - 0x48,0x8b,0x80,0x97,0x7e,0x89,0x46,0x28,0x4f,0x47,0x6d,0x6f,0xa7,0x81,0x34,0x75, - 0x1e,0xc3,0xaf,0xa9,0x1b,0xd7,0x03,0xb2,0xe4,0xa1,0x06,0x26,0x4f,0x49,0x07,0xe6, - 0xad,0x03,0x5a,0x34,0x9d,0x38,0x7e,0xfd,0x7a,0x8b,0x91,0x52,0xe2,0xc1,0xc2,0x70, - 0x19,0x03,0xd6,0x14,0xbf,0xc3,0xf2,0x46,0xd7,0xb4,0xc2,0x54,0x02,0x1e,0xf1,0x40, - 0xa3,0xac,0x8c,0x94,0x78,0xc8,0xf5,0x0d,0x71,0x10,0xe9,0x38,0x70,0xeb,0x84,0x32, - 0x5e,0x15,0xfd,0x1c,0x6e,0x0b,0x3f,0x07,0x62,0x2d,0xa5,0xc4,0x36,0x31,0x5a,0x39, - 0x36,0x65,0x39,0x22,0xaa,0x9a,0x87,0x44,0xf1,0x46,0xa6,0xb6,0xd9,0xdc,0x12,0xb3, - 0x28,0x39,0x26,0x28,0x3d,0xd5,0x41,0x83,0xb6,0x09,0xd8,0xd2,0x83,0xfd,0xaf,0x32, - 0xf2,0xe5,0xe1,0xd7,0xdc,0x73,0x0c,0xcd,0xb7,0x69,0xa9,0xc0,0x57,0x70,0x07,0x44, - 0x08,0x17,0x5d,0xbb,0x5d,0x87,0x7e,0xdf,0x90,0x34,0xe4,0x4a,0x68,0x1f,0x01,0x5f, - 0x76,0x9d,0x74,0xe2,0xa3,0xfc,0xae,0xb3,0x2a,0xc2,0x00,0xd8,0xb7,0x28,0x6c,0xe4, - 0x42,0x96,0x17,0x27,0x03,0x99,0x9d,0x43,0x68,0x2a,0x67,0x40,0x40,0x83,0x8f,0x68, - 0x41,0xfb,0xfa,0xb5,0x83,0xb1,0x51,0x37,0x20,0xd0,0xa0,0x26,0xf5,0x0b,0xdd,0x52, - 0x3a,0x19,0xec,0xed,0xd3,0x29,0xd3,0x8d,0x43,0xf9,0x74,0x06,0xf3,0xef,0xa1,0x89, - 0x3e,0x3c,0xca,0x2c,0x86,0x13,0x7a,0x96,0xad,0x5f,0x61,0xf5,0x2b,0x2e,0xb8,0xc6, - 0x2d,0x88,0x6f,0x5d,0xcc,0x70,0x70,0xe5,0xa4,0x78,0x66,0x61,0x42,0x41,0x1b,0xfe, - 0xc5,0x17,0xc4,0x29,0x41,0x91,0xcd,0x4c,0xb4,0xb2,0xce,0xed,0x89,0x92,0x8d,0x70, - 0xb0,0x72,0x62,0x87,0xdd,0xca,0x39,0xda,0x34,0x14,0x7c,0x07,0x88,0xb5,0x43,0x47, - 0x44,0xc2,0x50,0x48,0xf3,0xb3,0xa7,0xb7,0xf2,0x23,0x86,0xd9,0x54,0x3e,0xca,0xb6, - 0xa7,0x28,0xf4,0x4d,0x43,0x6b,0x31,0xbd,0xdd,0x1a,0x74,0xfa,0xd3,0xb0,0x35,0xe8, - 0xe0,0x1a,0x5c,0x61,0x00,0x63,0x74,0xcd,0xcf,0x29,0x2f,0x10,0x0d,0x7f,0x1a,0x83, - 0xc2,0xec,0xd1,0x8b,0x22,0x39,0x8d,0xfa,0x21,0x0f,0xe1,0x2c,0x9c,0xe9,0x59,0x82, - 0xc7,0x50,0xd9,0xdd,0x3f,0x50,0x2f,0x30,0x8d,0x56,0xcb,0x1d,0x8d,0x90,0x75,0x26, - 0xfe,0xd4,0xb4,0x60,0xb3,0x3f,0xeb,0x0e,0x9f,0x7b,0xfc,0x05,0xad,0xf8,0x95,0x83, - 0xe9,0xcd,0x08,0xae,0x83,0x6e,0x5f,0x3c,0xfd,0x3d,0xc2,0xcc,0xd0,0xc4,0x56,0x0c, - 0x78,0x35,0xe4,0xa0,0x71,0x9d,0xc1,0x3c,0x84,0x3a,0x40,0xd0,0xa8,0x89,0x9f,0x37, - 0xd2,0xd5,0x40,0x40,0xe5,0x76,0xe0,0x6f,0xeb,0x60,0x6b,0x75,0xac,0xef,0xcd,0x79, - 0x6b,0xd7,0xda,0xea,0xda,0x77,0x83,0x49,0x6b,0xb7,0x65,0xde,0xb4,0x60,0xd6,0xdb, - 0x30,0x7f,0xfc,0xfb,0xbd,0x09,0x45,0xda,0x6a,0xeb,0x1f,0x8b,0x61,0x5c,0x44,0xe6, - 0xad,0x7d,0x67,0xf5,0xae,0x1c,0xd4,0x8e,0xe4,0x53,0x91,0xc8,0x42,0x41,0xc8,0x94, - 0x4f,0x63,0x1f,0x05,0x16,0x82,0x96,0x0e,0x3b,0x98,0x00,0x66,0x4d,0xc2,0x1c,0xb4, - 0xf1,0x2d,0x4b,0xef,0x40,0xe8,0x9f,0xb6,0x66,0x3e,0x4e,0x0c,0xd1,0xe1,0x04,0xd3, - 0x68,0xc1,0x64,0x11,0x6f,0x8c,0xa2,0xa1,0x0b,0xc4,0x14,0x9c,0xc0,0x65,0x69,0x16, - 0x9f,0xec,0x79,0xab,0x6b,0xd3,0x48,0x6b,0xda,0x0f,0xd2,0x5a,0x91,0x4e,0x71,0x89, - 0xf4,0x8b,0xc7,0x52,0x3f,0xf1,0x3b,0x8a,0x51,0x37,0x85,0x4e,0xa1,0x9f,0x5e,0x8b, - 0x37,0x1b,0x1b,0xcb,0xb2,0x74,0xc9,0x1a,0xfd,0xdc,0x97,0x7a,0x18,0xcc,0x92,0xe2, - 0x58,0xd6,0xe5,0x85,0x5d,0x8c,0x2e,0x55,0x54,0xe7,0xb2,0x12,0xd1,0x5c,0xbe,0xb1, - 0xe1,0xa2,0xd6,0x94,0x97,0xc2,0x33,0x19,0x7c,0x47,0x0d,0x59,0xf2,0xaf,0x59,0xde, - 0xe7,0x79,0xfe,0x61,0x6d,0xbc,0x28,0x34,0xe6,0x99,0x99,0x06,0x85,0x43,0x2e,0x68, - 0xe9,0xef,0xf0,0xd4,0x53,0x5c,0xdc,0x8e,0x89,0x20,0xf3,0x4b,0x3e,0xc6,0x78,0x07, - 0x19,0xc1,0x58,0x5c,0x64,0x21,0x8e,0xc1,0xa8,0xa6,0x96,0x5f,0xb2,0x87,0x59,0x7e, - 0x9e,0xe4,0x3e,0xbc,0xc2,0x46,0x31,0x86,0xe1,0xa6,0x5c,0x79,0x44,0xdf,0x08,0x37, - 0x46,0x74,0x9b,0x96,0x3d,0x5d,0x4b,0x88,0xe6,0x27,0x1b,0xa2,0x6b,0xca,0x62,0xf9, - 0x44,0x5a,0x8c,0x51,0xf6,0xbe,0x4b,0xf3,0xca,0x74,0xa0,0x33,0x9f,0x48,0x9b,0x54, - 0x91,0xf3,0x27,0xe1,0x63,0xe8,0x8a,0xee,0x0f,0xa1,0xd4,0xd8,0x7a,0xf3,0xce,0x93, - 0xba,0xf9,0xb1,0x79,0x81,0x6c,0x1a,0x00,0x39,0xc6,0x6a,0xe9,0x2f,0x8b,0xdc,0x2b, - 0x03,0x1e,0x6c,0x6c,0xd4,0x4e,0xe9,0x65,0xc6,0x95,0x8d,0x8d,0xb1,0x83,0x87,0xa8, - 0x61,0x86,0x97,0x9d,0x58,0xfa,0x03,0x70,0x35,0x20,0xdb,0xd9,0x0f,0x74,0xe1,0x92, - 0x49,0xad,0xda,0xe8,0xb0,0x72,0x9b,0x9d,0xc3,0xac,0x40,0x76,0xa6,0x46,0xe8,0xfd, - 0xca,0x34,0x07,0x00,0x91,0x21,0xf4,0xdb,0xe2,0xe3,0x31,0xe6,0x28,0x52,0x13,0x14, - 0x53,0x36,0x29,0x07,0x27,0x5a,0xd6,0x13,0xee,0x06,0x32,0xc1,0x5f,0xbf,0x02,0x27, - 0xd8,0x31,0x30,0x04,0xbc,0xca,0x08,0x0f,0xea,0x9a,0xd0,0x1a,0xfb,0x69,0xb6,0x14, - 0x35,0x95,0xbe,0xb7,0x3b,0x7b,0x62,0xf3,0x17,0x49,0x38,0xc5,0x21,0x97,0x86,0x89, - 0x5a,0x8e,0xcc,0x45,0x15,0xb6,0x4b,0xb2,0x95,0x94,0xd2,0x6a,0x29,0xac,0x25,0xc5, - 0xa7,0xa6,0x96,0x51,0x97,0x51,0x32,0x12,0x86,0xbe,0xbc,0x7d,0x4a,0xb3,0xa9,0xcb, - 0x13,0x9b,0xdf,0x32,0xe5,0xe6,0xa6,0x38,0x2c,0x05,0xb2,0x95,0x77,0x7f,0x2c,0x75, - 0x2c,0x78,0x71,0x9c,0x50,0x02,0xd2,0x41,0x07,0x24,0x8f,0xfc,0x65,0xc5,0x0b,0x77, - 0xe8,0x26,0xa9,0x89,0xf2,0x9d,0x50,0xa9,0xc3,0x01,0xfe,0x3e,0x6a,0xed,0xed,0x1d, - 0xef,0xf6,0xc4,0xcf,0xfd,0xe7,0xc7,0x3b,0xf2,0xe7,0xf3,0x83,0xe3,0x6e,0xaf,0x43, - 0x81,0x35,0x51,0xb3,0x2f,0xe8,0x2e,0xf9,0x80,0x46,0xe8,0x25,0xa9,0x27,0x9a,0xf4, - 0xfc,0x34,0x0e,0xdc,0xbb,0x9e,0x48,0x19,0xdb,0x1a,0x62,0x72,0xc2,0xbe,0xcc,0xb3, - 0x17,0xdf,0xaa,0xe4,0x83,0x89,0x48,0x90,0x09,0x2f,0x86,0x51,0x02,0x9b,0x8f,0xb2, - 0xab,0xcc,0x52,0x7a,0x23,0x33,0xf1,0x81,0xba,0xbc,0xb3,0xe5,0x7f,0xbf,0x03,0x32, - 0x0b,0x16,0x03,0xe9,0xfc,0x8a,0xb8,0x0c,0x7e,0x80,0xee,0xc3,0x63,0x43,0xa4,0xcb, - 0x03,0x7e,0x65,0xc1,0xac,0xc5,0x03,0xf6,0x69,0x19,0x24,0xf8,0x28,0x41,0xa7,0xaf, - 0x39,0xfc,0xae,0x18,0x28,0x66,0x8f,0xed,0xd3,0xfa,0xb4,0x7c,0xa0,0xfa,0x69,0x0f, - 0x5f,0xe0,0x7d,0xa7,0x6a,0x3c,0x98,0xde,0x93,0x9c,0x2d,0xb7,0x0c,0xd9,0xb4,0x51, - 0xd5,0x80,0x71,0x65,0x66,0x49,0x90,0xeb,0xc0,0xf8,0x7b,0xad,0x03,0x36,0x5c,0xad, - 0x50,0x9e,0xb0,0x69,0xa8,0x37,0x58,0x6e,0x40,0xa5,0xbe,0xf4,0xa5,0xa6,0x7d,0x51, - 0xb1,0x9a,0x0a,0x67,0x36,0x14,0x55,0x13,0x47,0xa5,0xcc,0x95,0xc7,0x43,0x7e,0x00, - 0x28,0x58,0x34,0x1a,0xe6,0x19,0x0c,0x42,0x72,0x21,0x50,0x16,0x40,0xac,0xdd,0xe4, - 0x53,0x22,0x0b,0x0f,0x49,0xd3,0x6b,0xb9,0xf4,0xe7,0x5e,0xf7,0xf5,0xe7,0x3c,0x44, - 0xb3,0x39,0xf5,0x4e,0x8d,0x34,0xf4,0x88,0x62,0x3b,0x94,0xbb,0x14,0x5d,0x7e,0xb2, - 0xf4,0xb4,0x91,0x7d,0xfd,0x0b,0x20,0x9c,0xfc,0x86,0x47,0x47,0xfa,0x39,0x4a,0xf3, - 0xc6,0xa3,0x0e,0xa5,0x28,0x4f,0xbf,0x4b,0xc2,0x7c,0x98,0x0b,0xf3,0x0a,0x2d,0x64, - 0xa2,0xde,0x72,0x56,0x5e,0x95,0x22,0x94,0xe7,0x5e,0xbf,0x29,0x26,0x50,0x14,0xf2, - 0xb4,0x18,0x98,0x14,0xb0,0x69,0x63,0x85,0x04,0x03,0x2b,0x3f,0xd6,0xda,0xda,0x2c, - 0xef,0xe8,0x54,0x89,0xe2,0xaa,0x6a,0x8e,0x45,0x5b,0xf0,0x8a,0x87,0xa3,0x63,0x63, - 0xe3,0x59,0xa7,0x7b,0xd0,0x7d,0xbe,0xdb,0x37,0x48,0x53,0x33,0x0e,0xd3,0x29,0x68, - 0xe7,0x50,0x40,0xb4,0x4d,0x35,0xe8,0x4d,0x9e,0xf0,0xd7,0xa8,0x4b,0xec,0x9b,0x45, - 0x7e,0xc8,0x5a,0x52,0xc9,0x9c,0xe2,0x74,0x11,0x95,0x41,0xb4,0x57,0x48,0x01,0x7b, - 0x1b,0x76,0x96,0x83,0xe9,0x20,0x37,0x97,0x59,0x71,0x57,0xd1,0xb9,0x75,0xbb,0x44, - 0xac,0x95,0x67,0xd8,0xa2,0xb3,0xc2,0xe0,0xaa,0x37,0xfd,0xd7,0xad,0xae,0x88,0xee, - 0x0d,0x36,0xd7,0x07,0x98,0x72,0x49,0x58,0x91,0x71,0xa5,0xd2,0x60,0x5e,0x98,0x3c, - 0x29,0x90,0x5b,0x7c,0x42,0x12,0x82,0x60,0xa2,0xc1,0x9a,0x94,0x77,0x8e,0x7e,0xa9, - 0xc8,0xa4,0xc5,0x70,0x36,0x04,0x19,0x22,0xed,0x09,0xb4,0x15,0xde,0x6e,0x05,0x6b, - 0x6a,0xb4,0x89,0xca,0x50,0x98,0xb2,0x58,0x08,0x6c,0xf8,0x23,0x5d,0xcc,0x07,0x6c, - 0x96,0x4c,0xbd,0x85,0xbf,0xd9,0xd0,0xbd,0xa6,0x1b,0xd2,0x80,0x4f,0x8f,0x5c,0xcc, - 0xd7,0x59,0x64,0x93,0x3d,0x39,0xa3,0xf4,0x66,0x61,0x24,0xee,0x2b,0x02,0x90,0x90, - 0xc7,0xc6,0x79,0x34,0x03,0xe6,0xd1,0x63,0x98,0x32,0x37,0xed,0x6d,0x23,0x0d,0x41, - 0x3b,0xf9,0x64,0x04,0x7c,0xda,0x09,0xbf,0x10,0x4d,0xb9,0xe9,0xc8,0x23,0x10,0x66, - 0x8a,0xbf,0x45,0x09,0x3f,0xb2,0x6c,0xf6,0xf7,0x59,0x80,0xee,0x22,0xfb,0xd4,0xe0, - 0x78,0xf0,0xee,0xc7,0x2f,0x30,0x92,0xf9,0xe0,0xfa,0xc7,0x2f,0x78,0xbc,0x48,0x59, - 0x62,0xf0,0xa8,0x11,0x7a,0xc7,0x7c,0x18,0x72,0xcc,0x63,0x7b,0x38,0xb7,0xd3,0xb1, - 0x3d,0x4a,0x7e,0x73,0x28,0x69,0x68,0x29,0x58,0x67,0x70,0x09,0xd0,0x59,0x64,0x3d, - 0xe3,0xe7,0xf3,0x93,0xed,0x53,0x37,0x74,0x3d,0x97,0x99,0x78,0x54,0x38,0x85,0xf5, - 0xf1,0xb8,0x67,0xc1,0xf2,0xf7,0x8c,0x17,0x9d,0xb6,0xb3,0xd7,0xdd,0x33,0xa0,0xa9, - 0x9e,0xb1,0xdf,0x75,0xe0,0x57,0x0a,0xaf,0x9f,0x1b,0xd0,0x6a,0xcf,0xd8,0x13,0xa9, - 0x5d,0xb0,0x95,0x93,0x19,0xc8,0xf5,0x40,0xcf,0x5d,0x59,0x6d,0xcf,0x39,0x68,0xb7, - 0x45,0xb5,0xee,0x5e,0x5b,0xd4,0xea,0xb4,0x97,0x57,0x63,0xe6,0x7b,0x37,0x49,0xa2, - 0xb9,0xea,0x76,0xdf,0xd9,0x7b,0xbe,0xac,0xdb,0x83,0xc6,0xfa,0xef,0xfc,0x7c,0xcc, - 0x7b,0x4e,0x5b,0x55,0xee,0x74,0x65,0xdd,0x17,0xcb,0xfb,0xee,0xb1,0xf3,0x13,0x9b, - 0xfd,0x72,0x22,0x6a,0x77,0x77,0x9c,0x4e,0x7d,0xc6,0x07,0xcb,0xbb,0xee,0xb1,0xff, - 0x7a,0xfb,0x72,0x8d,0xba,0x5a,0xd7,0x3f,0x24,0xee,0x17,0x3f,0x78,0x5c,0x7f,0xaf, - 0x7e,0xde,0xfe,0xf9,0xa7,0x32,0x98,0x0e,0xf6,0x5f,0x38,0xfb,0x9d,0x83,0xb5,0xeb, - 0xbe,0xe4,0x80,0xc7,0x18,0x78,0xe7,0x15,0xf5,0xf3,0xd5,0x2d,0x96,0xa9,0x53,0x1b, - 0xef,0xe9,0x17,0x3e,0x9a,0xc0,0x9e,0x90,0x77,0x2b,0xd6,0x06,0xb1,0xbb,0xd3,0x7d, - 0x18,0x45,0x5e,0xfd,0xcc,0x76,0x77,0x76,0x00,0x77,0x99,0xf9,0x36,0x0a,0xaf,0xd8, - 0x47,0x74,0x0a,0x15,0x6d,0xc0,0x7b,0x07,0x73,0xe6,0x3e,0x38,0x10,0xad,0x0d,0x7d, - 0x0c,0xa5,0xfa,0xab,0x00,0xf1,0x9e,0x3c,0x01,0xd1,0x79,0x2c,0x5d,0x05,0xc1,0xfa, - 0xe0,0xdf,0x03,0xb5,0xf9,0x1f,0x10,0xbb,0xa1,0xa6,0x44,0xb2,0xe7,0xce,0xce,0xf3, - 0x35,0x40,0xa7,0x55,0xac,0xe2,0xb8,0xd6,0xc2,0xaa,0xae,0x31,0x8a,0x62,0x76,0x05, - 0xa4,0x08,0x66,0x99,0xcf,0xb6,0xa1,0xa6,0xc4,0xf0,0xfd,0x86,0x9a,0x07,0xfb,0x07, - 0x6b,0x4c,0x57,0xab,0x79,0x3e,0xf7,0xb3,0x2f,0x02,0x50,0x8f,0xc3,0xb4,0x7f,0xf8, - 0x3c,0x0b,0xdd,0x69,0x65,0xaa,0xdd,0xb6,0xd3,0x5d,0xba,0x3c,0x7b,0x0d,0xb5,0xab, - 0x98,0x5a,0x6a,0xa1,0x09,0xdc,0x4f,0x3e,0x69,0x11,0x7f,0xe5,0x30,0xc3,0x45,0x91, - 0x34,0x60,0x45,0xc6,0x00,0x5d,0x9d,0x91,0xd1,0x8a,0x24,0x7a,0xa1,0x4e,0x16,0x02, - 0xfb,0x4a,0x00,0xef,0x47,0xc2,0x8b,0xb6,0xc7,0xc4,0x24,0x40,0x92,0xc4,0xbf,0x30, - 0x18,0x46,0x63,0x1a,0xe0,0x95,0xc6,0x6d,0xa7,0x6d,0xc8,0xb0,0x0e,0xf7,0xeb,0xd7, - 0x9c,0x1d,0xb2,0xdc,0x79,0x48,0xd8,0x60,0x5c,0x07,0xc4,0x61,0x1f,0x78,0x97,0x6d, - 0x58,0xf6,0x1d,0xf0,0xbb,0xe2,0xb1,0x2c,0xfc,0xb7,0x41,0xf8,0x17,0xb2,0x3f,0x34, - 0x29,0x53,0x88,0xde,0x5e,0xfa,0x9f,0xac,0xa7,0x83,0x81,0x7c,0xbc,0xc3,0xc7,0x5a, - 0x3f,0x9a,0x08,0x57,0x36,0x0f,0x12,0x70,0x5e,0x52,0x20,0x57,0x19,0x36,0x0c,0xf7, - 0x55,0x9e,0x2a,0xf1,0x87,0x5f,0x14,0xa8,0x8a,0x77,0xe7,0xaf,0x15,0xd4,0x8a,0x77, - 0xa7,0x1f,0x6b,0x00,0x9c,0x7f,0xf9,0x88,0x1c,0xe8,0xb5,0x4f,0x21,0x88,0xb9,0xaf, - 0x77,0xa0,0x47,0xa4,0xda,0xa3,0x59,0x52,0x8e,0x6b,0xfd,0xfa,0x55,0x28,0x3e,0x79, - 0x4c,0x5c,0x35,0x8c,0x0c,0x04,0x1a,0x11,0x9c,0x25,0xee,0xb5,0x22,0x0e,0x5d,0x64, - 0xfd,0xd6,0x23,0xca,0xc8,0xfa,0x87,0xa2,0x02,0xc6,0xa8,0xc2,0xf3,0x3a,0x61,0xaa, - 0xb9,0xc3,0x4f,0x73,0xac,0x2a,0x0c,0xd7,0xb2,0x44,0x9b,0x7e,0x39,0x68,0xad,0x1a, - 0x33,0xe7,0xeb,0x91,0x72,0x19,0x08,0xad,0x79,0xe6,0xf9,0xcd,0xad,0x12,0xf0,0xf5, - 0x00,0xba,0xc2,0x76,0xd6,0xdc,0x2c,0xc5,0xba,0x1e,0x91,0xff,0x88,0xca,0x70,0xa5, - 0x3c,0x7c,0xca,0x8d,0x00,0x98,0x6b,0xb1,0x48,0xc2,0x1f,0x1a,0xbb,0x26,0xf0,0xe5, - 0xe6,0x15,0xe1,0xf2,0x23,0x2f,0x9a,0xa6,0xb9,0x61,0xc8,0x3d,0xfa,0xbb,0xcc,0x15, - 0x9e,0xf4,0x45,0xb2,0xce,0x39,0x1d,0x4b,0xa1,0x16,0x8c,0x9e,0x7c,0x93,0x48,0xb8, - 0xac,0xa0,0x8d,0x14,0x6b,0x1d,0x0d,0xda,0x56,0x5a,0x75,0x76,0xa0,0x2f,0x5a,0x64, - 0x82,0x29,0xbc,0x38,0x36,0x36,0xc4,0x5f,0x19,0xcb,0xac,0x92,0x4d,0xe7,0x98,0xd1, - 0x82,0xe9,0x95,0x73,0x78,0x9e,0x8a,0xf9,0x06,0x77,0x3d,0xbc,0x0c,0x40,0x98,0xe6, - 0x47,0x94,0x00,0xc4,0x6a,0xc2,0x59,0xf1,0x79,0x38,0xb7,0xaa,0x88,0x2b,0x3e,0xa4, - 0x63,0xab,0x8a,0xbd,0xe2,0xc3,0x28,0x21,0x25,0x01,0x7a,0xa0,0xd5,0xdd,0x12,0x13, - 0x38,0x6c,0x1f,0x1b,0xcc,0xf4,0x22,0x4e,0xf7,0x09,0xd0,0x2b,0x80,0x40,0xa2,0xa4, - 0x43,0x01,0x24,0x8b,0x34,0x08,0x05,0xfd,0x15,0x79,0x35,0xea,0x7b,0x04,0x30,0xba, - 0x14,0x2f,0x7a,0x33,0xc8,0x01,0x69,0x13,0x26,0xf6,0x8b,0xf4,0x0e,0x86,0xf1,0xf5, - 0x2b,0xfd,0x25,0x64,0xb0,0x16,0xf4,0xbd,0xbc,0x87,0xee,0x65,0x72,0x96,0x85,0xc8, - 0xba,0x52,0xc2,0xfb,0xcb,0xad,0x9b,0x4f,0x7d,0x51,0xa7,0x8c,0xdb,0xf7,0x02,0x41, - 0x3c,0x74,0xf6,0x14,0xf2,0x2c,0x06,0xe0,0x7b,0xec,0x37,0x6a,0x52,0x66,0x57,0x9b, - 0x24,0xe4,0x64,0x2d,0xfc,0xa3,0xa6,0x68,0xca,0xcb,0x26,0x28,0x70,0xd2,0xd5,0xab, - 0xde,0x0d,0x5e,0x99,0xe1,0x31,0xee,0xf9,0x59,0x94,0x3c,0x59,0xd3,0x3d,0x46,0x8b, - 0x94,0x48,0x40,0x6f,0xa5,0xa1,0xe9,0x64,0xd1,0xf7,0x6e,0x07,0x0b,0x5c,0xe7,0x5e, - 0x1b,0xe9,0x7e,0x07,0x69,0x7e,0x17,0x09,0xfe,0xce,0x7d,0xc5,0x85,0x46,0x79,0xd5, - 0x49,0x0c,0xa4,0xf6,0x2e,0xa1,0x7a,0xf1,0x61,0x69,0x78,0x3d,0xf5,0x5a,0x0b,0xb1, - 0x2f,0xb6,0x63,0xfa,0xe7,0x2a,0xd7,0x63,0xf3,0xa9,0x6c,0x25,0x36,0x46,0xcb,0xd9, - 0x59,0x72,0xba,0x91,0x99,0x2d,0xeb,0x7e,0x37,0x64,0xdd,0xd0,0xc8,0xeb,0xc3,0xa7, - 0x41,0x94,0xb3,0x36,0xcc,0x43,0x0d,0x8b,0x83,0xbb,0x8d,0x0d,0x8c,0x27,0xdf,0xb1, - 0xc2,0xc1,0x2e,0x39,0xb5,0x74,0xec,0xae,0xbd,0x63,0xef,0x36,0x25,0xd1,0x94,0x4b, - 0x25,0xf6,0xa8,0xb1,0xe5,0x2f,0xf7,0x61,0xf1,0x01,0x18,0xa1,0x6e,0x30,0xc8,0xe2, - 0x25,0xe5,0xd1,0xa9,0x06,0xed,0x4a,0x1a,0xa8,0x61,0x7c,0x62,0x48,0x79,0xba,0x88, - 0xb3,0x84,0xa3,0xc5,0xbf,0xb8,0xac,0x9d,0x2e,0x21,0xcc,0xdd,0x1d,0xf3,0x5b,0xd7, - 0x41,0x0b,0x22,0xef,0xf7,0xf9,0x04,0x74,0x5d,0xe9,0x81,0x4c,0x71,0x9c,0x68,0x79, - 0xce,0xf3,0xbe,0x87,0x8c,0xdf,0x0a,0x47,0x77,0x95,0x74,0x50,0xb8,0xed,0xb9,0xe1, - 0x0c,0xb6,0x32,0xe2,0x2e,0x25,0x25,0x21,0xd7,0x67,0xe9,0xb5,0xc6,0xbd,0x22,0x01, - 0x7c,0x44,0xb9,0xa3,0xe7,0x5f,0x76,0xd8,0x65,0xd3,0x55,0xf4,0xb9,0x1b,0x2b,0x99, - 0x81,0xa6,0x79,0x0c,0x8d,0x4c,0xa0,0xab,0x50,0x44,0xa6,0xd3,0x35,0xf2,0xa3,0xd4, - 0x68,0x63,0xe3,0x69,0x24,0x10,0x17,0x96,0x64,0x8a,0x6e,0xb7,0x02,0x8b,0xc3,0x69, - 0x3f,0x7a,0xb4,0xce,0x7b,0x2f,0x63,0x52,0x25,0x2c,0x77,0x2d,0x8a,0x4b,0x17,0x69, - 0x8f,0xc5,0x81,0x84,0x3c,0xcd,0x4b,0x47,0x98,0xbf,0xfe,0x22,0xc2,0x03,0xb5,0x6a, - 0x32,0xf6,0xf8,0xc3,0x4c,0x26,0x2e,0x41,0x57,0x49,0x60,0xb0,0x05,0xb4,0x31,0xe6, - 0x84,0xae,0x10,0x75,0xc3,0x74,0x2e,0xbc,0xfa,0x28,0xfb,0xbf,0xbc,0x66,0x28,0x89, - 0x86,0xd2,0xf5,0x8f,0x19,0x32,0x8a,0xc7,0x90,0xc6,0x7f,0x99,0xbc,0x9b,0x2c,0x7e, - 0xa1,0x96,0xc9,0x7f,0xc8,0x31,0xfc,0x60,0xea,0x63,0x88,0x43,0xee,0x34,0x88,0xa6, - 0xe8,0x58,0xe5,0x75,0xc1,0x63,0x73,0x19,0xe9,0x35,0x77,0x83,0xeb,0xa2,0x31,0x0c, - 0xcc,0xcc,0x29,0x53,0xc2,0xf1,0x40,0x44,0xd5,0xc1,0xd0,0x4b,0x37,0x90,0x17,0xbb, - 0xf8,0x89,0x30,0x99,0xcb,0xfb,0x5d,0x9c,0x92,0xcf,0x8c,0x28,0xb9,0x8d,0xf7,0xac, - 0x54,0x73,0x9c,0xae,0xf4,0xd7,0xc7,0x74,0xf8,0x68,0x71,0x44,0x34,0xcf,0x2f,0xc6, - 0x29,0x3b,0xc1,0x50,0x81,0x62,0x33,0xa8,0x7b,0x95,0xac,0x25,0x57,0x4d,0x2c,0xb1, - 0x04,0x49,0x1f,0xba,0xd3,0x68,0x16,0x78,0xc8,0x84,0x60,0x2a,0x23,0x0d,0xff,0x5b, - 0x0c,0x2f,0x2e,0x70,0x29,0x81,0xa1,0x55,0xd9,0xfc,0xa5,0xc5,0x57,0x2c,0x06,0x81, - 0x30,0xb8,0xfc,0x64,0x7b,0x39,0x8a,0x96,0xe2,0x2f,0xe0,0xb3,0x49,0x89,0xc1,0xb0, - 0x9c,0x38,0x40,0x6e,0x3a,0x31,0xdf,0xdc,0x0a,0x90,0x5d,0xd2,0xc1,0xb8,0x60,0xa1, - 0x37,0x92,0x7f,0x0a,0x03,0xba,0x88,0x8b,0xc0,0xc6,0x0c,0x3d,0xb9,0xb8,0x61,0x7b, - 0x97,0x5a,0xe8,0xd4,0xa7,0x7c,0x63,0x54,0xde,0x1a,0x26,0x26,0x07,0x22,0xde,0x2a, - 0xbc,0xc8,0xf2,0x86,0x8a,0x70,0x76,0xd5,0x12,0xc6,0x5a,0x7d,0x3a,0x96,0x57,0xa4, - 0x18,0x3d,0xb3,0xdc,0xa2,0xfc,0x6a,0xc2,0xfc,0x28,0x0c,0xd1,0x43,0x6e,0x6d,0x92, - 0xdb,0xb3,0x1c,0x93,0x65,0xe4,0xe9,0xf0,0xbd,0x86,0xdd,0x6a,0x51,0xdf,0xef,0x15, - 0xad,0x81,0x7e,0x9b,0x0a,0x29,0x41,0x27,0x19,0x0d,0x3c,0x25,0xcf,0xd6,0xc4,0x5b, - 0x3b,0x89,0x45,0xc8,0xf4,0x1a,0x32,0xe9,0xaa,0xc4,0x29,0xc9,0x08,0xe4,0xfd,0x58, - 0x05,0x51,0xd3,0xf8,0x3e,0x0a,0x2e,0x63,0x26,0xf1,0x71,0x02,0x62,0x27,0xc8,0x38, - 0x2d,0x26,0x0c,0x9b,0xc9,0x48,0x63,0xaa,0xd2,0x72,0x89,0x2e,0x12,0xd6,0x96,0x29, - 0x47,0x2a,0x3c,0x28,0x74,0x08,0x69,0x00,0xc9,0x6e,0x05,0x00,0x2e,0x7e,0x65,0x94, - 0x15,0x11,0xe7,0x9f,0xdd,0x42,0xfb,0xe4,0xb5,0x52,0xbf,0x46,0x40,0xec,0x10,0xaa, - 0xf2,0x41,0x27,0xdb,0xb4,0x5e,0x25,0x82,0xa9,0xad,0x7d,0xe5,0x7d,0x75,0xf5,0x65, - 0x7b,0x6f,0x4e,0x2e,0x4e,0x60,0x8a,0xaa,0x1d,0xca,0xb4,0x58,0x6b,0x45,0xbd,0xd5, - 0xdb,0x00,0xae,0xf3,0x73,0x1c,0xf3,0xe4,0x14,0x04,0x06,0xd3,0xd2,0xe8,0x34,0xde, - 0x01,0x97,0x0f,0x8b,0x52,0x0f,0xd6,0x47,0x25,0x5e,0x17,0xf4,0x7a,0x1e,0x0a,0x80, - 0x7c,0xc0,0x0f,0x28,0x1c,0x19,0x36,0xbc,0x53,0xde,0x15,0x9d,0xee,0x31,0x3e,0xd1, - 0x65,0x35,0x40,0x5b,0x3b,0x5d,0xd8,0x15,0xe8,0x39,0xd7,0xc3,0x7a,0x45,0xc7,0x7c, - 0x9a,0xf7,0x2b,0x52,0x1a,0xd6,0xfa,0x95,0xaf,0xf3,0x7e,0xf9,0x54,0xef,0x56,0x7c, - 0xb5,0xe1,0xa5,0x28,0xb0,0x46,0xe6,0x10,0x95,0x8a,0x4f,0xf6,0x0b,0x7c,0x19,0x46, - 0xa6,0x22,0xfe,0x2b,0xbd,0x97,0x3f,0x6a,0xe1,0x19,0xf1,0xc6,0x46,0xfc,0x54,0x05, - 0x9e,0x8a,0x01,0x91,0xef,0x0c,0x54,0xb0,0xe3,0x52,0x0e,0x1e,0x39,0x4f,0x0c,0x9b, - 0x2c,0x65,0xda,0xf1,0xea,0xc7,0x21,0xd7,0x4a,0x0f,0x7d,0xba,0xfd,0x2f,0x13,0x37, - 0xed,0x3f,0x9d,0xaf,0x88,0x32,0xdf,0x7d,0x25,0xf4,0xfc,0xee,0x6b,0x76,0xfb,0xdd, - 0x57,0x1c,0xd6,0x3f,0x25,0x8e,0xc8,0x07,0x5c,0x6a,0xf5,0x1e,0xa1,0x22,0x7f,0x13, - 0x6c,0xe8,0x37,0x05,0x8e,0xff,0xd3,0xb1,0x64,0x00,0xed,0x35,0xe6,0x24,0x2e,0xf2, - 0xfb,0x20,0x50,0x71,0x78,0x12,0xae,0x14,0x23,0xaa,0x32,0xea,0xd8,0xf4,0xa5,0x14, - 0xd2,0x51,0xba,0x63,0x41,0xdc,0x15,0x50,0x52,0x54,0x88,0x5e,0xaa,0x83,0x81,0xb2, - 0x0c,0x46,0xe2,0x1c,0x3a,0x6e,0x9b,0xba,0x20,0x26,0x42,0x6c,0x75,0xba,0x07,0xa2, - 0x40,0x13,0x39,0x14,0xa7,0x01,0xd0,0x6b,0x43,0x52,0xe7,0xb3,0x80,0xbb,0x29,0x99, - 0xa0,0x41,0x94,0x29,0x5d,0xe3,0x00,0x9b,0x12,0xb4,0xc5,0x8e,0x55,0xf2,0xda,0x84, - 0xae,0x35,0x25,0x5c,0x4a,0x1a,0xa8,0x5a,0xac,0xd1,0x87,0xbc,0xa9,0xce,0x2d,0xd9, - 0xc4,0x65,0x37,0x5d,0x8c,0x99,0x14,0x37,0x3a,0xa4,0x18,0x53,0x4e,0x22,0x00,0x20, - 0x49,0xeb,0xe7,0x73,0x26,0xaf,0xf6,0x4d,0xab,0x03,0xd1,0x65,0x51,0x58,0xf9,0xcb, - 0x93,0xd6,0xff,0xb8,0xad,0x2f,0x9f,0x16,0x3b,0xf7,0x2a,0xe2,0xb9,0x00,0xd2,0x3a, - 0x1b,0xde,0xb0,0x4a,0x82,0xea,0x8a,0x99,0xd0,0x89,0x30,0x4c,0x64,0xa7,0x25,0xee, - 0x92,0x65,0x48,0x59,0xa4,0x56,0x07,0xb2,0x21,0x50,0x7a,0x53,0xdc,0x8e,0x0a,0x53, - 0x2b,0xc3,0x6f,0x79,0xd3,0xb9,0xed,0x62,0xa8,0xcc,0x1c,0x32,0x38,0x77,0x58,0x8f, - 0xc2,0xd5,0x83,0xe2,0x17,0x22,0x6a,0x46,0x08,0x70,0xc5,0x91,0x4b,0xa2,0x85,0x4e, - 0x32,0x36,0x5c,0x16,0x00,0xf9,0x34,0xc9,0x77,0x37,0x3a,0x92,0x68,0x55,0xb4,0xd0, - 0x59,0xa1,0xe7,0x83,0x34,0x16,0xd3,0xc5,0x7a,0x79,0x88,0xa9,0x89,0xb7,0xf8,0x88, - 0xc8,0x48,0x25,0x78,0xc9,0x20,0x5a,0x0c,0xd3,0x98,0x73,0x79,0x91,0x8a,0xde,0x1c, - 0xda,0x06,0xf2,0xeb,0x5d,0x44,0xa4,0xa9,0xf0,0xfe,0x40,0xb6,0x0c,0x8d,0x9f,0xbe, - 0x7d,0x83,0x26,0x0b,0xdf,0xa3,0x18,0x4b,0x27,0xaf,0xda,0x18,0xc5,0x54,0x1a,0x92, - 0x32,0x67,0x50,0x30,0x8d,0x0c,0x7a,0xa5,0xdc,0xdd,0x38,0x3c,0x79,0x69,0x7a,0x11, - 0xbc,0x49,0xa1,0x60,0x79,0x08,0xa7,0xc3,0xde,0xa8,0xb8,0x57,0xd9,0x8c,0x1c,0x8f, - 0x4d,0x13,0x92,0x17,0xf7,0xe1,0x18,0x52,0x66,0x08,0x65,0x4a,0x44,0x2e,0x3d,0x35, - 0x28,0x08,0xca,0xcf,0x64,0xec,0x92,0x90,0x5d,0x41,0x6a,0xdc,0x69,0xcb,0x74,0x7f, - 0x29,0xb5,0x9d,0xfb,0xa9,0x88,0x90,0xee,0x59,0xcc,0xf0,0xfe,0x05,0xbb,0x88,0x23, - 0x47,0xc1,0xb8,0x7c,0x4f,0x21,0x35,0x2c,0x53,0x4f,0xc9,0x88,0x1e,0x19,0x11,0x46, - 0x00,0x73,0x64,0x8e,0x10,0x15,0xae,0x58,0x09,0xab,0x42,0x03,0x4f,0x4b,0x46,0xa3, - 0xe6,0xb2,0x5e,0x11,0x56,0xd5,0x18,0x29,0x56,0x8e,0xb6,0x2a,0x42,0xf3,0x96,0xe3, - 0xec,0x32,0x99,0x52,0x46,0x96,0x3b,0xec,0x94,0xe2,0x5e,0xb5,0x7b,0x23,0x59,0x1e, - 0x43,0x2c,0x2f,0x6e,0xa9,0x4c,0x5a,0x93,0xd5,0x29,0x3c,0xd6,0x31,0x1a,0x23,0xc1, - 0x2a,0x71,0x96,0xb5,0x70,0xca,0x0a,0x83,0xb8,0xd5,0x18,0x44,0x11,0x45,0x79,0x2b, - 0xa3,0x1d,0xef,0x0b,0xbf,0x54,0x6a,0xae,0x9a,0xad,0x6e,0xe9,0xfc,0x45,0xe9,0xaa, - 0xeb,0xa9,0x34,0x89,0xde,0x52,0x78,0xa2,0xd1,0x23,0x5f,0x52,0xea,0x28,0x3f,0xf7, - 0x45,0x0f,0x51,0x6b,0xf9,0xbc,0x96,0x45,0xec,0x61,0x65,0x44,0x4d,0xc3,0xd8,0x32, - 0x1b,0xc9,0x7f,0xb3,0x30,0x9c,0xe7,0x1d,0x32,0x0c,0x3d,0x98,0x8f,0x8c,0xa0,0x32, - 0x20,0x0d,0xd3,0x8b,0xc4,0x19,0xba,0x45,0xc9,0xfb,0x7c,0xc2,0x08,0x36,0x8a,0x08, - 0x3b,0x73,0xd8,0x6b,0xcc,0xa9,0xaf,0xd6,0x76,0x33,0x65,0x6f,0xce,0xf2,0xbb,0x7b, - 0x44,0xa0,0x9f,0x8d,0x2d,0x24,0xb3,0x90,0x19,0xe8,0xf4,0x35,0xe7,0xa0,0x36,0x18, - 0x22,0xaf,0x25,0xee,0x1e,0xdc,0xd2,0x88,0x10,0x9c,0x4b,0x63,0x8f,0x8a,0x05,0x7c, - 0xb2,0x04,0x77,0x1b,0x43,0x02,0x71,0xe7,0xa8,0x10,0xb9,0x25,0x11,0x7f,0x8d,0x81, - 0x4b,0x32,0xc8,0x7d,0x99,0xdf,0x5e,0x7e,0x93,0xba,0xe2,0xab,0x92,0x4a,0x60,0x56, - 0x3b,0xaa,0x58,0xdc,0x75,0x04,0x94,0xeb,0xd8,0xb0,0xb4,0xe3,0x69,0x2d,0x17,0x82, - 0xe8,0xa5,0xf9,0x8a,0x83,0x27,0xeb,0x45,0x61,0xca,0x00,0x7b,0xba,0x27,0x89,0x62, - 0xf7,0x51,0x2d,0xa3,0x70,0x3d,0xb5,0x62,0x98,0x79,0x0e,0xc9,0x17,0x46,0xdd,0xc3, - 0xc6,0x10,0x11,0xdb,0x4b,0x83,0x1a,0x57,0x06,0x54,0xc9,0xc9,0x89,0xb3,0x7d,0x44, - 0xd1,0xbc,0x68,0x55,0x05,0xd4,0x06,0x3e,0x4d,0xaf,0xec,0x7c,0x34,0x36,0xdd,0x96, - 0x94,0xbb,0x54,0x95,0x2e,0xd5,0x2c,0xef,0x12,0x7a,0x07,0xec,0x35,0xbf,0x69,0x93, - 0x02,0x41,0xca,0xf5,0xf0,0x72,0x4d,0xab,0x29,0x69,0x8c,0x56,0x66,0xb5,0xe6,0x2b, - 0x7c,0xa4,0x76,0xda,0x7d,0xad,0x86,0xb8,0x36,0xb3,0x92,0x07,0x51,0x15,0xce,0x4a, - 0x21,0x38,0xd5,0xf5,0x0a,0x5b,0xad,0x07,0x5b,0x3a,0x6a,0x1f,0x87,0x3d,0xcd,0x12, - 0x13,0x1e,0x0e,0xf6,0x40,0xab,0x57,0x10,0xca,0xe9,0x87,0x7e,0x17,0x8a,0x4c,0xdd, - 0x6e,0x2f,0xe8,0xc2,0xc3,0x5e,0x71,0x13,0x62,0x93,0xad,0x80,0xbc,0x8e,0xa2,0xeb, - 0x6a,0xa0,0x4f,0x66,0xf5,0x31,0x2b,0x0b,0x31,0x48,0x91,0x79,0xc0,0xb4,0xee,0x97, - 0x39,0x76,0x17,0x64,0x45,0x8c,0xb0,0xdd,0xd0,0x9a,0x46,0xe7,0x2a,0xf3,0xd5,0xa4, - 0x53,0x35,0xad,0xe3,0xcd,0xfc,0x02,0xda,0x6d,0xbc,0x3e,0x15,0xfb,0xc7,0x5b,0x61, - 0x37,0x7b,0xc6,0x7f,0x47,0x33,0x32,0xc9,0x90,0xd3,0x84,0x96,0xa4,0x45,0xf7,0x3f, - 0xb7,0x29,0x92,0x86,0x36,0xaa,0xd8,0x78,0x7d,0xbc,0xca,0x5d,0xde,0x60,0x0a,0xea, - 0x7f,0xe4,0xdd,0xe1,0x5f,0x3c,0x78,0x38,0x7a,0xf2,0x7f,0x01,0xdf,0x9a,0xa7,0x19, - 0xe7,0xdc,0x00,0x00, + 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0x03,0xd5,0x7d,0x69,0x7b,0xdb,0x46, + 0x92,0xf0,0x77,0xff,0x0a,0x18,0xc9,0x4a,0x40,0x04,0x42,0x24,0x75,0x58,0x26,0x45, + 0x69,0x15,0xc7,0x9e,0x78,0xe2,0x43,0x6b,0x29,0x3b,0xd9,0xd5,0x68,0x3c,0x20,0x01, + 0x8a,0x88,0x40,0x00,0x01,0x40,0x51,0x32,0xa5,0xf7,0xb7,0xbf,0x75,0x74,0x03,0x8d, + 0x83,0x14,0xe5,0x78,0x9e,0xdd,0x9d,0x4c,0x22,0x10,0xe8,0xb3,0xba,0xba,0xae,0xae, + 0xaa,0x3e,0x7c,0xfe,0xd3,0xc7,0x57,0xe7,0xff,0x75,0xfa,0x5a,0x9b,0x64,0xd3,0xe0, + 0xe8,0xd9,0x21,0xfe,0xd1,0x02,0x27,0xbc,0x1a,0xe8,0x5e,0xa8,0xe3,0x0b,0xcf,0x71, + 0xe1,0xcf,0xd4,0xcb,0x1c,0x6d,0x34,0x71,0x92,0xd4,0xcb,0x06,0xfa,0x2c,0x1b,0xb7, + 0x0e,0x74,0xf9,0x3a,0x74,0xa6,0xde,0x40,0xbf,0xf1,0xbd,0x79,0x1c,0x25,0x99,0xae, + 0x8d,0xa2,0x30,0xf3,0x42,0x28,0x36,0xf7,0xdd,0x6c,0x32,0x70,0xbd,0x1b,0x7f,0xe4, + 0xb5,0xe8,0x87,0xa5,0xf9,0xa1,0x9f,0xf9,0x4e,0xd0,0x4a,0x47,0x4e,0xe0,0x0d,0x3a, + 0x96,0x36,0x75,0x6e,0xfd,0xe9,0x6c,0x5a,0xbc,0x90,0x0d,0xb5,0xc6,0x7e,0x36,0x18, + 0x45,0x37,0x5e,0x82,0x3d,0x65,0x7e,0x16,0x78,0x47,0xef,0xbd,0x74,0xf2,0x2a,0x4a, + 0x3c,0xed,0x55,0x14,0x8e,0xfd,0xab,0xc3,0x6d,0x7e,0xfd,0xec,0x30,0xcd,0xee,0xf0, + 0x6f,0x2f,0x89,0xa2,0x6c,0xf1,0x4c,0xd3,0x5a,0xad,0xe1,0x55,0xef,0xbb,0xf1,0xee, + 0x78,0x7f,0x7c,0xd0,0x87,0x5f,0x23,0x27,0x71,0xe1,0xf7,0x78,0x8c,0x3f,0xfc,0xf0, + 0xba,0xf7,0x5d,0xc7,0xe9,0xee,0xec,0xb4,0xf1,0xe7,0x74,0x96,0xf5,0xbe,0xdb,0xdf, + 0x7f,0xb1,0x73,0xe0,0xe0,0xcf,0xc0,0x0f,0xbd,0xde,0x77,0x5e,0xd7,0x7b,0xe1,0x79, + 0x7d,0x6a,0xca,0x19,0x8d,0x7a,0xdf,0x75,0x87,0x2f,0x5c,0xef,0x65,0x9f,0x7f,0x72, + 0x13,0xa2,0xb9,0x08,0x5b,0x73,0x5f,0xba,0x7b,0xf4,0xcb,0x4b,0x92,0xde,0x77,0xee, + 0xfe,0xee,0xde,0xee,0x1e,0xfe,0x9c,0x3b,0x49,0xd8,0xfb,0x6e,0x74,0x70,0xf0,0xa2, + 0xe3,0x70,0x6b,0xa3,0x89,0x1f,0x43,0xfb,0xde,0xb8,0x3b,0x7e,0xc1,0xa3,0xe1,0xb1, + 0xca,0xb1,0x89,0x01,0x8c,0x0e,0xdc,0xae,0xeb,0xe1,0xab,0x74,0xe2,0xb8,0xd1,0xbc, + 0xd7,0xd6,0x3a,0xf1,0xad,0xb6,0x03,0xff,0x26,0x57,0x43,0xc7,0xe8,0xec,0x5b,0xdd, + 0x03,0x6b,0x77,0xcf,0xb2,0xdb,0x07,0x66,0xff,0xd9,0xc3,0xb3,0x7f,0x9f,0x7a,0xae, + 0xef,0x68,0x46,0x9c,0x78,0x63,0x2f,0x49,0x5b,0xa3,0x28,0x88,0x12,0x00,0xeb,0xc4, + 0x9b,0x7a,0x3d,0xd7,0x49,0xae,0xcd,0x45,0x05,0x3c,0x9d,0x76,0x67,0xaf,0x33,0x2a, + 0xc0,0x03,0x20,0xe9,0x74,0x87,0x39,0x84,0xbc,0x7d,0x6f,0x38,0xee,0xe6,0x10,0x3a, + 0x18,0xbe,0x3c,0x70,0x86,0x05,0x84,0xba,0xce,0xce,0xee,0x6e,0x57,0x81,0xd0,0xae, + 0xf3,0x72,0x77,0x4c,0x10,0xe5,0x29,0x76,0x77,0xba,0xee,0x8e,0xa3,0x4c,0xb1,0xb3, + 0x0b,0x3d,0x74,0x4b,0xb3,0xdc,0x71,0x76,0xf7,0xf7,0xf6,0x97,0xcf,0xb2,0x6d,0xe1, + 0x3f,0xf6,0x2e,0xce,0xf0,0xe1,0xd9,0x0f,0x8b,0x61,0x74,0xdb,0x4a,0xfd,0x2f,0x7e, + 0x78,0xd5,0x1b,0x46,0x89,0xeb,0x25,0x2d,0x78,0xd3,0x9f,0x3a,0xc9,0x95,0x1f,0xf6, + 0xda,0x0f,0xcf,0x10,0x7f,0x17,0xad,0xb9,0x37,0xbc,0xf6,0xb3,0x56,0xe6,0xdd,0x66, + 0x58,0xda,0x6b,0x39,0xee,0xef,0xb3,0x34,0xeb,0x75,0xda,0xed,0x7f,0x7b,0x78,0x36, + 0x8c,0xdc,0xbb,0xc5,0x18,0x90,0xb4,0xd7,0xd9,0x8b,0x6f,0xb7,0x3b,0xf6,0xee,0x9e, + 0x96,0xde,0xa5,0x99,0x37,0x6d,0xcd,0x7c,0xab,0xe5,0xc4,0x71,0xe0,0xb5,0xf8,0x85, + 0xa5,0x9f,0x79,0x57,0x91,0xa7,0xfd,0xfa,0x56,0xb7,0x3e,0x45,0xc3,0x28,0x8b,0xac, + 0xd4,0x09,0xd3,0x56,0xea,0x25,0xfe,0xb8,0x3f,0x74,0x46,0xd7,0x57,0x49,0x34,0x0b, + 0xdd,0xde,0x8d,0x93,0x18,0x08,0x54,0xb3,0x4f,0x60,0x17,0xbf,0x01,0x8c,0x66,0x3f, + 0x76,0x5c,0x17,0xc6,0x0b,0x03,0xcd,0xb2,0x68,0xda,0x7b,0xd9,0x8e,0x6f,0xfb,0x88, + 0xd5,0xe3,0x20,0x9a,0xb7,0x6e,0x7b,0x13,0xdf,0x75,0xbd,0x10,0x46,0xde,0xa1,0x31, + 0xd1,0x78,0x7b,0x9d,0x17,0x50,0x88,0x7e,0xce,0x3d,0xff,0x6a,0x92,0xf5,0xf6,0xf7, + 0x70,0x72,0x5d,0xb5,0xc8,0x6e,0xbd,0x48,0x9f,0x66,0x9c,0x25,0x30,0xc4,0x71,0x94, + 0x4c,0x7b,0xb3,0x38,0xf6,0x92,0x91,0x93,0x7a,0xfd,0xc0,0xcb,0x32,0x00,0x56,0x1a, + 0x3b,0x23,0x84,0x9d,0xdd,0xde,0xf3,0xa6,0xa5,0xa1,0xc2,0x12,0x9b,0x12,0x8e,0xdd, + 0x2e,0x80,0x1f,0x16,0x02,0x46,0x8a,0x9d,0xf6,0xc6,0x7e,0x92,0x66,0xb8,0xa8,0x81, + 0xbb,0xe0,0x22,0xad,0x2c,0x8a,0x11,0xdc,0xce,0x42,0x6d,0x03,0xb0,0xc0,0x84,0x0a, + 0x40,0x31,0xbc,0x64,0xe1,0xfa,0x69,0x1c,0x38,0x77,0xbd,0x71,0xe0,0xdd,0xf6,0x9d, + 0xc0,0xbf,0x0a,0x5b,0x3e,0x40,0x34,0xed,0x8d,0x80,0x36,0x78,0x49,0xff,0xca,0x89, + 0x7b,0xd8,0x83,0x84,0x4f,0xaf,0x83,0xbd,0x76,0xf6,0xe1,0x4d,0x0d,0xae,0x88,0x9e, + 0x66,0x3f,0x5f,0x70,0x82,0x23,0xa2,0x49,0x1a,0x05,0xbe,0xab,0x71,0x19,0xc4,0x27, + 0x00,0x76,0x94,0x02,0x7d,0x89,0xc2,0x5e,0x9a,0xf9,0xa3,0xeb,0xbb,0x3e,0x8d,0xb3, + 0xff,0x05,0x56,0xc2,0xf5,0x6e,0xa1,0x3f,0x39,0x3c,0x2d,0xbd,0xb9,0x5a,0xe0,0xd0, + 0x7a,0x61,0x14,0x7a,0x0f,0xcf,0xec,0x09,0x92,0xb2,0xc5,0x14,0xa6,0x46,0x84,0x0a, + 0x27,0xc7,0xef,0x34,0xd7,0xbf,0x51,0xc1,0x0e,0xa3,0xac,0x03,0x6e,0x3e,0x81,0xb9, + 0x11,0x74,0x3d,0x68,0x70,0x9e,0x38,0x71,0xbe,0xc4,0x62,0x81,0x79,0x69,0xf2,0x97, + 0x5e,0x10,0xf8,0x71,0xea,0xa7,0xd0,0xcb,0xd0,0x71,0xaf,0x3c,0x09,0xd7,0xc0,0x1b, + 0x67,0x3d,0x67,0x96,0x45,0xfd,0x7c,0x70,0x7d,0xa5,0xf3,0x4e,0xc3,0x9a,0x4b,0xf8, + 0xe1,0x9e,0x79,0x89,0xd0,0x63,0x30,0x25,0x8e,0xeb,0xcf,0xd2,0xde,0xcb,0x97,0x8d, + 0x00,0x85,0x0d,0x6a,0xd6,0xa6,0x21,0x07,0x63,0x03,0x91,0x9f,0xc5,0x0b,0xa5,0xd6, + 0x77,0xe3,0x1d,0xef,0xc0,0xdd,0x11,0x35,0xbe,0x3b,0x70,0xf6,0x46,0xed,0xf6,0x5a, + 0x24,0x67,0x69,0x8b,0x3b,0x4e,0x77,0xdc,0xd9,0x95,0x2d,0x7a,0xed,0xe1,0xee,0xde, + 0x08,0xb6,0xf8,0xd4,0xf1,0x43,0x00,0xc6,0xad,0x58,0x86,0xfd,0x5d,0x44,0x11,0xb9, + 0xbf,0x35,0x82,0x4c,0x8e,0x30,0xfb,0x88,0x9f,0x36,0x22,0xc7,0x62,0x25,0xca,0x2c, + 0xc1,0x95,0x32,0xa0,0x68,0x61,0xd5,0xa6,0xfb,0x44,0x6b,0x98,0x2a,0x71,0x35,0xfe, + 0x21,0xb7,0x49,0x8e,0x88,0xbb,0x34,0x8c,0xcc,0x19,0xa6,0x65,0xa4,0x47,0x0c,0xdf, + 0x5d,0x0e,0x7d,0xd9,0xd7,0x6e,0x6d,0xcd,0x3a,0xc5,0x9c,0x1b,0x3a,0xd1,0x86,0x33, + 0x78,0x15,0x32,0xf6,0x76,0xe4,0x14,0xdb,0x6a,0x37,0x84,0x36,0x35,0x24,0x25,0x92, + 0xe7,0x87,0x13,0xa0,0x5d,0x99,0x8a,0x54,0x3b,0xf6,0x5e,0x15,0xad,0xda,0x05,0x5a, + 0x01,0x25,0xd2,0xea,0x43,0x44,0xfa,0x34,0x9a,0x25,0x29,0xf4,0x10,0x47,0x3e,0x6e, + 0xe8,0xf2,0xe8,0x6c,0x18,0xe0,0x92,0x35,0xa9,0x11,0xc8,0x65,0x70,0x86,0x16,0xc7, + 0x8b,0x0a,0x18,0x76,0x08,0x0c,0x63,0x10,0x51,0x86,0x5e,0x90,0x83,0x7b,0x18,0x44, + 0xa3,0x6b,0x75,0x4a,0xdd,0xc6,0x29,0x95,0xdb,0xda,0x15,0x4d,0xd9,0x13,0x18,0xbf, + 0xba,0xc3,0x3b,0x54,0x79,0x09,0x71,0x24,0xca,0x47,0xa3,0xf0,0xc3,0x78,0x96,0x5d, + 0x64,0x77,0xb1,0x37,0xc0,0xbd,0x7d,0x69,0x29,0x2f,0x62,0x27,0x4d,0xe7,0x00,0xb0, + 0xd2,0xcb,0x70,0x36,0x1d,0x7a,0x49,0xe9,0x95,0x07,0xe8,0x1e,0x5c,0x5a,0xa9,0x17, + 0x78,0x23,0x62,0xcc,0x8c,0xf5,0xc8,0xa7,0xf2,0x05,0x38,0x40,0xb2,0xd8,0x16,0xd3, + 0x69,0x5a,0xbf,0xfd,0xca,0x68,0x19,0xa8,0x55,0xe0,0x13,0x0f,0x36,0x91,0x5d,0x2f, + 0xd9,0x14,0x82,0x27,0x57,0xf7,0xc5,0x01,0xb2,0xab,0x59,0x46,0xec,0x9a,0x68,0xa5, + 0xa6,0x6d,0xff,0xa0,0x1d,0x0d,0xb0,0xdb,0x9e,0x96,0x02,0x38,0x52,0xcd,0xff,0x78, + 0xa6,0x7d,0x89,0xa2,0x69,0x2b,0x0a,0x5b,0xe3,0x68,0x34,0x4b,0x35,0x23,0xf5,0x5d, + 0x6f,0xee,0xdc,0x01,0xab,0x1c,0x25,0x51,0x10,0x80,0xdc,0xa7,0x8d,0x9c,0x38,0xf3, + 0x6f,0x3c,0x2d,0x9d,0x78,0x5e,0x66,0x6a,0x3f,0x6c,0x33,0x08,0x7b,0x54,0x43,0xc0, + 0x80,0x7f,0x2c,0xc4,0x10,0xaa,0xcc,0x45,0x45,0x95,0xb6,0x86,0xff,0x20,0xcf,0x60, + 0xaa,0x33,0xf5,0x6f,0x0d,0xe8,0x24,0x05,0xb1,0xc1,0x2a,0x6a,0x68,0xdd,0xbd,0x7f, + 0xb3,0x88,0x21,0xc6,0x4e,0x02,0x8c,0xc7,0x14,0xeb,0x66,0xbb,0x7e,0x92,0xdd,0x89, + 0x4e,0xf9,0x47,0x53,0xa7,0x28,0xbd,0x21,0x1e,0x26,0xd1,0xbc,0xbe,0xb7,0x89,0x3f, + 0xe2,0xa7,0x23,0xc0,0x53,0xb1,0x1b,0xcb,0x0c,0x24,0x9d,0x3f,0xca,0x06,0x51,0x2e, + 0xf1,0xc7,0x77,0x2d,0x21,0x34,0xf7,0x88,0x87,0xb4,0x86,0x5e,0x36,0xf7,0x80,0x69, + 0xd4,0x98,0xe4,0x4b,0xe4,0xcc,0xd4,0xb2,0x06,0x25,0x43,0x6d,0xb8,0x7c,0x0f,0x34, + 0x6e,0x6b,0xa5,0xaa,0x2f,0x70,0x1e,0x25,0x67,0x58,0xd9,0x64,0xea,0x04,0xfd,0x47, + 0x77,0x01,0xee,0xf2,0xab,0x60,0x91,0xb3,0xd9,0xc4,0x0b,0x1c,0x5c,0xd4,0x3e,0x4f, + 0x7a,0x17,0x89,0xe8,0x84,0x7b,0xeb,0x92,0x80,0xa2,0xb0,0x58,0xa8,0xa8,0x11,0xf0, + 0x8b,0xea,0x40,0x30,0xa2,0x60,0x96,0x79,0xfd,0x08,0x05,0x93,0xec,0x0e,0xc8,0x98, + 0xb2,0x03,0x44,0x43,0xf4,0x2c,0x59,0x40,0x03,0xd5,0x81,0x66,0x67,0x0d,0x4d,0xfa, + 0x21,0x30,0x9d,0x32,0x5d,0x5c,0x89,0xe8,0xc4,0x29,0x09,0x55,0xb8,0x21,0xbb,0xb3, + 0x97,0xf6,0x45,0x37,0x2d,0xef,0x06,0x16,0x27,0x55,0x67,0x32,0xeb,0x0d,0x3d,0x10, + 0xb2,0xbc,0x85,0x5c,0x39,0x5d,0xef,0xd7,0x07,0x21,0xa8,0x45,0x9f,0xf8,0x3a,0x3e, + 0x88,0xe9,0x1d,0x14,0x70,0xa2,0xe7,0x12,0xaf,0x05,0x4d,0xa0,0x3c,0xb4,0x3d,0x00, + 0x40,0x65,0x64,0x2a,0x3c,0x7b,0xc0,0x70,0x47,0xd7,0x9e,0xbb,0x35,0xab,0xd3,0x5c, + 0x96,0xc9,0x9a,0xca,0xca,0xf1,0xd3,0xd0,0xba,0x1d,0x24,0x69,0x82,0x76,0x0f,0xb3, + 0x30,0x47,0x2b,0x3f,0x44,0x58,0xb5,0xd6,0x45,0x5f,0x45,0xb8,0xdb,0xcf,0x99,0x06, + 0x2c,0xc2,0x32,0xda,0xd5,0x24,0xc4,0x02,0x89,0x52,0xd1,0xbd,0xb3,0x5f,0x17,0x6a, + 0x6a,0xdc,0xa7,0xdf,0x38,0xf1,0x7e,0x85,0x80,0x10,0x69,0x54,0xa7,0x09,0x72,0xc9, + 0x68,0xb1,0x86,0x74,0x54,0xad,0xd7,0x03,0xf0,0x38,0xc3,0xc0,0x73,0x17,0x12,0x71, + 0xed,0x3d,0x39,0x22,0xd7,0x1b,0x3b,0xb3,0x20,0x2b,0x77,0x33,0x5d,0xd4,0xb8,0x93, + 0x9c,0xe3,0xbe,0xa0,0xef,0x28,0xca,0x40,0xc7,0x55,0xc8,0xd7,0xf6,0xf5,0x2a,0x19, + 0x10,0xc9,0xe1,0x41,0xb3,0x0c,0xa8,0x0a,0x98,0x08,0x51,0x90,0x44,0x41,0x44,0x06, + 0x4d,0x9c,0x56,0xb5,0xd7,0xc9,0x07,0x60,0x47,0xd7,0x2a,0x48,0x96,0x51,0xd7,0xe8, + 0xda,0xd4,0x3a,0x15,0xe2,0x5a,0x82,0x59,0x84,0x20,0xe3,0x16,0x41,0x27,0x5e,0xa7, + 0x49,0x28,0xf6,0x48,0x9b,0x58,0x02,0x49,0x98,0x73,0xe3,0x0d,0x9d,0xa4,0xd8,0xf4, + 0x63,0xff,0xd6,0x73,0x79,0x93,0xb5,0xfb,0x09,0x81,0x05,0x36,0x3e,0xf3,0xf9,0x42, + 0xfc,0xef,0xb6,0x1f,0x51,0x2d,0x70,0xb7,0x36,0xcb,0x8a,0x0a,0x4e,0xe2,0x5a,0x11, + 0x52,0x02,0x43,0x0b,0x46,0x06,0xfd,0xdc,0xd2,0xbc,0xf0,0xc6,0x48,0x9d,0x31,0xa8, + 0x9a,0x89,0xe7,0xb4,0x88,0xf6,0x08,0x41,0xc3,0x34,0xfb,0x72,0x49,0x49,0x2e,0x5b, + 0xa6,0x0b,0x75,0x69,0x05,0xc4,0xd4,0xec,0x74,0x52,0x61,0x39,0xc5,0x37,0x20,0xfa, + 0x55,0x32,0xaf,0x7c,0xb4,0xd3,0x58,0x30,0x23,0x7c,0x19,0x80,0xbe,0xff,0x14,0x21, + 0xb8,0xaa,0x90,0x55,0xc5,0x50,0xc6,0x53,0x6c,0x16,0x3a,0xca,0xd6,0x52,0xf2,0x0e, + 0x1e,0x6b,0xa5,0x79,0x3e,0xb1,0x1f,0x04,0xea,0xfb,0x76,0x8d,0x9d,0xbd,0x68,0xaf, + 0x85,0xf8,0x6b,0x2a,0x3f,0x4f,0xd0,0x9d,0x77,0xbd,0xa9,0x18,0xe0,0xb7,0xdd,0x2b, + 0xd4,0xe2,0x18,0x24,0x42,0x8b,0x1f,0x01,0xba,0xa3,0x6f,0xba,0x6f,0xa8,0xd5,0xb9, + 0xe3,0x67,0xeb,0xb4,0x4a,0xb2,0xcf,0xea,0x66,0xa5,0x78,0x94,0xf9,0x81,0x57,0x28, + 0x3f,0x57,0x89,0xef,0xf6,0xf1,0x3f,0x2d,0x40,0x05,0x78,0x03,0x9a,0x31,0x54,0x9a, + 0x4d,0xc3,0x14,0xc4,0x85,0xd8,0x73,0x32,0x03,0xd5,0xb8,0xd6,0x18,0x06,0x63,0x81, + 0xbc,0x04,0xca,0x9e,0xd1,0x41,0x35,0xcf,0xea,0x8c,0x13,0xd8,0x2c,0xb9,0x68,0xc5, + 0xed,0x2e,0x23,0xcf,0xab,0x10,0x97,0x76,0x68,0x37,0x6f,0xa2,0x51,0xd4,0x59,0x2a, + 0x37,0x35,0xd1,0xd7,0x3f,0x8d,0x2c,0x34,0x8c,0x12,0x9e,0x1f,0x34,0x59,0x78,0xb8, + 0x5c,0xba,0xa0,0xe6,0x5d,0x6f,0x14,0x25,0x0e,0xd1,0xb7,0x9a,0x1d,0x60,0x89,0x70, + 0x36,0x72,0xc2,0x1b,0x27,0x5d,0xd4,0x65,0xa8,0x3d,0x24,0xf9,0xa5,0x29,0x43,0x67, + 0xa1,0x97,0xad,0x6f,0xa6,0x51,0xda,0x5c,0x4b,0xe5,0x24,0x2d,0xa4,0xc4,0xf6,0xab, + 0x34,0x54,0xe1,0xeb,0x2b,0x6d,0x3a,0x15,0x4e,0x4f,0xb0,0x61,0xa6,0x85,0x04,0x9f, + 0xe7,0xd1,0x0b,0x9c,0xdc,0x44,0x55,0x6e,0xb3,0xcd,0x05,0x80,0xd0,0xa4,0x52,0x48, + 0xaf,0xea,0x85,0xeb,0x99,0x6a,0x1a,0x8c,0x3c,0xa2,0xe9,0x14,0xf0,0x29,0x58,0xd4, + 0x57,0x03,0xf4,0xa4,0xb7,0xc4,0xc4,0xb5,0x14,0x16,0x06,0x26,0x1f,0x7a,0x81,0x66, + 0x7c,0xf8,0x78,0xae,0xc1,0x2f,0x6c,0x1f,0x00,0x6f,0xf6,0xb4,0x6c,0xe2,0x91,0x06, + 0xf5,0xea,0xc3,0x09,0xeb,0x46,0x1a,0xcc,0x0d,0x24,0xb3,0x54,0x8b,0x93,0xe8,0x2a, + 0x71,0xa6,0x53,0x40,0x82,0x11,0x40,0x4f,0x1b,0x06,0xb3,0xc4,0x30,0x2d,0xa8,0xee, + 0x6a,0xf3,0x89,0x17,0x52,0xd5,0x6b,0xef,0x6e,0x18,0x01,0x17,0xd3,0xfc,0x54,0x9b, + 0xc5,0xd4,0x12,0xeb,0x5c,0xa9,0x46,0x6c,0x51,0xf6,0x94,0x6a,0xd1,0x78,0x0c,0x5f, + 0x3c,0xac,0x17,0x61,0x73,0xd7,0x9e,0x17,0x53,0x13,0xa4,0x74,0x41,0xc9,0xb1,0xef, + 0x05,0x00,0x7b,0x3f,0xf5,0x41,0xb6,0xb1,0xb5,0x13,0x31,0x64,0xa0,0x0b,0xbc,0x69, + 0x34,0x04,0x88,0x06,0xda,0x12,0x28,0x4d,0xc1,0x9d,0x06,0xeb,0xee,0x25,0xd8,0x00, + 0x36,0x76,0x76,0xf6,0xf6,0x27,0xd1,0x00,0x30,0xe5,0x14,0x2b,0x65,0x13,0x27,0xd3, + 0xae,0x66,0x0e,0x6c,0x93,0xcc,0xf3,0xdc,0x96,0x68,0x18,0x54,0x45,0x00,0x09,0xb0, + 0xc8,0xcc,0x73,0x5c,0x1b,0xf5,0x40,0x1b,0xe1,0x43,0x7d,0x3d,0x99,0x59,0x2d,0x65, + 0xe8,0x2b,0x6d,0x37,0xbd,0x96,0x30,0x71,0xb2,0x3d,0x21,0xef,0x1e,0xb9,0x67,0x0b, + 0x2d,0x84,0x6b,0xed,0x8b,0x7d,0xb9,0x85,0x0b,0xcb,0xe0,0x37,0x36,0xc8,0xd6,0x28, + 0x9a,0xb6,0x5b,0x19,0xf0,0x77,0xf8,0x08,0xe0,0x01,0x66,0x8c,0x56,0x33,0xb1,0xdd, + 0x77,0xf7,0x6e,0x26,0x05,0x5a,0x97,0xac,0x66,0x6d,0xe4,0x93,0x1a,0x9b,0xce,0x60, + 0x05,0xe2,0x06,0xa3,0xd5,0x7e,0x9d,0x63,0x2b,0xe5,0x91,0x9a,0xf2,0x3e,0x92,0x9d, + 0xd5,0xf8,0x6e,0xb7,0x69,0x61,0x68,0x09,0x8b,0x46,0x1a,0x2d,0x44,0x52,0x5b,0x89, + 0x60,0x37,0x57,0xa5,0x3b,0x14,0xce,0xf6,0x77,0xa5,0x2e,0x95,0xab,0x44,0x04,0x57, + 0x7a,0x42,0x4e,0xf3,0x9b,0xd1,0x82,0x2f,0x66,0x2e,0xf2,0xed,0x36,0xea,0x7f,0xd7, + 0x65,0x36,0x26,0xec,0x22,0x6b,0x9b,0xc4,0x56,0x68,0x25,0x85,0x1e,0xab,0xea,0x6b, + 0xdd,0x66,0x4d,0xb2,0x5f,0xd8,0x39,0x5f,0xb6,0x6f,0xe6,0x72,0xe2,0x2c,0xfb,0xc9, + 0x96,0x50,0x8c,0x13,0x3b,0xb8,0x0a,0x11,0xa9,0xe1,0xca,0xc9,0xee,0xb5,0x9b,0x8f, + 0x24,0x1e,0x13,0x3f,0x97,0x28,0x71,0x0a,0xa1,0x15,0x6f,0x72,0x69,0x8b,0xb1,0x50, + 0x0c,0xab,0x59,0x56,0x8d,0xfd,0x50,0xf0,0xa1,0xae,0x6a,0x14,0xe8,0x16,0x5a,0xe1, + 0xce,0xaa,0x3d,0x0e,0xab,0xdd,0x64,0xfe,0xa9,0x6a,0xc5,0x4e,0xe8,0x4f,0x99,0x45, + 0x26,0x5a,0x27,0xd5,0xb0,0x09,0x90,0x81,0xfd,0x70,0x8c,0xe7,0x8c,0x5e,0xbf,0x49, + 0x97,0x7a,0x78,0xf6,0xef,0x40,0x32,0xc7,0x40,0x57,0x81,0xc2,0x26,0x8b,0x2c,0x5a, + 0x14,0x78,0x94,0x44,0x19,0x20,0x91,0xb1,0xb3,0xdf,0x76,0xbd,0x2b,0xf3,0x01,0xe6, + 0x01,0x92,0x13,0x9d,0x23,0x2d,0xaa,0xe2,0x54,0xc9,0xd4,0x52,0x6c,0x18,0xc4,0x52, + 0x92,0x78,0xe1,0x59,0x51,0xf0,0xa1,0xa5,0xeb,0x9b,0x9a,0x98,0xdb,0x2f,0x6d,0xbd, + 0x35,0x4c,0x41,0xdd,0x8a,0xde,0xd8,0x5e,0x87,0x81,0x52,0xdf,0xab,0x19,0xe4,0xf5, + 0x4d,0xa3,0x8c,0xd4,0x60,0xfd,0x81,0x92,0x42,0x8c,0x51,0x36,0x86,0x82,0x29,0xa4, + 0x7c,0xad,0x7f,0xf8,0x01,0xdf,0xbd,0x85,0x8a,0x9f,0xcf,0xfd,0x29,0x1e,0xfe,0x02, + 0xc7,0x40,0xe6,0x1a,0x65,0x5e,0x5d,0x71,0xae,0x51,0xc9,0xb5,0x24,0xc4,0x83,0x8a, + 0x15,0x8d,0xe4,0xbc,0x26,0xeb,0xf2,0xe1,0xb6,0x38,0x4c,0x3e,0xdc,0x16,0x87,0xdf, + 0x78,0x60,0x28,0x8e,0xc2,0xbd,0xe4,0x08,0x28,0xc5,0x61,0x7a,0x73,0xc5,0xa6,0xda, + 0x81,0xde,0xdd,0xd7,0x35,0x5e,0x68,0x7e,0xc6,0xe3,0xeb,0x1f,0xa3,0xdb,0x81,0x4e, + 0xe6,0xc9,0x5d,0xf8,0xbf,0xae,0xa1,0xa8,0x3b,0xd0,0x71,0x7a,0xba,0x96,0x66,0x49, + 0x74,0x8d,0xe7,0xe5,0x39,0x5a,0xcb,0x77,0x2d,0xd9,0x62,0xfe,0x02,0x97,0x6f,0xe4, + 0xc4,0x03,0x9d,0x26,0xa7,0x63,0xd7,0xd0,0xf9,0xc8,0x4f,0x46,0xc0,0x45,0x47,0xd0, + 0x47,0x07,0xca,0x8e,0xee,0xf8,0x6f,0x02,0x35,0xed,0xae,0xec,0xac,0xde,0xbc,0x18, + 0xc0,0xb6,0x68,0x25,0x76,0xb2,0x89,0xe6,0x0e,0xf4,0xf7,0x2f,0xec,0x17,0x1a,0xfc, + 0xeb,0xec,0x6b,0xfb,0x5a,0x5b,0xfc,0x73,0x60,0xef,0xbf,0xef,0xec,0xdb,0x3b,0xa5, + 0x0f,0x1d,0xf1,0x61,0xd7,0x7e,0xa9,0xc1,0xbf,0x4e,0x07,0x8f,0x08,0xf3,0x2a,0x9d, + 0x5d,0xbb,0xfb,0xbe,0xf3,0xd2,0xee,0x54,0xbe,0x75,0xc4,0x37,0xee,0x18,0x80,0x7b, + 0x73,0x45,0x0f,0xae,0x7f,0xa3,0x8d,0x00,0x29,0xd3,0x81,0x4e,0xe7,0x6c,0x72,0x76, + 0x93,0x8e,0xe6,0xc3,0xb0,0x26,0x2d,0x74,0x2b,0xd0,0xf3,0x13,0x7f,0x58,0x8c,0x8e, + 0x28,0x81,0x35,0xb9,0x48,0x3a,0x1b,0xea,0x47,0xb0,0x5d,0x00,0x4a,0x19,0xac,0xeb, + 0xc6,0x84,0x30,0xab,0x7f,0xb8,0x0d,0x45,0xb8,0x37,0xf9,0x40,0x16,0x4f,0xd1,0x1d, + 0x9d,0x48,0x69,0x88,0x79,0xba,0x68,0x87,0xde,0xe8,0x47,0x30,0x38,0x28,0x26,0x17, + 0x1e,0x97,0xfa,0x10,0x8f,0xa3,0x8e,0x9e,0x3d,0x3b,0x7c,0xde,0x6a,0x69,0x03,0xe5, + 0x7f,0xda,0xbb,0x8f,0x7f,0x79,0xfb,0xa1,0xfc,0xaa,0xd5,0x42,0x47,0x04,0x1c,0x4a, + 0x14,0x52,0xc3,0x37,0xad,0x20,0x02,0xf4,0xd2,0xf3,0x79,0x62,0x97,0xd5,0xc9,0xa3, + 0xbc,0x92,0xcf,0xbd,0x7b,0x74,0xe2,0x02,0xe5,0xd0,0xa8,0x1e,0x0c,0xa3,0x2b,0x17, + 0x4b,0x23,0x9c,0x1c,0xe8,0x35,0x0a,0xb2,0xec,0x98,0x42,0xa2,0x34,0x50,0x0c,0xfd, + 0xe8,0x35,0x52,0x6f,0x10,0xc7,0x40,0x42,0x0c,0x23,0xd7,0xdb,0x4c,0x35,0x87,0xba, + 0x91,0xc7,0x14,0x9a,0x81,0x52,0x60,0x0a,0x00,0xd7,0x50,0x34,0x63,0x59,0x30,0x4a, + 0xb4,0xc4,0x9b,0xc2,0x0e,0xd4,0x5e,0xbd,0x7b,0xcb,0x15,0x4c,0xfb,0x70,0x3b,0x16, + 0x43,0x42,0x7a,0x49,0xb3,0xa4,0xb1,0xb6,0xf0,0xa7,0x98,0x46,0x79,0x7e,0x63,0x80, + 0x2b,0x9d,0xd6,0x60,0x8b,0xb2,0x74,0x3c,0x87,0x39,0x9f,0x8a,0xde,0x0f,0xb7,0xe9, + 0xbb,0xac,0x0c,0xd5,0xc9,0x52,0xa9,0xd1,0x09,0x89,0x2e,0xc7,0xa8,0x2b,0x9d,0x61, + 0x75,0x3a,0x0b,0x1c,0x45,0xa0,0x60,0x7a,0x19,0x14,0x03,0x25,0x01,0xf5,0xd3,0x56, + 0x51,0x1c,0xbf,0x93,0x60,0x7b,0x94,0xa3,0x01,0x35,0xce,0x66,0xb9,0x1c,0x19,0x32, + 0x58,0x1f,0xee,0x09,0xb0,0x69,0xea,0x67,0x6a,0x3f,0xf8,0xf1,0xe8,0x0c,0xc8,0x9a, + 0x86,0xab,0xc1,0x15,0x9b,0xe6,0x28,0x99,0x84,0x5a,0x17,0xde,0xe9,0x6a,0xcf,0x87, + 0xdb,0x08,0x22,0x05,0x2b,0x01,0xdb,0x18,0x57,0x1a,0xf1,0xeb,0xec,0xf5,0xf9,0xaf, + 0xa7,0xda,0xdf,0xde,0xfe,0xf7,0xc9,0xa7,0x9f,0x1e,0x45,0xb3,0xb9,0xff,0x05,0xb1, + 0x68,0x25,0x9e,0x21,0x2d,0xe5,0xf1,0x8d,0x42,0xa7,0x45,0xbf,0x8e,0xde,0xb2,0x06, + 0xf1,0x37,0xff,0x8d,0x0f,0x42,0x3b,0x8a,0x05,0x80,0x13,0x51,0x3c,0x8b,0x8f,0xb5, + 0xb7,0x19,0x54,0x8c,0x52,0x60,0x91,0xfe,0x58,0xbb,0x8b,0x66,0x89,0x16,0x4f,0x10, + 0x37,0xd2,0x00,0x94,0x86,0xd4,0xe6,0x29,0x39,0xda,0x24,0xf1,0xc6,0x03,0xfd,0x3b, + 0x1d,0xf0,0x66,0x14,0xf8,0xa3,0x6b,0x58,0xaf,0x28,0xfe,0x38,0xcb,0x0c,0xb3,0x9f, + 0x78,0xd9,0x2c,0x09,0xb5,0xb1,0x13,0xa4,0x44,0xf6,0x08,0x7f,0xeb,0x3a,0x94,0x7e, + 0xf4,0x31,0xf6,0x10,0xc0,0xdc,0xcb,0x30,0x89,0xe6,0xa9,0x97,0x1c,0x6e,0x3b,0x47, + 0xa0,0xa5,0xb0,0x8a,0xc2,0xdd,0xa3,0x2e,0xe4,0xa5,0xa9,0x5d,0x6c,0x6a,0x65,0x76, + 0x24,0x54,0x02,0xbc,0x7d,0x9a,0x61,0x9a,0xc5,0x9d,0x1c,0x1a,0x51,0x88,0xeb,0xe0, + 0x2b,0xdf,0xba,0xd5,0x17,0x3b,0xd5,0x17,0xbb,0xe2,0x05,0x77,0xf5,0x4c,0x21,0x3a, + 0xf3,0x2f,0x1d,0x5d,0xa1,0x43,0xf5,0x4d,0xcc,0xdb,0xf8,0x0c,0xc6,0x03,0xa4,0x6f, + 0x63,0x0a,0xfc,0x2f,0xca,0xfa,0x04,0xe2,0x62,0x3f,0xff,0xe9,0x1d,0x2d,0x86,0x02, + 0x0b,0x3f,0xf6,0x41,0x5e,0x88,0xef,0xf4,0xa3,0x57,0x4c,0x06,0x8b,0x5d,0x8e,0xe0, + 0x23,0xc8,0xd1,0xf2,0x82,0x9e,0x0a,0xdb,0xe2,0x1a,0xa4,0x03,0xcd,0x87,0xa5,0x05, + 0x6a,0x98,0x78,0xce,0x68,0x42,0xeb,0xff,0xfe,0x3f,0xce,0xcf,0x35,0x00,0x3a,0xf0, + 0xe7,0xb4,0xd8,0xe1,0x4b,0xb6,0xf1,0xd1,0x07,0xd1,0x12,0xd2,0x67,0xcd,0x40,0xb5, + 0xcf,0xac,0xef,0x60,0xa5,0x26,0x2c,0x68,0xbe,0xfc,0xc2,0x1e,0xa8,0x17,0x25,0x2b, + 0xbb,0x9d,0x77,0x91,0xeb,0x64,0x4e,0xeb,0x1a,0x1d,0xc8,0xc6,0x3e,0xe8,0xed,0xbe, + 0x9b,0x4f,0x98,0x7f,0xd0,0xd6,0x77,0x62,0x3f,0x03,0x99,0xe3,0x0b,0xd4,0x02,0xc5, + 0x56,0xd2,0x83,0x04,0x75,0x53,0xf1,0x46,0x42,0x98,0xb4,0x95,0x72,0x9f,0x35,0x22, + 0x00,0xf3,0x1f,0x49,0x42,0xc0,0x1f,0x11,0xa7,0xa7,0x11,0xd2,0xc2,0x68,0x1e,0xc2, + 0x1e,0x47,0xb9,0xdd,0x8e,0x13,0xfa,0xfb,0x13,0xdb,0xf8,0x0d,0x53,0x41,0xfc,0x08, + 0xd0,0xf8,0x0c,0x00,0x6b,0x6c,0x8a,0x71,0x6e,0x9a,0x40,0x3c,0xe0,0x45,0x95,0x72, + 0x48,0x0a,0x50,0x26,0x4b,0x8d,0xb0,0x7e,0x2a,0x89,0x2c,0x01,0xae,0x81,0x4a,0x22, + 0x58,0x9a,0x57,0x09,0x0f,0xc1,0xf5,0xa3,0x77,0x9e,0x73,0xe3,0x69,0xc3,0xc0,0x09, + 0xaf,0x89,0x01,0xa0,0x9d,0x02,0x77,0xa7,0xc0,0x1e,0x5b,0xeb,0xda,0xbb,0x1b,0x80, + 0x61,0x61,0x1a,0xf7,0xff,0xf2,0xf3,0x17,0xf9,0x3e,0x05,0x28,0x04,0x77,0xf6,0xda, + 0xd3,0xfa,0x80,0xe8,0x89,0xf8,0xb3,0x7a,0x5e,0x65,0x64,0x20,0x79,0x00,0xdd,0x05, + 0x03,0x2f,0xbc,0x42,0x39,0x69,0xa7,0xb3,0x6a,0x2e,0x12,0x63,0xb0,0x5a,0x8b,0x67, + 0x77,0x06,0x7a,0x0a,0x4c,0x88,0x89,0x1e,0x48,0xff,0x13,0x32,0xa4,0x00,0xe5,0xe1, + 0x0d,0x00,0xa2,0xff,0x2c,0xc5,0xf7,0xa9,0x73,0xe5,0xa5,0xf5,0xd9,0xa8,0x8f,0x0d, + 0x4c,0x24,0x47,0x84,0xf9,0x97,0xbf,0x44,0x46,0x17,0x16,0xff,0x03,0x8c,0xbf,0xa7, + 0x7d,0x02,0xf9,0x33,0x52,0x71,0xa0,0x91,0xaa,0x74,0xeb,0xd4,0x7b,0x1d,0x12,0xd3, + 0x2d,0x48,0x8c,0xe8,0xe7,0x9b,0xd1,0x18,0x5a,0x25,0x5e,0x59,0x10,0x70,0x41,0xab, + 0x22,0x7a,0x11,0x01,0xec,0x12,0x8d,0x38,0x0f,0xec,0x03,0x3c,0x40,0xd2,0x50,0xc0, + 0x8e,0x60,0xff,0x64,0x28,0x83,0xa5,0xb6,0x76,0x0a,0x50,0x20,0x10,0xc3,0x66,0x81, + 0xb7,0x4c,0x84,0x40,0xf3,0x82,0xa2,0x04,0x74,0xd8,0x52,0x6b,0x90,0x99,0x4f,0xde, + 0x15,0x32,0x38,0x6e,0xa3,0x8e,0x27,0x7c,0x72,0x2f,0x17,0x39,0x89,0x09,0xfe,0x13, + 0x27,0xbc,0x42,0x26,0xf3,0x85,0x60,0x71,0xe6,0x05,0x06,0x52,0x43,0x93,0x24,0x3b, + 0x2a,0xbf,0x06,0xbe,0x24,0xa0,0x77,0xce,0x72,0xd6,0xbd,0x06,0x32,0x9f,0xff,0x06, + 0x3c,0x73,0x0e,0x40,0x31,0xdc,0x1f,0xa7,0xe6,0x6a,0x8c,0x66,0x17,0x90,0x02,0xa7, + 0x33,0x20,0xe5,0x20,0x57,0x0d,0xf4,0xd6,0x4b,0x42,0x6d,0x40,0xea,0x76,0x4e,0xb7, + 0x0a,0xeb,0x40,0xa7,0xdb,0x2e,0x11,0xcd,0xfa,0xc6,0x7d,0xcf,0x5e,0xb4,0x5a,0xe0, + 0x5d,0x01,0x98,0x79,0x3c,0xb0,0xbc,0x3e,0x2c,0xe0,0xf0,0x0e,0x48,0x3d,0xc1,0x72, + 0x63,0xea,0x3a,0xe9,0xa4,0xaf,0xd1,0x11,0xb0,0x58,0x91,0x64,0x16,0x34,0xe1,0x7a, + 0x83,0xec,0x00,0xe8,0x0c,0x62,0xa1,0x5c,0x66,0x36,0xee,0xe1,0x99,0xc0,0x66,0xa6, + 0xa1,0xf9,0xc9,0x73,0x8f,0xb5,0x57,0x93,0x08,0x44,0x06,0xd8,0x1d,0x47,0xbf,0x20, + 0xdf,0x16,0x32,0x59,0x5e,0x07,0x36,0xc1,0x91,0xa5,0xa1,0x6a,0x0e,0x58,0x40,0x1e, + 0x61,0x16,0xe2,0x49,0x88,0xaf,0xbc,0x56,0x36,0x23,0xab,0x20,0x61,0x4e,0xa3,0x64, + 0x41,0x36,0x88,0x93,0x38,0xae,0xca,0x16,0x20,0x33,0xdf,0x38,0xe1,0x08,0x06,0xe7, + 0xb9,0x7e,0x16,0x91,0xec,0x60,0x2f,0xd9,0xb7,0x95,0x0d,0x95,0x4b,0xc0,0xad,0xda, + 0xf6,0x3a,0x29,0x49,0xc8,0xdf,0x72,0x5f,0x7d,0xf2,0x40,0xdb,0x1d,0x79,0x29,0x9b, + 0x5e,0x9d,0x11,0x0c,0xf9,0x2e,0xef,0xc8,0x46,0xc9,0x0b,0x2d,0xb9,0x28,0x83,0x03, + 0x07,0x4f,0x59,0xfa,0x84,0x1d,0x98,0x78,0x1a,0x5a,0xb8,0x12,0x22,0x5a,0xf0,0x85, + 0x24,0x72,0x04,0x3c,0x99,0x60,0x99,0xe5,0xb3,0xc0,0x0e,0x1b,0x16,0x75,0xed,0x9c, + 0xcc,0xad,0xc3,0xd3,0xe7,0xca,0x54,0x9f,0xc6,0x6b,0x8a,0x37,0x0a,0x6d,0xee,0xec, + 0x55,0x59,0x4f,0xe8,0xcd,0x0b,0xe1,0x7c,0x1d,0x54,0xee,0xec,0x91,0xc3,0x39,0xc0, + 0x87,0xc5,0x92,0x35,0xb7,0x23,0x79,0x86,0x83,0x6a,0xf2,0x55,0xd3,0x19,0x8d,0xa7, + 0x5f,0x39,0xa1,0xc7,0xf1,0x0d,0xe5,0xa0,0xe5,0x4a,0x08,0xcb,0x1f,0x65,0x1e,0xd2, + 0x01,0xca,0xf5,0xa3,0x33,0xba,0xae,0xa9,0x1e,0x0d,0xdc,0xa7,0x2c,0xeb,0xe4,0xc4, + 0x0c,0xe9,0x60,0x2b,0x24,0x3e,0x5a,0x6e,0x7b,0x27,0xe7,0x4f,0xc8,0xfc,0xca,0x3d, + 0x54,0xf5,0xe7,0x32,0xa7,0xda,0xf9,0x3a,0x4e,0xb5,0x53,0x70,0x2a,0xee,0x51,0xd9, + 0x50,0x8d,0x0b,0xf9,0x71,0xc8,0x22,0xa9,0x10,0x34,0xa3,0xc4,0x87,0x9d,0x64,0x3e, + 0x45,0x62,0x98,0xfe,0x91,0x65,0x36,0xd7,0x6b,0x96,0x19,0x57,0xa1,0xe1,0xcf,0xd1, + 0x9c,0x65,0xe9,0x48,0x8e,0x83,0x39,0xde,0x18,0x69,0xaa,0x9f,0x01,0x3f,0x19,0xe3, + 0xbe,0x8c,0x67,0x43,0xa0,0x7e,0x13,0xa4,0x8c,0x24,0x3f,0x6c,0x83,0x6a,0x73,0x0d, + 0x4c,0x2f,0x17,0x23,0xb4,0xb7,0x21,0x2a,0x7e,0x64,0xa4,0x04,0x7a,0x4b,0xec,0x54, + 0x12,0x62,0xa0,0xab,0x85,0xb2,0x9d,0x45,0xb1,0x3f,0x82,0xee,0x12,0x1c,0xea,0x84, + 0x99,0x2b,0x56,0x02,0x86,0x0a,0xa4,0xd0,0x0f,0x02,0x28,0x37,0xf7,0xb3,0x49,0xb1, + 0xdb,0x11,0x30,0xeb,0x6f,0x8c,0xb7,0x27,0xe7,0x27,0x92,0x13,0x8c,0xa0,0xfa,0x93, + 0x21,0xe9,0xc3,0x8f,0xb2,0x00,0x56,0x87,0x6a,0xb1,0x63,0x73,0x94,0x5c,0x7a,0x04, + 0xf2,0x14,0xfe,0xf6,0x01,0x04,0x10,0x2f,0x05,0x86,0xe3,0x27,0x68,0x1e,0xa4,0x09, + 0x58,0x9a,0x67,0x5f,0xd9,0xc8,0x6d,0x7e,0x7a,0xfd,0x01,0x79,0x8b,0xad,0xfd,0x8a, + 0xc0,0x44,0x1e,0x82,0xb0,0xd4,0xd0,0xc4,0xf5,0x04,0xca,0xf1,0x71,0x1e,0xc2,0x2a, + 0xd3,0x82,0x8e,0xf0,0x54,0xed,0xe9,0xa8,0x86,0x0d,0x94,0x20,0xb4,0xbf,0xbb,0xb6, + 0xb2,0xf2,0x34,0x41,0xfd,0x63,0x2c,0xf0,0x49,0xa2,0xd2,0xfe,0x6e,0x0b,0x61,0x0f, + 0x7c,0xe2,0x56,0x99,0x82,0x16,0x09,0xe5,0x1d,0x5b,0x76,0x42,0x5c,0x7a,0xc4,0x1c, + 0x44,0xca,0x51,0x30,0x73,0x19,0x58,0x88,0x6e,0xda,0x5f,0xff,0x76,0x9e,0xa2,0x72, + 0x88,0xb8,0xee,0x33,0x73,0x02,0xfe,0x9e,0xc2,0x6a,0x21,0xb8,0x85,0xca,0x18,0xb0, + 0x34,0x87,0x43,0xf1,0xa7,0x85,0xa6,0xf9,0x54,0x10,0x93,0xd7,0xeb,0x6a,0xe8,0x52, + 0x91,0x0a,0x78,0xc5,0xbb,0xa7,0x6e,0xe4,0x1a,0xa8,0x40,0x68,0x00,0x25,0xb8,0x11, + 0x00,0xb8,0x0f,0x69,0x72,0x20,0xae,0x28,0x9a,0x34,0x80,0x2d,0x8d,0x80,0x08,0x49, + 0xe0,0x34,0x4e,0x18,0x09,0x1d,0xeb,0xd0,0x35,0xda,0x26,0x95,0xd7,0x20,0xca,0xd2, + 0x7f,0x0d,0xab,0xe8,0x7e,0x3d,0xab,0x28,0x37,0xb4,0x6b,0xa2,0x80,0x82,0x46,0xec, + 0xa7,0xf0,0x84,0xdd,0xa7,0xf2,0x84,0x9c,0x41,0x51,0x57,0x2d,0x0a,0xe3,0xd2,0x99, + 0x51,0xec,0x2a,0x2a,0x0d,0x7d,0xd5,0x36,0x9c,0x69,0xdc,0xd7,0xd0,0xc1,0x69,0x19, + 0x64,0xb9,0x99,0x75,0x40,0xcb,0xa2,0xec,0x99,0x73,0x83,0x2b,0x9c,0x78,0xc3,0x08, + 0x96,0xa4,0xa0,0xa8,0x80,0x30,0x91,0x3c,0xeb,0x06,0xc5,0x37,0x11,0x04,0xf8,0x3c, + 0x2a,0xce,0xca,0x63,0xa0,0xea,0x9a,0x73,0x03,0xa8,0x88,0x0e,0x80,0x2c,0x92,0x59, + 0x20,0x46,0x87,0x48,0x88,0x50,0xdd,0x99,0x7b,0xc3,0x99,0x0f,0x50,0x45,0x8a,0xd4, + 0x47,0xf2,0x8e,0x1f,0xae,0xe4,0x07,0x7c,0x4b,0x72,0x9d,0xe7,0x21,0x17,0x01,0x29, + 0xce,0x2d,0x5b,0xb5,0x9e,0x8e,0x09,0x65,0x50,0xe2,0x61,0x48,0x13,0xb3,0xff,0x73, + 0x82,0x04,0x02,0x5f,0x6d,0x95,0x0c,0x8e,0x00,0x45,0xcf,0x30,0x09,0x9a,0x9e,0x58, + 0x24,0x86,0xe8,0x12,0xd4,0x59,0x65,0x34,0x85,0x3e,0xca,0x16,0x53,0x05,0xcf,0xe2, + 0x12,0x2b,0x29,0x9d,0x48,0x2a,0xa7,0x6e,0x78,0x22,0x0b,0xf5,0x9f,0xa2,0x39,0x34, + 0xc9,0xf1,0xd8,0x48,0x83,0x42,0x41,0x22,0xf4,0x6a,0x93,0xed,0xfb,0x93,0xb7,0x1f, + 0xb4,0x93,0xd3,0xd3,0x47,0xcd,0xb5,0x4e,0x1c,0xaf,0xb6,0xd5,0x62,0x20,0x05,0x83, + 0x85,0x9e,0xca,0x36,0x07,0xa2,0x89,0xc0,0x35,0x48,0xb6,0x2b,0x59,0x39,0x6b,0x96, + 0x86,0x7a,0x25,0x24,0xa4,0x20,0x62,0xd7,0x45,0xbe,0x4a,0x39,0x34,0x20,0xe9,0x47, + 0x6c,0xb5,0x5c,0x55,0x0e,0x85,0x9e,0x14,0x37,0x2e,0xfc,0x79,0xc4,0xc6,0x91,0xb1, + 0x34,0xba,0x8e,0xc0,0xf8,0x81,0xc4,0x93,0xc7,0x04,0xc4,0x0f,0x8a,0x01,0xe9,0xa9, + 0x76,0xa3,0xa5,0xbc,0x8a,0xac,0x95,0xac,0x0b,0xe0,0xc1,0xdb,0x32,0x13,0x53,0x31, + 0x8a,0x77,0x40,0x23,0xb2,0x99,0xdb,0x3c,0x92,0xaa,0xbe,0x0f,0xd4,0x02,0x91,0xce, + 0x83,0x96,0x9d,0xf0,0xae,0x32,0x8c,0xa5,0x3d,0x44,0xe1,0xd5,0x13,0xba,0x88,0xc2, + 0xe5,0x5d,0xfc,0x2b,0xb5,0xe2,0x3a,0x91,0x85,0x7d,0x9a,0xe6,0xba,0x2b,0x6a,0xad, + 0x8a,0x60,0x8b,0x67,0xef,0x7c,0xba,0xc4,0x47,0x51,0x20,0xa0,0x4c,0xa1,0x48,0x5a, + 0xd1,0x5c,0x35,0xd5,0x22,0x99,0x29,0x64,0x58,0xda,0x16,0x0a,0xbd,0x79,0x95,0xde, + 0xf8,0xbf,0x4b,0xf9,0xfd,0xbf,0xac,0xc5,0xd6,0x30,0xe2,0x5d,0xf4,0xc9,0x11,0x26, + 0xc0,0xd2,0x29,0x2b,0x9e,0xc1,0x6b,0x40,0xd2,0x95,0x83,0x56,0xd6,0x46,0xd1,0x69, + 0x54,0x3f,0x62,0x46,0x41,0x76,0x8c,0x38,0x0e,0xee,0xc4,0xd1,0xeb,0x6a,0x84,0x7a, + 0x93,0x78,0x7f,0xcc,0xbc,0x70,0x74,0x67,0x69,0x43,0xc0,0x15,0x11,0x51,0x7e,0xf6, + 0x86,0x70,0xeb,0xd5,0x27,0x6d,0x3a,0x03,0x0d,0x01,0x34,0xad,0xd1,0x84,0x85,0x5e, + 0xb2,0x36,0x7a,0xb7,0x0e,0x39,0xab,0xe5,0xd2,0x9f,0x36,0x4f,0x60,0x43,0x69,0x37, + 0x4e,0x30,0xf3,0xb4,0xcc,0xb9,0x26,0x59,0x37,0x97,0xf4,0xc6,0x63,0x42,0x2f,0xd0, + 0x33,0xb4,0x19,0x60,0x6a,0xa0,0xf8,0xd0,0xa1,0xf8,0xe7,0x3b,0x81,0xbd,0x92,0x76, + 0x3c,0xb6,0x99,0xf3,0x39,0x68,0xc6,0xfb,0x9f,0xbf,0x98,0x8f,0x6f,0xe9,0xe4,0x0a, + 0xaa,0x43,0x25,0xb9,0xa9,0xdb,0x76,0xbb,0xdd,0x11,0x56,0xc3,0xce,0x5e,0x5b,0x98, + 0x0d,0xbb,0x7b,0xed,0xf6,0x9a,0xe4,0xe4,0x47,0x09,0x3a,0xcd,0xb8,0x5e,0x7b,0x04, + 0xc3,0xb9,0xd2,0xbf,0xec,0xfe,0x85,0xe8,0xbc,0xde,0xf7,0x9f,0x02,0xd1,0x59,0x9c, + 0x78,0x0e,0xfa,0x69,0x08,0xe3,0x58,0x0d,0xf7,0x0b,0xf3,0x6f,0x3e,0xbe,0x14,0xab, + 0x47,0x24,0xe9,0x1f,0xed,0x1d,0x6e,0x8b,0x27,0xf9,0x66,0xbf,0xf6,0xe6,0x45,0xed, + 0xcd,0x41,0xed,0xcd,0xcb,0xda,0x9b,0x4e,0xbb,0xfe,0xaa,0x53,0x7f,0xd5,0x2d,0x5e, + 0x49,0xbb,0xf3,0x5a,0x2b,0xf3,0x2a,0xa2,0x59,0x83,0xd4,0xe9,0xad,0x33,0xe7,0x51, + 0xf2,0x0d,0xe7,0xdc,0x38,0xd0,0xaf,0xb6,0x7e,0x7f,0x43,0xa3,0xf7,0x57,0x90,0x24, + 0x29,0xbe,0x2d,0xa3,0x26,0xab,0x90,0x50,0xe1,0xfd,0xdd,0x7c,0x92,0x27,0x7e,0x92, + 0xf9,0x53,0xaf,0x82,0x8f,0x2b,0x27,0xe9,0x8c,0x8b,0x0d,0x23,0xf7,0x4b,0xbb,0x7c, + 0x06,0xd9,0x78,0x02,0xe1,0x8c,0x5b,0xee,0xa8,0xf1,0xf4,0x61,0xc5,0x48,0x77,0x8b, + 0x53,0x93,0xdf,0x34,0xd7,0x0b,0x9c,0x3b,0x20,0x90,0xe9,0x7a,0xf2,0x42,0x72,0x4b, + 0x15,0x9a,0x46,0x2b,0x48,0xcb,0xda,0x9b,0xbb,0x34,0xa6,0xbd,0x4e,0x57,0x45,0x12, + 0x1e,0xd5,0x13,0x00,0x98,0x3d,0x3a,0xae,0xb5,0x70,0xa6,0xe2,0x26,0x50,0x12,0xee, + 0x0e,0xa0,0x24,0xb3,0x9d,0xe1,0xd1,0xab,0x93,0x9f,0x34,0x83,0xce,0x31,0x42,0x8d, + 0x03,0xe3,0x34,0xb2,0x59,0x4d,0xfd,0xcc,0x44,0x9d,0xed,0xd0,0x3f,0x7a,0x35,0x71, + 0x42,0x74,0xb9,0x85,0x39,0xf8,0x37,0x7e,0x76,0x07,0x73,0xca,0x58,0xae,0x67,0x5f, + 0x01,0x76,0x1e,0x2a,0xb6,0xab,0xc2,0x0c,0xb3,0xab,0x40,0x2f,0x4f,0x97,0xce,0x60, + 0x86,0xd1,0x6d,0x31,0xe1,0x91,0x83,0x0c,0x78,0x06,0x0d,0xcd,0x8e,0x72,0x76,0xb8, + 0xd6,0x3c,0x3a,0xfb,0xca,0x44,0x00,0x03,0x80,0xb3,0xe2,0x79,0x8c,0x76,0xe5,0x90, + 0xdb,0x0a,0x8e,0xfd,0xec,0xb7,0x4e,0x77,0xff,0x16,0x44,0xae,0x91,0xe7,0xa3,0x89, + 0x82,0xec,0x90,0xdf,0x64,0xd8,0xc4,0xd4,0xed,0xe4,0x16,0x3b,0xfb,0xca,0xf1,0x77, + 0xdb,0xbb,0xea,0x52,0xbc,0x79,0xfd,0x5e,0x93,0xb3,0x10,0xc3,0x7f,0x7d,0x0b,0xc2, + 0x1c,0xda,0x70,0xc6,0x09,0xaa,0x6a,0x5e,0xe8,0xb6,0xa6,0x91,0x3b,0x03,0xfd,0xfb, + 0xdd,0x87,0x93,0x6f,0x38,0x8d,0xb1,0x37,0xfd,0x73,0x53,0xd9,0xe9,0xaa,0x4b,0x41, + 0xd1,0x2e,0x62,0x0a,0x6f,0xa2,0x64,0x8e,0xb2,0x35,0x09,0x25,0x80,0x5a,0xe3,0xb1, + 0x3f,0xb2,0xb5,0x8f,0x20,0x6f,0x0c,0xf8,0xfc,0x2c,0x6c,0x91,0x6d,0xd8,0x48,0x41, + 0xea,0x08,0xa4,0xd5,0x39,0x25,0xd9,0x46,0x1a,0x99,0x53,0x74,0xc0,0xfa,0x16,0x93, + 0xa5,0x81,0xad,0x3d,0x45,0xd5,0x83,0x43,0x51,0xb8,0x51,0xdf,0x56,0xa6,0xbe,0xbf, + 0xfb,0xb8,0xae,0x44,0x47,0x8e,0x8e,0x8b,0xb1,0x87,0x9a,0x31,0x55,0xcc,0xf9,0xab, + 0x69,0x29,0x55,0xb0,0xc9,0x7f,0x1a,0xc4,0xb6,0x2a,0x1d,0xd8,0x7d,0x84,0xae,0x1e, + 0xb5,0x01,0xc4,0x20,0xd8,0xad,0x41,0x53,0x73,0x29,0x2d,0x88,0x22,0x37,0x1f,0xe8, + 0x24,0x49,0xd7,0x1b,0xe8,0x18,0xab,0xd9,0xab,0x87,0xdb,0xd9,0x3f,0x58,0x6f,0xb8, + 0x96,0xe6,0x05,0xa9,0xa7,0xed,0x6c,0x84,0x24,0xb8,0x42,0xbd,0x26,0xab,0xe3,0x7a, + 0xdc,0x51,0x11,0xbf,0x69,0x8c,0x75,0xc5,0x8e,0x66,0x8c,0xd2,0x07,0xdb,0x71,0xe0, + 0x57,0x9c,0xae,0x96,0xc6,0x7f,0x8e,0x62,0x40,0xdc,0x29,0x1a,0xb1,0xd0,0x6c,0x4a, + 0xed,0x4a,0xc4,0x56,0x24,0xea,0x31,0xe3,0x7d,0x0a,0xaa,0x9c,0x38,0x95,0x06,0xb9, + 0x3b,0xa5,0x24,0x51,0x0e,0x05,0x96,0xf8,0xe9,0x9d,0x16,0xa2,0x03,0xed,0x10,0x64, + 0xf6,0xb4,0x0f,0x2a,0x41,0xa4,0x61,0x98,0x09,0x8b,0xe6,0xb1,0x93,0x64,0x18,0xb9, + 0x52,0xf8,0x6c,0x44,0x00,0x7e,0xf8,0x4d,0xee,0x4b,0xf6,0xda,0x7a,0x7c,0xf7,0xe0, + 0x51,0xec,0x04,0x05,0x52,0xcc,0x62,0x42,0x93,0x5f,0x7b,0xc1,0x61,0x5d,0x2b,0x8b, + 0x5c,0xda,0x0b,0x8d,0x6b,0x2c,0x56,0x75,0x7f,0xd7,0xd6,0x84,0x33,0x91,0x06,0xcf, + 0x4f,0x40,0x51,0x1c,0xad,0x40,0xd0,0xa7,0x0f,0x57,0xe0,0xe8,0xb7,0x18,0xf5,0x81, + 0xbd,0x1a,0x2d,0x57,0x88,0x08,0xb4,0x28,0xca,0x74,0x66,0x61,0x3a,0x8a,0x62,0x3c, + 0x0e,0xfb,0xca,0x55,0xb0,0x65,0x0b,0xf5,0x89,0x7d,0x8d,0x53,0xc5,0x09,0x68,0xa6, + 0x78,0xda,0x07,0x4a,0x2a,0x9f,0xea,0xa5,0x7c,0x00,0x17,0x46,0xf2,0x1c,0x8d,0x7a, + 0xb3,0x35,0x05,0x2e,0x8f,0x1e,0x85,0x3c,0x4e,0x48,0xbb,0x7b,0xfb,0x0a,0xc5,0x84, + 0x5d,0xa6,0xc8,0x19,0x4b,0xdc,0x60,0x0a,0xe3,0x4f,0x14,0xdb,0x5c,0x3c,0xd7,0x0d, + 0x78,0xc7,0x89,0x73,0x92,0x8f,0x48,0x06,0xcb,0x5a,0x80,0xfc,0x8e,0xde,0x04,0x53, + 0x20,0x57,0x47,0xef,0xf9,0x61,0x69,0x39,0xd8,0xd5,0xa8,0xa2,0x40,0x41,0xf1,0xb4, + 0xac,0x64,0x9a,0x25,0x3e,0x0e,0xe4,0x8c,0xfe,0xd6,0x75,0x8d,0x15,0xa0,0xff,0x29, + 0xc1,0xbc,0x22,0x8c,0x07,0x12,0xf6,0xec,0x71,0x12,0xa0,0x72,0x78,0xa7,0x01,0x01, + 0x4b,0xee,0x4a,0x2e,0xcd,0x13,0x07,0xed,0x45,0xdc,0x17,0xd0,0x99,0x81,0x36,0xf6, + 0x90,0xde,0x30,0xaf,0xc3,0x45,0x0c,0x68,0xb0,0xae,0x14,0xef,0x5c,0xe8,0x22,0x06, + 0x8a,0xb7,0xc2,0xd1,0xab,0xd1,0x8a,0x49,0x26,0xd4,0xa7,0x1e,0x81,0xbf,0xa5,0xb3, + 0xe3,0xec,0xee,0xcf,0x1e,0x7b,0xff,0xaf,0x39,0xed,0x96,0xe7,0xdb,0x7d,0x0c,0xb6, + 0xa9,0x9c,0x6f,0xdb,0xda,0x8f,0x64,0xaf,0x1b,0x7c,0xcb,0x63,0xea,0xff,0xf1,0xd3, + 0xe9,0x3f,0x71,0x82,0xfc,0xaf,0x3c,0x38,0x5e,0xb5,0xaa,0xcb,0x0f,0x85,0x91,0x9b, + 0x52,0xaf,0x9b,0x69,0xe5,0x6c,0x18,0x50,0x4e,0x1c,0x98,0x9a,0x7f,0xea,0x5c,0x77, + 0xcd,0xe3,0xdc,0xaf,0xd1,0xef,0x4f,0x19,0x0b,0x61,0xef,0xae,0x96,0x50,0xce,0x95, + 0x03,0x5c,0x90,0xac,0x3d,0x44,0x6b,0x10,0xcb,0x67,0x71,0x10,0x39,0x6e,0x2a,0x4f, + 0xe2,0xc8,0xf9,0xb3,0x85,0xbe,0x18,0x13,0xb2,0x39,0x25,0xd1,0x54,0x73,0xd0,0x0f, + 0xfd,0x1a,0x77,0x01,0x1a,0xb0,0x7f,0x07,0x60,0xd3,0x49,0x31,0x94,0x56,0x7c,0xaa, + 0x96,0x2b,0x04,0x85,0x0a,0x40,0x9e,0xae,0xec,0x19,0x22,0xf4,0x80,0x53,0x2f,0xf1, + 0x41,0xd2,0x1a,0xa1,0x4f,0x65,0x90,0x4d,0xb6,0xe9,0x04,0x45,0x13,0x2d,0x0b,0x1f, + 0x54,0x72,0x5f,0xf8,0x26,0x92,0x3e,0x41,0x9a,0x5b,0x7d,0x8a,0x46,0x53,0x4c,0x40, + 0xc0,0x5a,0x12,0x61,0x31,0x89,0xf7,0x0e,0xf9,0x9d,0xa5,0xc0,0x0e,0x47,0x13,0xf6, + 0x14,0x06,0x9d,0x45,0xb8,0xbe,0x48,0x19,0x10,0xb0,0x20,0x9a,0x7f,0xbb,0x59,0x88, + 0x11,0x7c,0xdd,0x34,0x4e,0x5c,0x57,0x4b,0x9c,0xb9,0xc6,0x61,0x7f,0x62,0x16,0x27, + 0x01,0x39,0x3c,0xa0,0x3b,0x1d,0x7a,0xe2,0xcd,0x60,0x0a,0xb8,0x53,0xa8,0x8c,0x5c, + 0x11,0xac,0xf4,0x8d,0x97,0x03,0x9a,0xfc,0xba,0x49,0x7c,0x62,0xed,0xdd,0xad,0x2c, + 0x06,0xa8,0x99,0xe8,0xa3,0x21,0xd9,0x24,0xfa,0xea,0xba,0xc5,0x31,0x8a,0xe3,0x27, + 0xdf,0x70,0xe8,0xb7,0x7f,0x0e,0x8b,0x84,0x9c,0x9f,0xd4,0x36,0x83,0xfc,0x00,0x52, + 0x3f,0x9e,0xaa,0xa3,0xd2,0x4b,0xb2,0x55,0xaa,0x19,0xa7,0x67,0x9f,0x4e,0xde,0x6b, + 0x14,0xe2,0xce,0x66,0x0b,0xf3,0xdb,0xcd,0x27,0x1f,0xcf,0xb7,0xd0,0x85,0x1f,0x93, + 0xd8,0xcf,0x85,0x3d,0x29,0x53,0x57,0x70,0xb5,0x99,0x57,0x8e,0x33,0xbb,0x7d,0xb2, + 0x38,0x87,0x07,0xc2,0x27,0x80,0xd1,0xe7,0xbf,0x2d,0x2b,0x21,0x74,0x00,0x24,0xde, + 0x42,0x93,0x90,0x66,0xa1,0xa5,0x82,0x5a,0x13,0x93,0xa9,0xa0,0x5f,0xa1,0xf9,0xe1, + 0xce,0x4a,0x9f,0xa2,0xd2,0x9c,0x31,0xf5,0x93,0x8a,0xf3,0x13,0x2c,0x04,0x2c,0x01, + 0x94,0x15,0xee,0x8e,0x14,0xfe,0x9f,0x74,0x38,0xf1,0x95,0x6b,0xfb,0x41,0x22,0x92, + 0x32,0xfa,0x09,0xea,0xb3,0x4f,0x18,0x7f,0x8e,0x8c,0xb5,0x99,0x74,0xa5,0x9d,0x7c, + 0x67,0xff,0x11,0x0d,0x0d,0xe5,0xb8,0x68,0x9c,0x51,0x8a,0x07,0x29,0xad,0xb1,0x7b, + 0x4b,0x79,0x83,0x19,0x9d,0x6e,0x0b,0x5a,0xb3,0x34,0x91,0xad,0x4a,0xeb,0xee,0x9a, + 0xf6,0xd7,0xdb,0x17,0xd6,0x75,0x82,0x72,0xe2,0x78,0x7d,0x2f,0xa8,0x5a,0xcb,0xe7, + 0x68,0x83,0x67,0xfb,0xc4,0xd9,0x87,0xf7,0xa7,0x6b,0x78,0x08,0x9c,0x9f,0x8a,0xb8, + 0xa6,0x75,0x25,0xb1,0x30,0x8b,0x97,0x08,0x5e,0xe4,0xb3,0x3d,0x89,0x02,0x50,0x79, + 0x30,0xc8,0x2d,0x0a,0xb0,0x2c,0xc8,0xdb,0x57,0x2b,0x25,0x30,0x8e,0xbc,0x04,0x0a, + 0x88,0xb1,0xb7,0x52,0xe2,0x18,0x05,0x40,0xa2,0x1f,0x13,0xae,0xd6,0x39,0x29,0x43, + 0x80,0x50,0x32,0x0d,0xe3,0xf4,0xe3,0xd9,0xdb,0xdf,0xd6,0xd1,0x12,0x32,0x51,0x65, + 0x9d,0x59,0xbe,0x3f,0x3b,0x7f,0xf1,0xfe,0xa7,0x73,0xeb,0xfd,0x8e,0xdd,0xb5,0xdb, + 0xd6,0xfb,0x4e,0xc7,0xee,0xd8,0x8f,0x1d,0x33,0x36,0x28,0xda,0x1d,0x96,0x9f,0x79, + 0x6c,0xbf,0x9e,0xbf,0xa2,0x44,0x24,0x45,0x50,0xc7,0xea,0x63,0x00,0x31,0x5e,0x9b, + 0xeb,0xc8,0x93,0xa3,0x7c,0x4b,0x74,0x76,0xd7,0xdd,0xdd,0x25,0x86,0x84,0xf8,0xa3, + 0x81,0xfe,0x12,0x66,0x39,0x13,0x05,0x29,0x09,0xc8,0x18,0x1e,0xcf,0xfa,0x09,0x9e, + 0x1d,0x7d,0x03,0x1e,0x93,0x86,0xd3,0x78,0x6d,0xce,0xa2,0xd0,0x41,0x1c,0x1c,0x3a, + 0x44,0xcc,0x42,0x52,0x19,0x1f,0x5d,0x54,0xec,0xc7,0xce,0x2b,0x2c,0xd1,0xfe,0xd6, + 0x56,0x70,0xc9,0xf7,0xe7,0xa9,0x0a,0x2e,0x05,0x19,0xca,0x50,0xec,0x28,0x5c,0x2d, + 0x98,0xe3,0x49,0xca,0x15,0x0a,0xdb,0x54,0x2b,0x61,0xd0,0xb3,0x1b,0x5e,0xd1,0x44, + 0x7e,0x62,0x4f,0x1c,0x85,0xdc,0xee,0xe6,0x68,0x19,0x47,0xbd,0x9d,0x63,0x20,0x8a, + 0xa8,0x07,0xa0,0x2b,0xb0,0xa5,0x52,0x19,0xdb,0x15,0x7a,0xf3,0x22,0x64,0xed,0x8d, + 0x8f,0x41,0x5e,0xd0,0x3c,0xbe,0x7d,0x7b,0xca,0x02,0x3e,0xf9,0x07,0x24,0xd1,0x0c, + 0x77,0x67,0xc4,0x82,0x12,0x9f,0xee,0xe3,0x00,0xd2,0x28,0xf0,0xd6,0x73,0x1c,0xf9, + 0x9f,0x8a,0x84,0x24,0x3a,0xfa,0x7f,0x21,0x14,0x52,0x0e,0xf4,0x5f,0x10,0x0b,0xf9, + 0x67,0x42,0x20,0xd7,0xb4,0x4f,0xb6,0xbb,0xc5,0xc9,0xea,0x29,0x59,0xaf,0xd9,0x09, + 0xf5,0x11,0x2b,0x1c,0x77,0x8d,0xe5,0xc9,0x6d,0xb2,0xc1,0xd0,0x26,0x8c,0x6c,0xb3, + 0xe9,0x32,0xd9,0x8c,0x52,0x35,0x80,0xf6,0x88,0xb4,0x5d,0xb0,0x68,0x34,0xca,0xcf, + 0xd1,0x07,0x1e,0x5d,0x4e,0xc3,0x11,0x8a,0xc1,0x4b,0x0c,0x74,0xce,0x6d,0x11,0x45, + 0x63,0x0c,0x1d,0x4c,0x34,0xa4,0x96,0x6e,0x38,0xea,0x5f,0x97,0x03,0xbf,0x77,0x50, + 0x34,0x09,0xf1,0x74,0x7d,0xcd,0xc3,0xf5,0x25,0xd8,0xb5,0xcc,0x07,0xd9,0x8d,0x3e, + 0x91,0x63,0x90,0x41,0x6e,0xc8,0xe4,0x22,0x14,0x92,0x11,0xa8,0x86,0x36,0xeb,0x35, + 0x2c,0xe2,0xf1,0x61,0xa3,0xb7,0xca,0x41,0x9a,0x6e,0xf4,0x8e,0x5e,0x93,0xff,0x2a, + 0xc6,0x58,0xc1,0x73,0xcd,0x3d,0x76,0x5d,0x8a,0xc9,0x5e,0x90,0x4f,0x25,0x99,0x3f, + 0xd1,0xc5,0x18,0x82,0xab,0x70,0xe0,0x39,0x34,0xd3,0x02,0x42,0xa7,0x4c,0x23,0x72, + 0x32,0x91,0x53,0x45,0x4d,0xba,0xb2,0xd7,0xae,0xa5,0x96,0xa2,0xe4,0x3e,0x95,0xac, + 0x52,0x6d,0xfd,0xe8,0x11,0xc7,0x2a,0xca,0xdc,0x27,0x5c,0x4d,0xe9,0xf1,0x6b,0xb0, + 0xe2,0x4d,0xe2,0x79,0xa8,0x76,0xc6,0x9a,0xf1,0xcb,0x8f,0x26,0x75,0x75,0xc8,0x99, + 0xe8,0x78,0x5a,0xb1,0x93,0x5c,0x63,0x56,0xad,0xb8,0xc8,0x94,0x42,0x26,0xee,0x6d, + 0x2e,0x54,0xf6,0xfc,0xf4,0x53,0x8f,0xec,0xbe,0xb9,0x47,0x49,0x63,0x6b,0x78,0x6c, + 0xe4,0xad,0x6a,0xee,0xd1,0xd3,0x30,0x14,0xf7,0x78,0xe1,0xea,0x87,0x61,0x6a,0x40, + 0x7c,0xa3,0x1c,0x8b,0xf5,0x84,0x20,0xbb,0x76,0x2c,0x20,0x6e,0x67,0x22,0x11,0xda, + 0x9d,0x97,0xd9,0x2b,0x70,0x4b,0xf1,0x32,0x3e,0xdc,0x56,0x13,0x90,0xa4,0xe4,0x0a, + 0xe9,0x24,0xec,0x53,0xac,0x8a,0x36,0x9c,0xfb,0x93,0x27,0x26,0x7f,0x50,0x8b,0xc3, + 0xfc,0x55,0x8b,0xd2,0x20,0x0c,0x8f,0x4a,0x42,0x4c,0x1a,0xeb,0x8a,0x94,0xd3,0xbc, + 0xa7,0xb4,0x74,0xaa,0xec,0x1b,0x24,0xfa,0x49,0x06,0x8a,0x25,0xef,0x50,0xfc,0x51, + 0xf2,0xfb,0x6d,0x68,0x62,0x5a,0x8c,0xab,0xb2,0x09,0x81,0x3b,0x94,0x7c,0xc8,0x39, + 0x80,0x57,0x71,0x24,0x96,0xbb,0x8d,0xa6,0xef,0x57,0x73,0xe9,0xf5,0xb4,0x69,0x84, + 0xe6,0x10,0xce,0x43,0x37,0x9f,0xf8,0x20,0x81,0xa1,0xd9,0x43,0x49,0x45,0x47,0xde, + 0x76,0xe4,0x0c,0xe1,0x67,0x75,0xb0,0xe5,0x59,0xd4,0x0a,0x1f,0x45,0x4a,0xa7,0x46, + 0x2f,0x6b,0x5e,0xd9,0x22,0x35,0x1c,0x07,0x23,0x0d,0xef,0xf2,0xe0,0x77,0x86,0x68, + 0x85,0xc3,0xaa,0xb2,0xe3,0x0a,0xb8,0xfe,0x19,0x2e,0x4b,0x52,0x13,0xb1,0xd9,0x2c, + 0x99,0x79,0xb4,0x1c,0x69,0x8d,0xc5,0xfe,0x8b,0xfa,0xa6,0xdc,0x20,0xd4,0x37,0xf4, + 0xfb,0x0a,0x7f,0xe4,0xdd,0x96,0xf3,0x73,0xe4,0x30,0x45,0x1f,0x07,0x00,0x0b,0xbe, + 0x5b,0xea,0xcd,0x9f,0xa7,0xbb,0x23,0xc9,0xa8,0x82,0xa9,0xe4,0x93,0xa1,0xca,0xd0, + 0xf9,0x76,0x11,0x38,0xa2,0x92,0x35,0xcc,0xa3,0x26,0xc8,0x1a,0x3d,0x1e,0x95,0x30, + 0x49,0x66,0xa2,0x8f,0xa3,0xb8,0x45,0xc7,0xc9,0x9c,0xc8,0xac,0x86,0x20,0xe2,0x3d, + 0x37,0xe4,0xdd,0xfa,0x98,0xc3,0xaa,0x40,0x8b,0x9a,0xa2,0xb3,0x8b,0x07,0x05,0xd5, + 0xfc,0x94,0x4a,0x5e,0xa3,0xb2,0x69,0x41,0x89,0x09,0xae,0xa7,0x5a,0xa3,0x7c,0x1e, + 0x99,0x1f,0xce,0xbc,0x7a,0x66,0x94,0x3c,0x11,0x52,0x14,0x34,0x10,0x1f,0x24,0x3d, + 0x94,0xec,0x4c,0x66,0x1f,0xb3,0x5f,0xe6,0x57,0xd0,0x70,0xaa,0xef,0xea,0x0d,0x26, + 0xe3,0x2b,0xb3,0xa0,0x7e,0x81,0x7f,0x74,0x0e,0x84,0x1c,0x83,0xea,0xa4,0xc6,0x6a, + 0x44,0xa4,0xc1,0xbe,0x42,0xae,0x1f,0xe0,0x1b,0x53,0x86,0x69,0x83,0x8c,0x89,0x76, + 0x7f,0x3a,0x4f,0xf0,0xf1,0x70,0x34,0x04,0xf4,0xb1,0x0f,0x87,0x49,0x55,0x67,0x6a, + 0x8c,0xb3,0x68,0xa0,0x90,0x6f,0xc7,0x9a,0x93,0x5e,0x7b,0xae,0x05,0xa4,0x40,0x86, + 0x92,0xff,0x9a,0x62,0x86,0x9a,0x6c,0x82,0x0b,0xf5,0x16,0x61,0x13,0x7a,0xa4,0xb4, + 0x69,0xdb,0xf8,0xf5,0x2c,0x83,0x75,0x13,0x4a,0x03,0x6a,0x6d,0x43,0x0c,0xfd,0x16, + 0x48,0x02,0x73,0x51,0xa6,0x45,0x69,0x66,0xce,0x9c,0xb1,0x93,0xf8,0x28,0xf2,0xbf, + 0x9a,0x24,0x11,0xe5,0x3b,0xe2,0xb1,0xf3,0x2d,0x55,0x76,0xb5,0xd2,0x5f,0x22,0x54, + 0xd8,0x81,0x8c,0xd6,0x67,0xf0,0x42,0xe6,0x61,0x21,0xc4,0x98,0x25,0x40,0x30,0x26, + 0x59,0x16,0xf7,0xb6,0xb7,0x3b,0x2f,0xbb,0x76,0x67,0xff,0xc0,0xde,0xb5,0x3b,0x44, + 0x71,0xf3,0x36,0x41,0x06,0x0b,0x96,0x84,0x24,0x35,0xd8,0x9d,0x30,0x39,0xba,0x5e, + 0x99,0xa0,0x34,0x4b,0xc3,0xcc,0x65,0x66,0xac,0xd6,0x19,0x46,0xd2,0x13,0x44,0xd4, + 0x4c,0x2f,0xb6,0xf6,0x5f,0x6a,0xda,0x1c,0x76,0xc2,0xf7,0x53,0x0c,0xd7,0xc7,0xb3, + 0x53,0x5f,0x3a,0x35,0x17,0x5a,0x96,0xc7,0x31,0xf9,0x85,0x3e,0x26,0x56,0x59,0x20, + 0x9e,0x85,0x29,0x47,0xb1,0x0d,0x0c,0x0b,0x4c,0x6e,0xfc,0x1b,0x11,0xc1,0x2e,0x52, + 0x8a,0x62,0x6a,0xbd,0xe2,0x34,0xb5,0x92,0xb6,0x6a,0x75,0xe0,0x4e,0x7b,0x69,0xe0, + 0x8e,0x1b,0x8d,0x66,0x53,0x24,0x45,0x57,0x5e,0xf6,0x3a,0xf0,0xf0,0xf1,0xc7,0xbb, + 0xb7,0xae,0xb1,0x29,0x36,0xe3,0xa6,0x69,0x13,0x10,0xdf,0x01,0x71,0xb1,0x31,0x9e, + 0x00,0x98,0xc9,0x26,0xa6,0x23,0xdc,0x5c,0x33,0xd2,0x07,0xd6,0x77,0x48,0x81,0x51, + 0x22,0xc2,0x47,0x61,0xc2,0x0a,0xbd,0x10,0xce,0xea,0x6b,0x91,0x09,0x2e,0xab,0x12, + 0x0a,0x01,0x8c,0x1a,0x35,0xcb,0x73,0x22,0x11,0xf1,0xd8,0xd9,0x2d,0xb2,0x24,0xee, + 0xec,0xf2,0x81,0xa3,0xc2,0x42,0xaa,0xe4,0x83,0xc2,0x9d,0x34,0x4a,0xe6,0x59,0xea, + 0x57,0x44,0xd2,0xb1,0xec,0x5c,0xca,0x89,0x96,0x93,0x8d,0x58,0x2d,0x3f,0x4d,0xaf, + 0xf2,0x81,0x34,0x64,0x2f,0x28,0x91,0x36,0xfd,0xa8,0x48,0xff,0x15,0x2f,0x41,0x58, + 0x25,0x2d,0x62,0x77,0xbf,0x9e,0x10,0xb5,0x34,0xd6,0x51,0x34,0x0b,0x33,0x7d,0x19, + 0xe0,0x01,0xaf,0xfc,0x38,0x3b,0x7a,0xa6,0x63,0x08,0x9d,0x70,0x25,0xe8,0x3f,0x83, + 0xd1,0x69,0x67,0xaf,0x3f,0x9c,0xbf,0xfd,0xf0,0xfa,0xdd,0x40,0xff,0x41,0xfc,0x4f, + 0x7c,0x48,0xb3,0xc1,0x02,0xdd,0x13,0x7a,0xba,0x6e,0x51,0xac,0x89,0xdb,0xa3,0xe5, + 0xb7,0x26,0x4e,0xfa,0x1e,0xe4,0x3d,0xf1,0x4b,0x2a,0x8b,0x67,0x1c,0xdd,0x9a,0x97, + 0x0a,0x3d,0xcf,0x4d,0xa5,0x9a,0x29,0xde,0x01,0xab,0x48,0x7b,0x6d,0x6b,0x34,0xbe, + 0xea,0x85,0xb3,0x20,0xb0,0xf0,0x80,0xbd,0xb7,0x78,0xb0,0xe8,0xca,0x10,0x7c,0xe0, + 0x04,0x25,0x69,0xef,0xe2,0xd2,0x0a,0x49,0x22,0x84,0xd2,0x24,0x56,0xa2,0x71,0x2e, + 0x81,0x1f,0x40,0x5d,0xb2,0xde,0x02,0xc5,0x5f,0x2a,0x83,0x82,0x2b,0x3c,0x3c,0x58, + 0xc8,0x15,0xcf,0x01,0x78,0x1e,0x5e,0x1d,0xc1,0xbf,0xb8,0xc6,0x43,0xff,0xd9,0xb3, + 0xf1,0x2c,0x64,0x23,0xc8,0xf7,0x46,0x6a,0x2e,0x04,0x2a,0xe7,0xbb,0x01,0x64,0x9a, + 0xe4,0xee,0x8c,0xb4,0xb8,0x28,0x81,0x02,0x0f,0x4a,0x79,0xb5,0xc2,0x49,0x92,0x38, + 0x77,0xc0,0xc6,0xa3,0x2c,0x42,0x66,0x6f,0xa7,0x01,0xd2,0xb8,0x91,0x03,0xd2,0x5b, + 0x73,0x5b,0x28,0xd7,0xa5,0xa6,0xda,0x1e,0xb1,0x4f,0x63,0x6a,0x2e,0x10,0xbe,0xd9, + 0xe0,0x7b,0x43,0xff,0x8e,0x39,0xaa,0xd9,0xcf,0x6c,0xdc,0xd6,0xaf,0xc4,0x6d,0x7f, + 0x53,0xf8,0x5d,0xec,0x45,0x60,0x38,0x86,0x8e,0x1b,0x11,0xca,0x91,0xb9,0x13,0xe7, + 0x86,0xea,0x56,0x66,0x7f,0x9e,0x60,0xd5,0xcf,0x93,0x01,0x80,0x4d,0xbe,0x95,0xdd, + 0x19,0xe6,0x22,0xab,0xef,0x68,0xd1,0xd0,0x83,0xd5,0xdd,0x6f,0xb7,0xd5,0xc1,0x81, + 0xbc,0xa3,0xcc,0x16,0x5d,0x3f,0xc2,0x2b,0x78,0x01,0xf5,0xc8,0x8e,0x69,0x6c,0x5f, + 0x6c,0x1c,0x1e,0xe9,0x97,0xdb,0x57,0x56,0xde,0xc1,0x48,0x16,0x5f,0xe8,0x1b,0x7a, + 0x4f,0x27,0x6b,0xb2,0x6e,0xe9,0x87,0xf8,0x0c,0x3a,0x36,0x3c,0x1e,0xe1,0xe3,0x15, + 0x3e,0x6e,0xea,0x9b,0xf0,0xf8,0xc7,0x2c,0x82,0x1f,0x0f,0x17,0xa3,0xcb,0x07,0xe8, + 0xbb,0xe8,0xdc,0x89,0x7d,0x03,0x23,0xcc,0x2d,0xd0,0xab,0x61,0x10,0x80,0xc1,0xf8, + 0x30,0xc0,0xff,0xdc,0xdf,0x2f,0x1e,0x30,0xa3,0xac,0x3f,0x36,0x9e,0xe3,0x6f,0x00, + 0x39,0x88,0xa6,0x66,0xf1,0x88,0x44,0x1f,0x94,0x0f,0xa0,0xde,0x7a,0x5f,0xdb,0xde, + 0xa6,0xa4,0xcd,0xf4,0x21,0x45,0x0b,0x2c,0x6c,0xa7,0x9f,0xcf,0xcf,0x4f,0xb5,0x93, + 0xd3,0xb7,0xda,0x5f,0x5e,0x9f,0xa7,0x9a,0x33,0x4a,0x22,0x20,0xe1,0x29,0xd0,0x71, + 0xe8,0x39,0xe5,0x96,0xa9,0xb5,0x8c,0x01,0xb8,0xb1,0x21,0x78,0xef,0xc9,0x10,0x10, + 0x1a,0x97,0x04,0x13,0x40,0x7b,0x09,0x0d,0x4b,0xc3,0x1e,0xce,0x81,0x52,0x17,0x09, + 0xa6,0x39,0x98,0x1b,0x45,0x6e,0x47,0x1b,0x7b,0x78,0x86,0x2b,0x2d,0x72,0xe8,0x1a, + 0x73,0xc3,0x19,0x7e,0x1c,0x60,0x8a,0x8e,0xab,0x58,0xe4,0xfa,0xb2,0x31,0x07,0x7b, + 0xc1,0x68,0x71,0x12,0x02,0x1c,0xec,0x69,0x13,0xe3,0xb9,0xb2,0xe4,0x8e,0x3c,0x07, + 0x29,0x4c,0x2b,0x45,0xe9,0x1c,0x39,0x02,0xd5,0x42,0xf4,0x19,0x65,0xc1,0x00,0x8d, + 0x70,0x95,0x41,0x1a,0x66,0x9f,0xe6,0x82,0xa9,0x4e,0x9c,0x60,0x00,0xa5,0xc4,0x23, + 0xf7,0xd7,0x8c,0x26,0x58,0x8a,0x86,0x61,0x00,0x5e,0xa8,0xa0,0xa0,0x54,0xbe,0x0f, + 0xf0,0xaf,0xa4,0xff,0x38,0x3d,0x65,0xa1,0x6c,0x0c,0x41,0x2b,0x5a,0x92,0x20,0x02, + 0x80,0x26,0xe2,0xa0,0x7c,0x30,0x18,0xec,0xb6,0x3b,0xe6,0x02,0xb1,0xee,0x1d,0xe6, + 0xf2,0x83,0x01,0x66,0x20,0x2d,0xcc,0xc9,0x82,0xf8,0x3a,0x49,0x60,0xcf,0xe9,0x48, + 0x5e,0x00,0x25,0xa9,0xae,0xe8,0x29,0xb1,0x7f,0x4f,0x71,0x6c,0x95,0x1e,0x7e,0x17, + 0x3d,0x30,0x3a,0x24,0x76,0x74,0xbd,0xb1,0x21,0xbb,0x7a,0x3e,0x18,0x74,0xdb,0xdd, + 0xbc,0x00,0x83,0xc9,0x1b,0x14,0xfd,0xfc,0x8e,0x09,0x68,0xa3,0xe4,0xfe,0xde,0xd0, + 0x09,0x25,0xf4,0x2d,0x59,0xd5,0x34,0xfb,0x79,0x2d,0x4f,0x8e,0x5c,0x7e,0xec,0x7b, + 0xb0,0x07,0xfe,0x00,0x52,0xfb,0x3b,0xff,0x2d,0x8a,0xf2,0x44,0x3c,0xf9,0xe2,0x41, + 0xfc,0x15,0x53,0xf8,0x9d,0xdf,0x3f,0x30,0x10,0xe9,0x5a,0xc9,0x1c,0xdf,0xe3,0x08, + 0x08,0x01,0xc1,0x11,0xf3,0xa1,0xe6,0xbb,0x2e,0xdf,0x06,0x8b,0xa9,0x07,0x42,0x9a, + 0xdb,0xd3,0x4f,0x3f,0x9e,0x9d,0xeb,0x16,0xa7,0xd0,0x4c,0x7b,0x0b,0x5d,0x50,0x88, + 0xd6,0x39,0x90,0x20,0xd8,0x5b,0x18,0xef,0x25,0x52,0x5b,0x6c,0x23,0xc0,0xf4,0x07, + 0x6a,0xb0,0xf7,0xd7,0xb3,0x8f,0x1f,0xec,0x94,0xf6,0xb0,0x3f,0xbe,0x33,0xa8,0x93, + 0x07,0x75,0xb7,0x4f,0xaf,0x3f,0x79,0x7f,0x00,0xf3,0x27,0x70,0x21,0xa0,0x1c,0x02, + 0xd4,0xaf,0x7e,0x98,0x1d,0x10,0xa5,0x33,0x0e,0x4c,0xb1,0xeb,0xc4,0x6e,0x18,0x25, + 0x77,0x71,0x16,0x6d,0x6c,0xf0,0x5f,0x14,0x22,0x3e,0x39,0xf0,0x61,0xfa,0x9f,0xe4, + 0x83,0x6a,0x36,0xbf,0x36,0x1c,0x6a,0x85,0x9c,0x6f,0x61,0x37,0x18,0xd8,0x93,0x3f, + 0x68,0xf7,0xfd,0xc3,0x83,0xbe,0xbf,0xb5,0x65,0x3a,0x17,0xfe,0xe5,0xe0,0x3d,0xcc, + 0xd8,0x26,0x33,0x85,0x41,0x8f,0x09,0x35,0x60,0x98,0x3f,0x74,0xf7,0xf6,0xa9,0xfa, + 0x12,0x0a,0x3c,0x75,0x62,0xa6,0xbf,0x4e,0x41,0x92,0x86,0x62,0xfd,0xb9,0x8a,0x31, + 0x3c,0xec,0xec,0x1f,0xeb,0x6d,0x80,0x94,0x6e,0x6e,0x0d,0xed,0x2c,0x12,0x74,0xad, + 0xb3,0x2f,0x56,0xc5,0xfe,0x3d,0x02,0x9c,0x84,0xaf,0xb8,0x3c,0x2a,0x3d,0x8a,0x83, + 0xbb,0x37,0x1e,0x2c,0x3f,0x70,0x24,0x06,0x12,0x30,0x83,0xcd,0x0b,0x69,0xcf,0xbc, + 0x04,0x61,0x09,0xe6,0xf3,0x1a,0xc8,0x4c,0x81,0x9c,0x5e,0x20,0x3a,0xf7,0x02,0x85, + 0xee,0x66,0xd1,0xd5,0x55,0x00,0x74,0x97,0xd4,0x62,0xeb,0xb9,0x01,0xaf,0x90,0x0f, + 0x6e,0x7c,0xa0,0xc3,0x19,0xa8,0x64,0x63,0xa3,0xb0,0x31,0xf1,0xb5,0x69,0xe6,0xd8, + 0xc2,0xcb,0x42,0x5e,0x7b,0xaf,0x9c,0xc4,0x25,0x6e,0x51,0xf3,0x3a,0x96,0x6b,0x94, + 0x17,0x33,0xf3,0xa7,0x35,0x86,0x60,0x74,0xba,0x07,0xf7,0x08,0x64,0xee,0x15,0x66, + 0xa8,0xdb,0xe5,0x70,0xd5,0x25,0xb3,0x5c,0x35,0xc1,0x34,0xb3,0x6b,0x32,0x81,0xf9, + 0x20,0x07,0x0a,0x5f,0x85,0xfc,0x60,0xf2,0x12,0xf5,0xd1,0xfc,0x07,0x64,0xf0,0x63, + 0xe8,0x69,0x3f,0x9f,0xbf,0x7f,0x07,0x3a,0x0b,0x06,0xfe,0x0b,0x87,0x7d,0x24,0x9f, + 0x77,0xda,0xeb,0xb3,0xd3,0x9d,0xae,0x96,0xe0,0x49,0x02,0xd1,0x5e,0x8c,0xe6,0x9c, + 0x3b,0x09,0x1e,0x56,0x60,0xfb,0x29,0x0a,0xd3,0x94,0x4a,0xcd,0xcf,0xb8,0x31,0x67, + 0x94,0xcd,0x00,0x31,0xee,0xd0,0x8f,0x11,0xf3,0xee,0x91,0x0d,0x6a,0x98,0xf8,0xee, + 0x95,0xd7,0x47,0x6a,0x0c,0x63,0xf3,0x43,0x07,0x5a,0x1e,0xce,0xfc,0xc0,0xe5,0xe8, + 0xd9,0x1b,0x91,0xbb,0x31,0x71,0xa8,0xa9,0x0c,0x88,0x38,0x37,0x86,0xa4,0x0b,0xa9, + 0xf9,0x88,0x89,0xac,0xf0,0x9d,0x24,0x61,0x1e,0x8f,0xe9,0x43,0x17,0x39,0x00,0x66, + 0x9f,0x21,0xa4,0xb1,0xc5,0xc2,0xa1,0x49,0xec,0xdc,0x19,0xc2,0xb2,0x6d,0x7e,0xa7, + 0xdc,0xe5,0x77,0x51,0x0a,0x1e,0x07,0x34,0xea,0x03,0x50,0x44,0x59,0x53,0xfc,0x95, + 0xac,0x9a,0x17,0x05,0x85,0x04,0xf6,0x93,0x34,0xab,0x32,0x01,0x41,0x3c,0x2f,0x85, + 0xd9,0x84,0x1e,0x29,0x42,0xe9,0x39,0x1f,0x29,0x53,0xb5,0xe5,0x3d,0xda,0xab,0x92, + 0x38,0xd3,0x2c,0x49,0x31,0xfa,0x53,0xd2,0x68,0x92,0x17,0xfb,0xd8,0xbf,0x9a,0x71, + 0x9e,0x08,0x3e,0xa3,0x82,0x6a,0x45,0x76,0x88,0x48,0xe8,0xee,0xef,0x4e,0x3e,0xd8, + 0xba,0xd2,0x7d,0x91,0x94,0xb0,0xd2,0x3d,0xa7,0x28,0x84,0x2e,0x39,0xcf,0x5e,0x28, + 0x52,0xf0,0x15,0x6e,0x67,0x6a,0x2b,0x4a,0x76,0xa7,0x4a,0x33,0x9c,0x38,0xa3,0x54, + 0x56,0x4d,0xb4,0x61,0xda,0x3e,0xcc,0x32,0x41,0xdc,0x85,0x2e,0x2b,0xd9,0x99,0x6a, + 0x49,0x37,0x1a,0x9a,0xa1,0x24,0x13,0xa6,0x2d,0xf5,0x34,0x85,0x2f,0x8b,0x6c,0x24, + 0x0f,0x44,0x9b,0xb6,0x7f,0x00,0x5d,0x49,0xfe,0x4f,0x23,0x1d,0x4a,0xf9,0xfd,0xc3, + 0x76,0x41,0xbc,0xd8,0xe6,0x8f,0xa4,0x08,0xf9,0x89,0xbe,0x0d,0xff,0xdd,0x16,0x6e, + 0x6b,0x55,0x7e,0x9a,0x0a,0x8a,0x05,0x6b,0x8b,0xb2,0xfe,0x20,0xa5,0x3f,0x7d,0x5c, + 0x6a,0x12,0xf9,0xe1,0x05,0x3e,0xf4,0x8b,0x6d,0x3b,0x78,0xfe,0x1c,0x0a,0xc1,0x43, + 0xbf,0x69,0xa3,0xd3,0x57,0xf9,0xf6,0x73,0x2a,0x5f,0x63,0xd9,0x92,0x42,0x40,0xe5, + 0xe8,0xcd,0x67,0x59,0xba,0x2f,0xa8,0xd2,0x00,0xc5,0xba,0xd8,0x19,0xfa,0x81,0x9f, + 0xf9,0x1e,0x88,0x7f,0x6d,0xaa,0x4d,0xea,0x00,0x0e,0x07,0x03,0xc6,0xbc,0xcf,0xf4, + 0xf3,0xfe,0x3e,0xb5,0x13,0x8c,0x2c,0x9e,0xe6,0x2f,0xda,0xcc,0x74,0x11,0xc2,0x22, + 0xeb,0x75,0x79,0x35,0xa1,0x57,0x78,0x79,0x7f,0xaf,0x4b,0x8d,0x5f,0x57,0x2b,0x60, + 0x0e,0xec,0x6a,0x79,0x24,0x3b,0x5b,0xba,0xf6,0x03,0x88,0x0b,0xa9,0x3d,0x9e,0xe7, + 0x8f,0xe4,0x44,0xd5,0xcf,0xe5,0xb1,0xe1,0x80,0x9b,0xe0,0xf4,0xd7,0x66,0x7f,0xd8, + 0x20,0x7a,0x17,0x3b,0x87,0x69,0x21,0xc3,0x7c,0x00,0x2a,0x2c,0xda,0x0a,0x74,0x73, + 0x31,0xac,0x20,0x30,0xe6,0x2d,0xd6,0x4b,0x4d,0xb1,0x2e,0xc0,0xc5,0x1f,0x90,0xad, + 0x56,0xeb,0xc0,0xee,0xd0,0x59,0x10,0xa9,0xf0,0xb0,0xbe,0x2a,0x5e,0x15,0xeb,0x79, + 0x5c,0xe0,0x88,0xd0,0xbe,0x70,0xc7,0x63,0xdc,0xb7,0x2a,0x21,0x0a,0x31,0xbf,0xd0, + 0xcf,0x1e,0x1e,0xcc,0x9e,0xfa,0x53,0x70,0xd3,0x32,0x76,0xc5,0x05,0x76,0x89,0xa2, + 0x83,0x58,0x3e,0xdd,0xdf,0x5f,0x5c,0x16,0x90,0xc8,0x6a,0xa0,0x40,0xaa,0xfb,0x37, + 0xca,0x8c,0x62,0x08,0xc9,0x90,0x84,0x08,0x14,0xfb,0x72,0xec,0x2c,0x89,0x95,0x45, + 0xa1,0x45,0x91,0xa2,0xe4,0x81,0x87,0x55,0x99,0x8f,0x57,0xc8,0xa9,0x20,0x45,0xb0, + 0xf3,0x35,0x48,0x8f,0x42,0x0e,0x5d,0x2c,0xc3,0x05,0x5d,0x39,0x4f,0xc7,0x9b,0xa5, + 0x3c,0x57,0x6b,0xb1,0xa0,0x0e,0xec,0x41,0xef,0x2b,0xb2,0x35,0xee,0x40,0xab,0xdb, + 0x26,0x05,0x4b,0xca,0x7e,0xc5,0xfe,0xc4,0x41,0x1b,0x37,0xe6,0xe2,0x42,0xff,0x4e, + 0x66,0x35,0xb7,0xf0,0x51,0x64,0x9e,0xa6,0x67,0x4c,0x6b,0x72,0x59,0x67,0xbe,0xbe, + 0x8b,0xc3,0x83,0xff,0x36,0x13,0x65,0xe8,0xe9,0x7b,0x68,0x79,0x39,0xea,0xcd,0x62, + 0xe0,0x3f,0x1e,0x9e,0x0f,0xfc,0xe8,0x24,0x86,0x2a,0x10,0x2a,0x90,0x24,0xa8,0x1a, + 0xc5,0xe0,0xcc,0x1a,0xf5,0xa1,0xf7,0x15,0xf2,0x83,0x50,0x53,0xd2,0x97,0x9b,0x38, + 0xae,0xd7,0x68,0xf2,0x7e,0x47,0x01,0x79,0x20,0xea,0xc8,0xa4,0xe0,0x85,0xb4,0xe6, + 0xdd,0xd0,0x4a,0x78,0x37,0x35,0xf3,0xb8,0x94,0x7f,0x86,0x59,0x38,0x28,0x5a,0xc6, + 0x13,0x0f,0xd8,0x5a,0x59,0x68,0xcb,0x3b,0x03,0x07,0x68,0xae,0xef,0x17,0x25,0x30, + 0xef,0x4d,0x65,0xd5,0x68,0x87,0xb3,0xe6,0xc2,0x78,0x2e,0x40,0xfe,0x27,0x64,0x6c, + 0xc2,0x9e,0x26,0x41,0x7b,0x21,0xa9,0x59,0xaf,0x18,0x12,0x9e,0x9b,0x9b,0x36,0x9d, + 0x27,0x83,0x08,0x6e,0x42,0xdd,0xba,0xda,0xf4,0xa8,0xd6,0x23,0x36,0x60,0xda,0x93, + 0x5a,0x89,0xf5,0x7b,0xef,0xf7,0x87,0xe6,0xf6,0x6e,0x05,0x7a,0x97,0xe0,0x44,0x16, + 0x98,0x7c,0xc3,0xdd,0xda,0xa8,0x99,0x01,0x86,0xc2,0x62,0xe7,0xb4,0xbe,0x02,0x4a, + 0x65,0xdc,0x08,0xc4,0xf2,0xa6,0x12,0xdb,0x71,0x15,0xe0,0x6f,0xed,0x5c,0xe1,0xe2, + 0x34,0xee,0x62,0xd3,0xe8,0xfd,0xc6,0x4d,0x09,0xd4,0xaf,0x3e,0xde,0x95,0x0b,0xab, + 0x6c,0x47,0xea,0x47,0xa7,0x8d,0x06,0xff,0xe6,0x48,0x5d,0x1c,0x4c,0x2f,0x48,0xe3, + 0xca,0xd7,0x1f,0xde,0x01,0x02,0xd4,0x08,0x96,0x0a,0x0d,0x1e,0x80,0x4a,0x60,0x1a, + 0x86,0xfc,0x50,0xdf,0x1b,0x2c,0xc6,0x68,0xe8,0x16,0xaf,0x6d,0x6b,0x43,0x74,0x98, + 0x59,0xc2,0xa7,0x47,0xe3,0x2b,0xd0,0x92,0x8c,0x6b,0x73,0x81,0x62,0x26,0x68,0x33, + 0x9a,0xa3,0xfd,0x13,0xa8,0xc8,0x3f,0x29,0xb0,0x20,0x13,0x52,0x91,0xc8,0xcd,0xc2, + 0x29,0x36,0x18,0xd3,0x2c,0xf6,0xbb,0x41,0x86,0x39,0xbe,0x12,0x1b,0x65,0x34,0xe0, + 0x9f,0xd6,0xd4,0x4a,0x06,0x23,0x9b,0xc4,0x19,0xeb,0x0f,0x78,0x42,0x5e,0x7d,0x7f, + 0xbf,0x60,0x73,0x1a,0xda,0xca,0xe6,0xf0,0x12,0x45,0x36,0x21,0x91,0x1b,0xd3,0xc1, + 0xb5,0x4d,0x89,0x3e,0x8c,0xed,0x7f,0x60,0x61,0xe3,0xa2,0xd3,0x7a,0x79,0x69,0xfe, + 0xdd,0x36,0xfe,0x3e,0xdf,0x32,0xbf,0xdf,0x06,0xed,0x80,0x0c,0x56,0xe9,0xc0,0xf8, + 0xc3,0x16,0x6c,0xf6,0xe2,0xd2,0xbc,0xd8,0x9a,0x5e,0x74,0x2e,0x5b,0x9d,0x4b,0x32, + 0xd0,0x60,0x81,0x9b,0x41,0x7a,0x31,0xbd,0xe8,0x5e,0x5e,0x4a,0x73,0xf1,0xcd,0x60, + 0x80,0x76,0xbe,0x63,0x5d,0xef,0x09,0xbd,0xeb,0x86,0x90,0x87,0x5d,0xdd,0x71,0xe2, + 0x84,0x49,0x18,0x22,0xc2,0x99,0x85,0x7a,0xf9,0x3e,0xc0,0x9f,0x7d,0xfa,0x80,0x69, + 0x7e,0x7a,0x65,0xab,0x54,0x62,0xc3,0x4b,0x53,0x7c,0x86,0x0d,0x59,0xff,0x1c,0x85, + 0x82,0xdf,0x51,0x99,0xec,0xb6,0x5e,0x24,0xbb,0x15,0x0d,0x38,0xe3,0xfa,0x47,0x67, + 0xac,0x56,0x97,0x51,0xfd,0xb5,0x62,0xe2,0x83,0x68,0x28,0x5b,0x56,0x2c,0x93,0xc5, + 0x8a,0x26,0x31,0x3e,0xbd,0x98,0x2c,0xfc,0x3a,0x46,0x1f,0xe3,0x1e,0x39,0xd4,0x70, + 0x6b,0xa5,0x50,0xf0,0xa2,0x28,0xbf,0x50,0x4b,0x2b,0xe3,0xac,0x86,0x5d,0x17,0xd5, + 0xe0,0xe5,0xe7,0x95,0x55,0x39,0x88,0x59,0xe9,0x87,0x5e,0x34,0x17,0x2e,0x02,0x35, + 0x6b,0x53,0xa5,0x4f,0x9f,0xe1,0x93,0xd9,0x54,0x5e,0x46,0x4a,0x2e,0xaf,0xf6,0x99, + 0x4b,0x34,0xd7,0xce,0x03,0x12,0x57,0xd4,0x97,0x65,0xd4,0x16,0xd4,0x40,0xbe,0x62, + 0x86,0xf8,0xf6,0x33,0xbf,0x55,0xca,0x56,0xe3,0x8d,0xeb,0xa8,0x41,0x05,0x3e,0xcb, + 0x02,0xf5,0x91,0x3e,0xda,0x02,0x0f,0x76,0x45,0x3b,0xb9,0xff,0x95,0xa8,0xaa,0xab, + 0xc0,0x2f,0x9c,0xd7,0x64,0xc3,0x73,0xfa,0xd9,0x2f,0xbe,0x22,0xe1,0x2e,0x3e,0xc2, + 0x2f,0xf5,0x5b,0xee,0x49,0xa5,0x94,0x90,0xef,0x94,0x6e,0xd4,0x00,0x38,0x59,0xf2, + 0x0f,0xf1,0xa2,0x5f,0x94,0xa0,0x40,0xb1,0xe2,0x3b,0xfe,0xac,0x36,0x22,0x34,0x8f, + 0xa2,0x10,0xbf,0xa8,0x21,0x7c,0x29,0x2e,0xa5,0x28,0x2d,0xde,0x34,0x63,0x62,0x1e, + 0x02,0x52,0x94,0x87,0x5f,0xcd,0x4d,0x2b,0x44,0xe0,0x0f,0xf8,0xa1,0x7c,0x49,0xd4, + 0x2f,0xc9,0xed,0x8a,0xae,0x96,0x2d,0xea,0x1f,0x76,0xd3,0x3a,0x56,0xa2,0x22,0x8a, + 0x3e,0xf2,0x77,0xcd,0x23,0x6d,0xf0,0x5d,0xaf,0xf5,0x97,0x97,0xf9,0xbc,0xbc,0xe7, + 0x2c,0x56,0xfb,0xcc,0xe2,0xda,0xf2,0x52,0xa4,0x9c,0xb2,0xba,0xf8,0x5b,0x19,0x07, + 0x07,0x95,0x15,0xdf,0xe9,0xb7,0x4a,0x56,0xa5,0xfb,0xb3,0x02,0x57,0xf1,0xaa,0x5f, + 0x2a,0x20,0xfd,0x8d,0x6b,0xb3,0x90,0x05,0x3e,0x73,0x01,0x75,0x0a,0xe4,0xee,0xab, + 0x20,0x0d,0xfc,0xac,0x41,0xab,0xe2,0xaa,0x5b,0x2e,0xfd,0x39,0xff,0x50,0x36,0x62, + 0xe3,0x6e,0x52,0x8f,0x3d,0x02,0xe4,0xc1,0xc2,0x74,0x87,0x0a,0x41,0x60,0x93,0x2f, + 0xc7,0x40,0xf1,0x41,0x16,0xa6,0x2a,0xb2,0xeb,0xf1,0x8d,0xdf,0x15,0x1c,0x29,0xbe, + 0x13,0x9b,0x2e,0x75,0x90,0xe2,0x69,0x2b,0x34,0x6b,0xdd,0xac,0xec,0xa2,0x68,0x7b, + 0x60,0xdc,0xe0,0xa7,0x28,0xd4,0x0b,0xdb,0xa9,0x6c,0x79,0x70,0x53,0x6a,0x9b,0x28, + 0xfe,0xab,0x68,0x3a,0x8c,0x0a,0x43,0x6e,0x3c,0x00,0x76,0x8c,0xa6,0xbc,0x0b,0x91, + 0xdc,0xe8,0x72,0x99,0x15,0x2f,0xbe,0x50,0x8c,0x8f,0x50,0x6e,0x20,0x7b,0x79,0x50, + 0xad,0xae,0xb1,0x8d,0x19,0xb3,0xb6,0x40,0x2d,0xd9,0x8a,0xed,0xe1,0x5c,0x3c,0xa4, + 0x63,0xf1,0x30,0x4a,0x4a,0x23,0x42,0xb1,0x87,0xf2,0xad,0x5d,0xf1,0x88,0x14,0xc3, + 0x36,0x0b,0x5f,0x2c,0x1e,0xd5,0x0c,0x11,0xa3,0x42,0x55,0x04,0x21,0x66,0x30,0x42, + 0x9d,0x1f,0xa9,0x0d,0x4e,0x06,0x1e,0xe9,0x84,0x70,0xc0,0x07,0x41,0x55,0x3b,0xe2, + 0x82,0x8c,0x78,0x67,0x28,0x98,0x80,0xb4,0x58,0x44,0x45,0x80,0xa2,0xa0,0x7e,0xc8, + 0x73,0xc6,0x0a,0xf9,0xb5,0x00,0xd1,0xf5,0x52,0x08,0x3d,0x2b,0x0e,0x12,0xae,0x07, + 0x0a,0xb0,0xae,0xad,0x9b,0x41,0x2e,0xbd,0x49,0xf3,0xbf,0x18,0xf1,0xc5,0xf5,0x25, + 0xac,0x92,0xb2,0xea,0xfd,0x92,0xb5,0x54,0x2a,0x64,0x34,0x23,0x3d,0xaf,0x8c,0x5d, + 0x04,0xce,0x70,0x40,0x65,0xf1,0xaa,0x20,0x10,0x56,0xed,0x31,0x7c,0x27,0xd9,0x6e, + 0x32,0x80,0x6f,0x1b,0x1b,0xf0,0x9f,0xca,0x71,0xa5,0x4e,0x77,0x82,0xeb,0x64,0x45, + 0x1c,0x4d,0xcc,0xd1,0xa4,0x64,0x3b,0x94,0x86,0x64,0x75,0xb2,0xa3,0xf1,0xf4,0x72, + 0x85,0x59,0x57,0x8a,0xfb,0xf5,0x8a,0x2b,0x10,0x29,0xaf,0x27,0x36,0xb6,0x10,0x3f, + 0x2b,0xe8,0xb5,0x1a,0x10,0xb2,0x43,0x01,0x45,0x6e,0x61,0xa0,0x22,0x78,0xdf,0x19, + 0xff,0x0c,0xc4,0xce,0x30,0x9b,0x90,0xa0,0x38,0x5c,0xe8,0xf4,0xfd,0xc3,0x41,0x6e, + 0x31,0xa2,0x53,0x06,0xd0,0x9e,0x00,0x87,0xd3,0x09,0xa2,0xc2,0x1b,0x74,0x65,0x4b, + 0x8d,0xef,0xcb,0xa8,0x62,0xf9,0xe8,0xe3,0xd0,0x54,0xa8,0x40,0x1b,0x28,0xc3,0x98, + 0x53,0x51,0xa2,0x1b,0x8e,0x77,0xa6,0x4e,0x72,0xfd,0x13,0xce,0xcb,0x00,0x4c,0xb1, + 0x0a,0xe2,0x82,0x1b,0xbb,0x40,0x13,0x73,0x01,0x72,0x21,0x08,0x20,0x9a,0x44,0x70, + 0x78,0xc7,0x36,0x1d,0xe5,0xc5,0xe0,0xe6,0x41,0x52,0x0d,0xb3,0xd1,0xec,0xce,0xf0, + 0xb3,0xae,0xd1,0xc3,0x44,0xd6,0x93,0xd6,0xf6,0x6b,0x24,0x24,0x20,0xe0,0x9a,0x2a, + 0xe8,0x6a,0xc3,0x7f,0x78,0x26,0xcf,0x34,0xdf,0xbd,0xdd,0x4c,0xb5,0x7f,0xba,0xb3, + 0xec,0x6e,0x74,0x37,0x0a,0xbc,0x7f,0x6a,0x64,0xb7,0xcd,0x48,0x11,0x41,0x5b,0x2a, + 0x68,0xe9,0x63,0xad,0x85,0x5e,0x2b,0x78,0xbf,0xa2,0x36,0x4f,0x7c,0xcc,0x3d,0xe9, + 0x70,0x46,0xaf,0xcf,0x9c,0x90,0x4a,0x73,0x52,0x6c,0xaf,0xd3,0x6e,0x6f,0x1b,0xee, + 0xc8,0x6c,0x75,0x6c,0xed,0x4c,0x58,0xd0,0xd1,0xf0,0x8a,0x21,0x21,0x80,0x2c,0xa8, + 0xd2,0x60,0x3f,0x1a,0x75,0xa4,0x0d,0x3d,0x10,0x61,0x3c,0xed,0x9f,0xce,0xf8,0x9f, + 0xda,0x55,0xee,0x0d,0xc3,0xf1,0x2b,0xd8,0x1a,0xe7,0x0f,0xc6,0x90,0xb0,0xdc,0xb4, + 0x9f,0x78,0x4e,0x8a,0x27,0x35,0x43,0xf4,0x65,0x9a,0x0b,0x9f,0x26,0x07,0x1d,0xf2, + 0xa2,0x50,0xde,0xbe,0x3b,0x46,0x57,0x09,0xec,0x39,0xe2,0x63,0x58,0xba,0x46,0x65, + 0x6c,0x2b,0xe7,0x3c,0x02,0x2e,0x92,0x78,0x7a,0x64,0x6c,0xe0,0xbc,0x61,0xb4,0xa9, + 0x9e,0x7b,0xf9,0x39,0x85,0x38,0x27,0x1b,0x0f,0x62,0x27,0x49,0xbd,0x37,0xe8,0x4d, + 0x6c,0xe4,0x67,0x42,0x9c,0xa5,0x0c,0x8f,0x85,0x68,0x27,0x30,0xe5,0x2e,0xa9,0xab, + 0x7e,0xfa,0x86,0xee,0xd1,0x34,0x40,0xb9,0xd8,0xd8,0x70,0xc6,0x47,0x83,0xf6,0xb1, + 0xfe,0xff,0x34,0x7d,0x4b,0x1c,0x7a,0xe1,0xad,0x84,0x06,0x40,0x0d,0xc0,0xe6,0x8c, + 0xb7,0x3a,0xa6,0xb9,0xdd,0x69,0x9b,0x5b,0xfa,0xbf,0x29,0x60,0xc2,0x63,0x2c,0x5c, + 0xae,0xdc,0xdf,0xa0,0x6e,0x62,0x21,0x3f,0xfe,0xba,0x85,0x85,0xe6,0x16,0x0c,0xbc, + 0x1b,0x3b,0x23,0x2f,0x89,0x7e,0xce,0x89,0x72,0x92,0x56,0x1c,0x6f,0x53,0x5e,0x50, + 0x77,0xc6,0x56,0x0f,0x8f,0x41,0x99,0x6a,0x06,0x9b,0xa7,0xb4,0x9b,0x14,0x2d,0x8b, + 0x5a,0x3a,0xc1,0x03,0x18,0x50,0x54,0x53,0xf2,0x60,0x4b,0xef,0xc2,0x91,0x24,0x18, + 0x05,0x48,0x36,0xb7,0xd4,0x1e,0xb6,0x36,0xf5,0xc6,0x73,0xb3,0xc8,0x5c,0xe0,0x11, + 0xfc,0xf3,0x01,0x50,0x3f,0x93,0x09,0x67,0x64,0xe5,0x7c,0x39,0xa7,0x0c,0xc5,0xa6, + 0x2a,0x51,0xe2,0xbc,0x20,0xee,0x33,0xda,0x8b,0xd2,0x50,0xa8,0x1e,0xad,0x8d,0xa7, + 0x62,0x7e,0xca,0x31,0x1e,0x66,0xf6,0x2c,0x8d,0x10,0xde,0xac,0x35,0xc6,0x48,0x90, + 0xbb,0x32,0xbf,0x6c,0xea,0x37,0xb9,0x12,0xdd,0xb2,0x7e,0x5c,0x22,0x67,0xeb,0xd1, + 0x57,0xe8,0x38,0x5a,0xbe,0xeb,0x6f,0x9e,0xe7,0x04,0x85,0x29,0x66,0x03,0xb4,0x44, + 0x72,0x63,0xe4,0x44,0xf9,0x50,0xd2,0x81,0x3c,0x83,0x40,0xe6,0x91,0x2f,0x3a,0x6e, + 0x3a,0xb1,0xcc,0xe2,0xbe,0x21,0x0c,0x10,0x42,0x37,0x56,0x0d,0x2f,0xac,0x4b,0xb3, + 0x9c,0xf2,0x6e,0x6c,0xa4,0x36,0xc7,0x40,0xa4,0x36,0xe7,0x59,0x28,0x71,0xcb,0xe9, + 0xa0,0xd5,0xe9,0x7f,0x3a,0xf9,0xe9,0xed,0xc7,0xcf,0xa7,0x9f,0x5e,0x9f,0xbd,0x3e, + 0x3f,0xab,0xcf,0x2c,0x06,0x82,0x8a,0x40,0x1d,0x21,0x3c,0x5e,0xff,0x61,0x70,0x8f, + 0x0c,0x9d,0xd8,0x84,0xe1,0x9a,0xd3,0x81,0xff,0x50,0xb0,0x57,0x01,0xf4,0x29,0x6e, + 0x1c,0xc1,0x68,0xa6,0x66,0xcf,0x90,0xf3,0x2b,0xd1,0xbf,0x63,0x1d,0xe7,0xa3,0xf7, + 0x52,0x65,0x3b,0xb2,0x10,0xf8,0x50,0xb6,0x10,0x55,0x88,0xa1,0xdc,0x2b,0xe1,0xe0, + 0xe3,0x10,0x33,0x15,0xd8,0x88,0xe0,0x46,0xde,0xac,0x98,0x6a,0x4e,0x0b,0xe2,0xf8, + 0x3f,0xf9,0x1a,0xee,0xc1,0xf3,0xef,0x0d,0x69,0xb6,0x55,0xcd,0xaf,0x22,0x33,0x53, + 0x5a,0x3b,0x88,0x13,0x7e,0xe5,0x66,0xc3,0xd2,0x92,0x83,0x8e,0x15,0x1e,0xb5,0x81, + 0x4a,0xe4,0x1d,0x94,0x6a,0xb6,0xc2,0x8a,0x29,0x2c,0xdc,0x32,0x42,0x20,0xf5,0x9d, + 0x63,0x5d,0xb8,0x7d,0x03,0xa5,0x90,0x0e,0xe0,0x7a,0x99,0x35,0x29,0x3e,0xe7,0x0b, + 0x55,0x58,0xab,0x59,0xc2,0xd8,0x59,0x49,0x67,0xb7,0x74,0x50,0xba,0xcd,0x06,0x5b, + 0x17,0x71,0xc6,0x65,0xe6,0xad,0x54,0x32,0xd2,0x9f,0xb3,0x69,0x60,0xf8,0xd6,0xd8, + 0x42,0xa9,0xd7,0xa2,0x78,0x1f,0x0b,0xcf,0xeb,0x14,0x01,0x71,0xb3,0x14,0x37,0xa4, + 0xa5,0xe3,0xd6,0xe6,0xd6,0x18,0xf6,0xa2,0x0c,0x18,0xda,0xdc,0xa2,0xbf,0x5b,0x9b, + 0x8d,0x01,0x4b,0x9b,0x5b,0xf8,0x17,0x8a,0x97,0xe2,0x4f,0x37,0xb7,0xfc,0xad,0x4d, + 0x9b,0xdb,0xf9,0x13,0x17,0x47,0x6c,0x6e,0x19,0x38,0xd8,0xe3,0xcd,0x7a,0x40,0xea, + 0xe6,0x16,0xfe,0xc5,0x41,0xa1,0x9b,0xdd,0x66,0x6f,0x73,0xd3,0xcc,0x7f,0x94,0x80, + 0xae,0x08,0xa2,0xa9,0x90,0x06,0x10,0x7f,0x26,0x51,0x8a,0x5e,0x60,0xf8,0x8a,0x78, + 0x0d,0xfe,0xae,0xb0,0x9b,0x09,0xc0,0x4e,0x9a,0xb0,0x57,0x8a,0x38,0xf9,0xd6,0x26, + 0xc7,0xa9,0xcd,0xc6,0x10,0x28,0x4c,0xcc,0x01,0x3c,0xb0,0x95,0x87,0x2f,0x6d,0xf6, + 0x2b,0xe7,0x33,0x0d,0xbb,0x14,0xe8,0x0f,0xb4,0xb8,0x55,0x6b,0x12,0x28,0x67,0x3a, + 0x32,0x62,0xb2,0xd4,0x99,0xb8,0x54,0x95,0x17,0x45,0x27,0x72,0x0f,0x37,0xb7,0x33, + 0x02,0x49,0x22,0x9a,0xea,0x47,0xfc,0xd7,0xb6,0xed,0xea,0xe8,0x10,0x04,0x58,0x4d, + 0x8d,0x1e,0x80,0x79,0x8b,0xc5,0xc6,0x47,0x1c,0x8c,0x4f,0x43,0x10,0xb4,0x62,0xab, + 0x5c,0x1a,0xfd,0x22,0x87,0x47,0x08,0x7f,0x8d,0x0a,0xb2,0x23,0x31,0x2d,0x53,0x63, + 0x05,0x25,0x4e,0xae,0x74,0x63,0x5a,0x53,0x3a,0x01,0xc6,0x32,0x06,0x1f,0xc2,0x80, + 0x26,0xb9,0x59,0x89,0x0b,0xcb,0xbb,0xa9,0xee,0x09,0x3d,0x8b,0xae,0x3d,0x3c,0xfe, + 0xc9,0x6d,0x3e,0x96,0x7e,0x32,0x1a,0xa1,0x8f,0xb1,0xfc,0xa2,0x9b,0x4b,0x2b,0xcf, + 0x52,0x4c,0xe4,0x38,0xf5,0xa0,0x14,0x45,0x38,0x5a,0xfa,0xaf,0xc5,0x9b,0x15,0xf5, + 0x94,0xce,0x94,0xc7,0xd3,0xe2,0x71,0x45,0x5d,0x8e,0xe4,0x29,0x7a,0xe4,0x08,0x75, + 0x42,0xe5,0x47,0x3a,0x8d,0x12,0x2c,0x21,0x62,0x92,0xa1,0x3b,0xfe,0xbd,0xa2,0x06, + 0xa5,0x0a,0x29,0x7a,0x3a,0xa7,0x7b,0x68,0x32,0x0f,0x76,0x27,0x66,0xcc,0x82,0x16, + 0x8a,0xf8,0xea,0xb4,0xa7,0x2d,0xd0,0xf8,0xf4,0xa0,0x2d,0xd8,0xf9,0x1c,0x1e,0x90, + 0x26,0xe0,0x1f,0x84,0xe3,0xc3,0x8a,0x6e,0x9c,0x99,0xeb,0x7b,0xe1,0x48,0x81,0xe2, + 0x5f,0xff,0x76,0xae,0x29,0x6f,0xf3,0x4b,0x4f,0x5a,0xca,0x6d,0x8e,0x08,0xfb,0x6d, + 0x84,0x9e,0xc6,0xa7,0x86,0x05,0x22,0xc9,0xfd,0xcf,0xfc,0x06,0x01,0xa3,0x78,0x08, + 0x20,0x32,0x57,0xe8,0x71,0x55,0xeb,0xc0,0x1a,0xc8,0x1a,0x85,0xcf,0x63,0x03,0x51, + 0x40,0xbf,0x8c,0x01,0x35,0x5c,0xd6,0x06,0x85,0x60,0xa3,0x6e,0x09,0xe1,0x61,0xf2, + 0x1c,0xab,0x54,0x5a,0x61,0x9c,0x7d,0x1d,0x0c,0xf0,0x5b,0x73,0x4b,0x4d,0x38,0x8e, + 0x2d,0x4a,0x2e,0x09,0x98,0x36,0x90,0xcd,0x48,0xd3,0x07,0x7f,0x72,0xbd,0x31,0xd9, + 0xfe,0xfb,0x8f,0xd0,0x16,0x18,0x1b,0x53,0x0c,0x60,0x5f,0x44,0x38,0xb0,0x62,0x5c, + 0xf8,0x3f,0x91,0x8f,0xc0,0x40,0x14,0x90,0xb4,0xe2,0x58,0x3e,0xf4,0x30,0x9e,0xf3, + 0x18,0xfe,0x65,0x5f,0x82,0x1e,0xd3,0xb9,0xbc,0x32,0x72,0xd2,0xf7,0x4e,0x3c,0x60, + 0x1c,0xe8,0x71,0x5b,0x83,0x81,0xdc,0x74,0x72,0xfb,0x14,0x1f,0xf0,0x0d,0xae,0xa9, + 0x7e,0x7f,0x9f,0xbf,0x13,0x5d,0xf1,0x99,0x5f,0x7e,0xbc,0xb7,0xb2,0x4a,0xbe,0xa9, + 0x1a,0x9a,0xe1,0xfd,0xd3,0x5b,0xd2,0x3c,0xec,0x89,0xfa,0x27,0xda,0x07,0xf5,0xd7, + 0x12,0x43,0x6b,0x5f,0xc8,0x4a,0x52,0x92,0x5f,0x18,0x0e,0x0d,0xd2,0xe5,0x58,0x61, + 0x1c,0xf3,0x26,0x44,0xd0,0x6d,0xe0,0xc5,0xfa,0xd6,0x98,0xb0,0x68,0x6e,0xce,0x57, + 0xf8,0x7d,0x71,0x27,0x17,0xe3,0x4b,0x45,0x23,0x5e,0xa1,0xa9,0x08,0x41,0xe5,0x51, + 0x55,0xc5,0x9a,0x36,0x28,0x2b,0x1b,0x1b,0xc6,0xb4,0x64,0x90,0x69,0x3e,0xc1,0x62, + 0xcc,0xe3,0x23,0x2c,0x9a,0xe7,0x85,0xaa,0xf5,0x5b,0x8a,0x76,0xdf,0x70,0xd6,0x4e, + 0x9c,0xba,0xc9,0x28,0x80,0x1f,0x2c,0x3a,0xfd,0x12,0xd2,0x36,0xcb,0x94,0x55,0xd9, + 0x08,0x3d,0x82,0xb7,0x31,0x00,0x64,0x16,0x2c,0x17,0x92,0xd0,0x7c,0xf3,0x26,0x42, + 0x37,0x40,0x2b,0xba,0xb6,0xa6,0xe9,0x55,0x0e,0x83,0x46,0x6b,0x10,0x6e,0x65,0xf8, + 0x50,0xd9,0xc9,0x40,0x02,0x07,0xab,0x6d,0x43,0x50,0xc2,0x84,0x7f,0x4b,0xd6,0x21, + 0x3e,0x35,0xcc,0x57,0x68,0x04,0x0a,0x75,0xe6,0x89,0xc0,0x10,0x03,0x43,0x43,0x85, + 0x01,0x72,0xc4,0x6b,0x8e,0x37,0x84,0x88,0x9b,0x08,0x40,0x67,0x8d,0xae,0x8f,0xf5, + 0xe8,0x1a,0xc4,0x4c,0x3a,0x97,0xed,0x8f,0xca,0xee,0xeb,0xe9,0x15,0x56,0x6c,0x18, + 0x12,0xf1,0x51,0xf4,0x0d,0x88,0x63,0x2f,0x74,0x5f,0x4d,0x40,0x2c,0x32,0x46,0x65, + 0x01,0x15,0xe1,0xf6,0x8a,0x45,0x57,0x03,0xda,0x4c,0x1c,0x6b,0x34,0xcc,0x81,0x02, + 0xab,0x39,0x75,0x62,0x61,0x08,0x04,0xad,0xe5,0x15,0xba,0xc3,0xe3,0xfc,0x05,0x35, + 0x4b,0xd1,0x37,0x10,0xc8,0x27,0x5f,0x85,0x09,0xa3,0xc6,0x68,0x64,0x74,0x6c,0x15, + 0x9f,0xe5,0x07,0x74,0x8d,0xc1,0x47,0x3f,0x11,0xea,0xad,0xfd,0x4c,0x78,0x76,0xfb, + 0x29,0xde,0xcc,0x95,0x8a,0xdb,0x7a,0xc9,0xe8,0x40,0x91,0x91,0x9a,0xa3,0xcd,0x42, + 0x1f,0xa6,0x23,0x35,0x23,0xbc,0x6d,0x21,0xd4,0x80,0x1b,0x81,0x76,0x8e,0x38,0x84, + 0xfa,0x07,0x5d,0xea,0x0d,0xe3,0xb7,0xab,0x1b,0x30,0x57,0x20,0x52,0xf4,0xb2,0xce, + 0x51,0xcc,0xb1,0x86,0xca,0x06,0x8c,0x9d,0xc1,0x76,0x81,0xb3,0x00,0x51,0x58,0x76, + 0xc7,0x3c,0xce,0x6b,0x5f,0x38,0x97,0xb8,0xcb,0x89,0xc6,0x1d,0xb7,0x7b,0x1d,0xb3, + 0xd7,0x2d,0x34,0xba,0x78,0x58,0xaf,0x3c,0x54,0x2b,0x0f,0x97,0x54,0x96,0x66,0x5e, + 0xa7,0x15,0x0f,0xef,0xef,0x1d,0x9b,0xae,0x32,0xf5,0x5e,0x61,0x1e,0xbb,0xc4,0x83, + 0x26,0xc4,0x69,0x7d,0x6d,0x7b,0x5c,0x9b,0x0b,0x5e,0x0d,0x34,0x52,0xa9,0x16,0xac, + 0xc2,0x77,0x23,0x72,0xef,0x06,0x58,0xa8,0xc7,0x05,0xe5,0xa2,0x61,0x2a,0x50,0x74, + 0x99,0xa1,0x85,0xc1,0xd3,0x20,0xda,0x08,0x89,0x47,0x49,0x12,0x9d,0x00,0x93,0x4b, + 0xa0,0x04,0xe4,0x24,0x3e,0x2c,0x92,0x4b,0xc9,0x2b,0xf3,0xb4,0x15,0xb6,0xc6,0xd1, + 0x38,0xc2,0xcf,0x73,0x4c,0x77,0x78,0x6a,0xe9,0x8c,0x64,0xa6,0xf1,0x2c,0xd0,0x86, + 0x74,0x65,0x85,0x70,0xa7,0x67,0x97,0x4f,0x0d,0x7d,0x7c,0xd3,0xe2,0xbe,0x3f,0x29, + 0xe0,0x73,0x6c,0xab,0x13,0xba,0xdc,0x98,0xa4,0xda,0xb0,0xb2,0x57,0xc2,0x9d,0x34, + 0x04,0x62,0xe5,0xb8,0x9c,0x21,0x57,0x7a,0x1b,0xc0,0xb8,0x87,0x5e,0x36,0xc7,0x30, + 0x2d,0x6c,0x2c,0x9b,0x47,0xc2,0x0e,0x66,0x8b,0xf3,0x74,0x42,0xda,0x8d,0x0d,0xfa, + 0x63,0x73,0x74,0x8e,0x89,0x3e,0xe7,0x6a,0xda,0x89,0x50,0x60,0x72,0xf1,0x81,0xb2, + 0x2b,0x14,0xef,0x4d,0x04,0x9e,0xa8,0xcd,0x8e,0x19,0xdc,0x7a,0x09,0xab,0xb8,0xa8, + 0x50,0x4a,0x61,0x75,0x41,0x63,0x7c,0xae,0xd4,0xcb,0x75,0xb8,0x0f,0x22,0x1d,0x1f, + 0x5e,0x95,0x85,0x47,0x7f,0x32,0x9e,0xeb,0x41,0x2c,0x14,0xbb,0xb7,0xe7,0x2e,0xe1, + 0x7d,0xd1,0x06,0xbe,0xcc,0x3d,0xde,0x73,0xdd,0x93,0x7c,0x70,0x2a,0xfe,0x37,0xcf, + 0x34,0x4d,0xf1,0xaf,0x10,0x26,0x7e,0x76,0x6d,0xaf,0xe9,0x95,0x71,0x14,0x04,0x9f, + 0x88,0x28,0x1a,0xa3,0xa1,0xd5,0x86,0x7f,0xb0,0x09,0x8b,0x3a,0xb2,0xc4,0x94,0xd8, + 0xab,0x65,0x0d,0xaf,0xad,0x41,0xd9,0x6b,0xab,0x69,0x80,0xec,0xc7,0x81,0x76,0x86, + 0xa1,0x39,0x1a,0x1a,0x14,0x78,0x44,0xef,0x54,0x28,0x88,0x56,0x95,0x98,0x85,0xf6, + 0xfd,0xbd,0xfa,0xbb,0xb3,0x93,0xdb,0x37,0x56,0x77,0x54,0x84,0x27,0xd4,0xfb,0x23, + 0x03,0x11,0xaf,0x49,0x3e,0x83,0xfb,0x7b,0x9d,0xc2,0xb5,0x13,0x8f,0x33,0x23,0x16, + 0x47,0x02,0x05,0x8d,0x97,0xa1,0x05,0xd5,0x31,0xbe,0x54,0xa3,0x21,0x64,0x94,0xc2, + 0x80,0xd7,0x6c,0x3d,0x40,0x97,0x40,0xf0,0x8d,0xa6,0xa6,0x9f,0x84,0xec,0x70,0xcb, + 0x1c,0x1e,0x6d,0xc8,0x43,0x0f,0xd1,0x8f,0x1c,0xa9,0x0a,0xa7,0x39,0x14,0xcf,0x92, + 0x2c,0xb8,0x5b,0x35,0x63,0xa4,0x16,0xb9,0x9f,0x30,0xba,0x0f,0xcd,0x12,0x8f,0x03, + 0x6e,0x30,0xa0,0x95,0x72,0x0a,0x7a,0x69,0x1c,0x85,0xf0,0x83,0x69,0x01,0xbe,0x42, + 0x7f,0x2e,0x60,0x3e,0xa1,0xeb,0xb9,0xb6,0x76,0x0a,0x50,0xc0,0xb7,0xb2,0x39,0xe6, + 0xc7,0x7c,0x91,0x33,0x85,0xb3,0x6b,0x40,0x03,0x52,0xa4,0xe5,0xe2,0x36,0x6e,0x8c, + 0x7c,0x03,0x02,0xc8,0xfc,0x5b,0x78,0x76,0x0f,0x3d,0x6d,0x0a,0x82,0x8b,0x73,0x8d, + 0xf7,0x2b,0x47,0xe8,0x67,0x6e,0x0b,0x79,0xad,0x0a,0x62,0x0e,0x74,0x2b,0xc3,0xb8, + 0x7e,0x3e,0x90,0xc7,0xea,0x4b,0xbe,0x86,0x24,0x80,0x99,0x9e,0x3b,0xa8,0x51,0x0a, + 0xc9,0x3c,0xea,0xb4,0x42,0x35,0xf7,0x13,0x13,0x98,0xbb,0xaf,0x93,0x64,0x40,0xb7, + 0x40,0x9d,0xd2,0x33,0x30,0x5d,0xa5,0x6d,0x79,0x2e,0xc0,0xe5,0xa4,0x45,0x74,0x89, + 0x87,0x3a,0x9b,0xd5,0x2e,0x29,0x2c,0xd4,0x1f,0x5d,0x4b,0x8b,0x25,0x2f,0xb2,0x68, + 0x41,0x65,0x21,0x52,0xe3,0x51,0x19,0xb8,0x3c,0x5d,0xc0,0x43,0x7c,0xb7,0xa7,0x66, + 0x19,0xc2,0x1a,0x80,0x0b,0xdd,0x76,0x97,0x2f,0x6c,0x15,0xeb,0x66,0xe5,0xa4,0x9b, + 0xe8,0x38,0x25,0x29,0x1a,0x22,0xcf,0x8d,0x90,0xc0,0xc3,0x82,0x3e,0x23,0x94,0x28, + 0x52,0x11,0x88,0x70,0xd2,0x09,0x49,0x14,0xe2,0x86,0x75,0x7f,0x2c,0x02,0x07,0xc4, + 0x45,0x58,0xcc,0x1f,0x3c,0xcc,0x9b,0x4e,0x0c,0x9e,0x6f,0x05,0x40,0x36,0x8f,0xad, + 0x71,0xcc,0x55,0xaa,0x01,0xb6,0x23,0x75,0x9f,0x61,0x96,0x37,0x7d,0xc4,0x77,0x46, + 0x39,0xe2,0x50,0x13,0x03,0xe0,0xc9,0xe7,0x8a,0x32,0x2f,0x00,0x46,0xf2,0x15,0x46, + 0x61,0xa6,0xdb,0x6a,0x50,0x8f,0x8a,0x0d,0x20,0x18,0xa5,0x96,0xef,0x06,0x5e,0x6a, + 0xd1,0xec,0x25,0x52,0x90,0x0f,0x24,0xc6,0x44,0x90,0xe7,0x23,0x14,0x1a,0xe0,0x7f, + 0xd0,0x73,0x99,0x0a,0x0f,0xe8,0xbf,0xc2,0x91,0x39,0x6f,0x1a,0x51,0xdf,0x78,0x8c, + 0xc6,0x3d,0xab,0xef,0x4e,0xea,0xb9,0xb4,0x3b,0x1d,0x3e,0xbe,0x46,0xf4,0x73,0x2d, + 0x5c,0x72,0xad,0x34,0xd5,0xb9,0x93,0xd2,0x74,0x69,0x93,0x92,0x0f,0x1b,0x30,0x0d, + 0x98,0xac,0x3f,0xbe,0x03,0x61,0x8f,0x28,0x55,0xee,0xfa,0xca,0x57,0x9f,0x2b,0x5e, + 0x78,0x08,0x6a,0xda,0xda,0x7c,0xc6,0x5a,0x3d,0xf4,0xdd,0xe6,0xbd,0x77,0xcc,0x74, + 0x4a,0xdf,0x02,0xe5,0x05,0x70,0xe1,0xd7,0x4f,0x6f,0x51,0xd8,0x00,0xb9,0x04,0xc4, + 0x4e,0x26,0x5d,0xd6,0x42,0xc4,0x9f,0xf5,0x80,0x0e,0xb7,0x1f,0x56,0x86,0x99,0x51, + 0x8d,0xe7,0x39,0xd1,0x63,0x40,0x55,0x29,0x3b,0x3b,0x4d,0x12,0xb7,0x40,0xb9,0x13, + 0xdd,0x75,0x49,0x7a,0x69,0x88,0x85,0xab,0x6e,0x69,0x5e,0x41,0x95,0x72,0xe6,0x0b, + 0xf8,0x60,0xc1,0xf0,0x96,0xf5,0x85,0xfe,0xc4,0x2e,0x69,0xa3,0x92,0x46,0x63,0x68, + 0x22,0x34,0xd6,0x53,0x68,0x94,0x7f,0x8d,0xf7,0x8b,0x33,0x36,0x61,0x1c,0xf9,0x0d, + 0xc1,0x15,0x17,0x05,0x0f,0xc1,0xf0,0x9c,0xcd,0x01,0xe1,0x13,0x05,0x72,0xdc,0xee, + 0xe8,0xf3,0xa0,0xb4,0x95,0x6d,0x4a,0x4c,0x46,0xd7,0xca,0x00,0x8f,0xba,0x72,0x81, + 0x48,0x59,0x94,0x61,0xe0,0xc7,0x05,0xe5,0xa6,0xe9,0x1c,0xee,0xae,0x37,0xfb,0x02, + 0x85,0xb7,0x3a,0x4b,0x90,0xf8,0xc1,0x7a,0x51,0x85,0x81,0xa6,0x95,0x16,0x41,0x25, + 0xe7,0x6b,0xe0,0x2f,0x05,0x3d,0x41,0xfd,0x74,0xd0,0xb6,0x88,0x02,0xb1,0xbc,0xa7, + 0x16,0xa1,0x55,0x27,0xad,0x8a,0x1c,0x11,0x1b,0xbc,0xa4,0xb3,0xd2,0x61,0x44,0x74, + 0x3d,0xd8,0xfe,0xc7,0xc7,0x5f,0xb6,0x7d,0x96,0x8a,0xfd,0x8c,0x62,0x5a,0xef,0x4c, + 0x85,0xa1,0x3d,0x8f,0xae,0x4d,0xea,0x75,0x6b,0x4b,0x7d,0x3b,0x1a,0x6e,0x6c,0x60, + 0x88,0x61,0x3e,0x5f,0xd0,0x3b,0x7d,0x92,0xbe,0x88,0xfc,0xe6,0x50,0x80,0x12,0x52, + 0x00,0xe6,0xcf,0x28,0x6b,0xe7,0x9f,0xe5,0x3b,0xb3,0x7a,0xdc,0x2b,0xde,0x2b,0x3d, + 0xf2,0x1b,0xc4,0x53,0xa6,0xbe,0x4a,0x34,0x23,0x7c,0xdd,0x66,0x62,0x57,0x9b,0x88, + 0xa9,0x02,0x4a,0x0a,0x64,0x6a,0x44,0xa2,0xd0,0xa9,0x97,0xc4,0x3c,0x6f,0xf2,0xc1, + 0x85,0xa6,0x1c,0xdb,0xf1,0x38,0x84,0xfd,0x48,0x91,0x31,0x02,0xb3,0xac,0xb0,0xa2, + 0x1a,0xf8,0xf1,0x17,0xbd,0x27,0x87,0x52,0x44,0x0b,0xff,0x03,0xd8,0x83,0x11,0x25, + 0xe6,0xf1,0x45,0xcf,0xba,0x3c,0xfe,0x7b,0xfa,0xc3,0xb6,0x8f,0xc6,0xbe,0xaa,0x5f, + 0x42,0x1e,0xd4,0x46,0x37,0xd9,0x99,0xcb,0xcd,0x0b,0xca,0x14,0x4d,0x95,0xd2,0x2d, + 0x80,0xd4,0x25,0xb5,0x4d,0x48,0x6b,0x69,0x32,0xc5,0xe3,0x75,0xd5,0x51,0xb2,0xce, + 0x38,0xa6,0x39,0x17,0xb6,0x60,0xb3,0xe1,0xdd,0xb1,0xac,0x02,0x4a,0x39,0x44,0x1e, + 0xe3,0x25,0xb9,0x18,0x5d,0x38,0x78,0x94,0xfd,0x50,0x34,0xb2,0x30,0x89,0x24,0x5b, + 0xfa,0x79,0x7e,0xf1,0x6d,0x2a,0xd3,0x3e,0xa0,0xd4,0x93,0xab,0x1d,0x88,0xf8,0x2e, + 0x47,0x3e,0x89,0xa1,0xa4,0xa8,0xcd,0x8c,0x64,0x90,0x54,0xc4,0x81,0x65,0x52,0xda, + 0x41,0x62,0x8a,0xb9,0xdf,0x0a,0xa6,0xe9,0xf0,0x41,0x3c,0x67,0xda,0x6b,0xb1,0x72, + 0xeb,0xb9,0xf9,0xad,0xb7,0xba,0x10,0x3e,0xf4,0x33,0xd1,0xba,0xe8,0xf1,0xe2,0xe3, + 0x2f,0x97,0x9a,0xcc,0xbb,0x40,0xa4,0xaf,0x51,0xde,0x52,0x25,0xb8,0xb3,0xbc,0xa2, + 0x2c,0x5c,0x1c,0x15,0x9d,0x45,0xe3,0x4c,0x3a,0x3d,0x3c,0x51,0x44,0x6f,0x26,0xce, + 0xab,0xc4,0xee,0xf6,0x6e,0x63,0x2d,0xd4,0xd4,0x31,0x59,0x97,0x8f,0x94,0x51,0x02, + 0x8c,0x7c,0xc0,0x7b,0x39,0xa8,0x65,0xa6,0x7d,0x94,0x16,0xaf,0xc3,0x08,0x38,0x15, + 0xdd,0x38,0x8c,0xe7,0x98,0xf2,0x66,0xc2,0x5c,0xc4,0x3c,0xe5,0xb4,0xdb,0x92,0xd6, + 0x1a,0x63,0xc0,0xc2,0x89,0x74,0x6f,0x40,0x71,0x30,0xa0,0x75,0x33,0x6d,0xed,0x17, + 0x3c,0x26,0x45,0x2a,0x89,0x4b,0xdb,0xa2,0x26,0xd9,0xe0,0x2c,0xdb,0x42,0x7b,0x74, + 0x2a,0x25,0x53,0x94,0x34,0xf9,0xe6,0xc3,0x39,0x48,0x90,0x4e,0x88,0x31,0x6d,0x19, + 0x5e,0x78,0xe1,0xb8,0xb9,0x0c,0x89,0x10,0x00,0x3a,0x7b,0xd8,0x6d,0xaf,0x4f,0x94, + 0x81,0x1c,0xaf,0x92,0x2c,0x1a,0x88,0xb2,0x00,0x62,0x5d,0x14,0xad,0x2e,0xec,0x82, + 0x85,0xe4,0x16,0x1e,0xe8,0x6b,0xec,0xef,0x29,0xaf,0x99,0x91,0x4e,0x16,0xa3,0x20, + 0x1a,0x0e,0xbd,0x84,0x2f,0x7d,0xa6,0x39,0x22,0x51,0x48,0x1b,0x38,0xfe,0xe3,0x6e, + 0x5e,0xdf,0xc0,0x15,0x8b,0x42,0xff,0xdd,0x95,0xfe,0x58,0x5c,0x44,0x21,0x64,0x46, + 0xd9,0x5d,0x46,0xa1,0xdf,0xd7,0x97,0x74,0xa4,0x8e,0xe5,0x69,0x3d,0xd8,0x8b,0x4b, + 0xf9,0x5c,0x71,0x63,0xa2,0xe8,0x1b,0xe9,0xc3,0x24,0x3a,0x20,0x1f,0x9e,0xba,0x6f, + 0x4f,0x5f,0x69,0x8e,0xda,0x5f,0xed,0x10,0x55,0x22,0x94,0xc8,0x8d,0x0c,0x25,0x33, + 0xa3,0x3a,0x7a,0xf3,0xab,0xbd,0xbb,0x38,0xcf,0x07,0x8e,0x85,0xae,0x3b,0x1e,0x5c, + 0x08,0xcf,0x2d,0x72,0xf9,0xb3,0xe4,0x8f,0xe1,0x3c,0x7f,0x4c,0xc7,0xf9,0xe3,0x28, + 0xb9,0x14,0xc1,0xd3,0x96,0xde,0xe8,0xbb,0xa5,0x34,0x5c,0xf4,0x86,0xc7,0xf2,0xdc, + 0x57,0xe3,0xb9,0xfd,0xc6,0xc6,0x73,0xe9,0x0e,0x20,0xdf,0x89,0x50,0x06,0xa5,0x35, + 0x05,0x24,0x79,0x73,0x55,0xde,0xca,0xb5,0x8a,0x7e,0xe9,0xe7,0x29,0x66,0xe2,0x19, + 0x18,0x79,0xa5,0xe3,0x72,0xe9,0x9e,0xda,0x87,0x9d,0x82,0xb2,0x9a,0x29,0x93,0x5b, + 0xcb,0x13,0x4e,0xc1,0x52,0xdf,0xbd,0x1d,0x2c,0x10,0x8e,0xbd,0xb6,0x35,0x9c,0xf7, + 0x3a,0x56,0x3a,0xee,0x75,0xad,0x51,0xd2,0xdb,0x79,0xa8,0x38,0xc6,0xf5,0x73,0xb4, + 0x11,0x0b,0x54,0x0c,0xf6,0x02,0x5a,0xb9,0xec,0xaf,0xf4,0xfb,0xca,0x21,0xd0,0xaf, + 0x22,0xcc,0xff,0x90,0x7b,0x5c,0xd9,0x4d,0xaf,0xd1,0x59,0xae,0x29,0x90,0xa6,0x21, + 0xc8,0x95,0xb4,0xd2,0x6d,0x72,0x37,0x5a,0x62,0x3e,0x2f,0x82,0x92,0x10,0xf4,0x79, + 0xdc,0x1a,0xb9,0x61,0x90,0x09,0x5a,0xf5,0x70,0xa8,0x33,0xab,0xe6,0x00,0x40,0xc1, + 0x04,0xdf,0xb0,0x9e,0x93,0x45,0x1c,0xcb,0x23,0x29,0x1a,0x8f,0x94,0xc2,0xb2,0xf1, + 0x3e,0xf1,0xa6,0x38,0x37,0xd2,0x97,0x9b,0x4f,0x36,0x86,0xc5,0xc1,0x46,0x61,0xda, + 0x17,0x69,0xe3,0xd8,0xbc,0xaf,0x1a,0xf7,0xbf,0x97,0xdd,0x68,0xb2,0x48,0x1d,0xed, + 0x6e,0xcd,0xc5,0x6d,0x03,0x7a,0x40,0x61,0xeb,0x16,0x58,0xe8,0x50,0x9c,0x51,0x5c, + 0x48,0x37,0x20,0x8e,0x3c,0xb7,0xf8,0x56,0x72,0x4b,0x5c,0x3a,0xde,0x70,0xfc,0x91, + 0xb1,0xba,0x99,0xb5,0xf4,0xad,0x6c,0xb9,0x5c,0x96,0x01,0xd8,0x86,0x39,0x32,0x67, + 0x45,0xa0,0xbf,0xf2,0x92,0xc2,0x3a,0xa9,0x1b,0x93,0xd3,0xec,0xe1,0xb3,0x21,0xb4, + 0x52,0xd8,0x73,0xb1,0x7c,0xd1,0x78,0x8c,0x42,0x57,0x7a,0x2c,0x59,0xfe,0xf1,0x34, + 0xfb,0x35,0xc6,0x20,0x66,0x3a,0x72,0x54,0x13,0x49,0xa4,0xdb,0x07,0xfb,0xa8,0x9d, + 0x59,0x93,0xd2,0xdb,0x7f,0xa3,0xb7,0xdb,0x3b,0x98,0xfb,0xc6,0x9a,0x96,0x3f,0xe1, + 0xcb,0xed,0xfd,0xb6,0xea,0xfb,0xec,0x1e,0xb5,0x8f,0xdd,0x2d,0xdd,0xd5,0xf4,0xad, + 0xc9,0x96,0x3e,0xd1,0x7b,0xc6,0x04,0xde,0xe0,0x23,0xbc,0x99,0x6e,0xe9,0x53,0xbd, + 0x47,0xff,0x2d,0x25,0xf9,0x01,0xac,0x31,0x7c,0x6b,0x68,0xe5,0xc9,0x74,0x36,0xab, + 0x49,0x42,0x31,0xd7,0xf5,0x91,0xf0,0x44,0xf0,0xd1,0x35,0x61,0x73,0x6b,0xc8,0x5e, + 0x09,0x9b,0x5b,0x46,0x7a,0xbc,0xa9,0x1d,0xa6,0xf0,0xc4,0x6e,0x04,0x15,0xb7,0x12, + 0xf5,0xa0,0x44,0x81,0xe5,0x42,0x05,0x23,0xca,0x0b,0xf2,0x39,0xe5,0x00,0x0c,0x4e, + 0xa0,0x84,0x59,0x82,0xde,0x8a,0x70,0x01,0x23,0x2f,0x65,0xed,0xb4,0xcb,0x99,0x80, + 0x94,0xb6,0x16,0x4c,0x46,0x8a,0x26,0x40,0xde,0xc6,0x23,0x97,0xbc,0x95,0xf2,0xc7, + 0x4a,0x77,0xed,0x87,0x87,0xb2,0x75,0x44,0xb6,0x5a,0x0b,0x4d,0x5f,0x1e,0x99,0x4e, + 0x71,0xd2,0x0d,0xda,0x3b,0xa7,0x33,0x90,0x39,0x5e,0x6b,0x91,0x81,0x41,0xc0,0xb6, + 0x7a,0xdb,0xb6,0xf5,0x92,0x20,0xb4,0xb2,0x9e,0xae,0x30,0xaa,0xe1,0x40,0xf1,0xcb, + 0x4c,0x6d,0xcc,0x39,0xf5,0x19,0x28,0xa0,0xb7,0x8d,0x99,0x8c,0x15,0xf5,0x85,0x53, + 0xbd,0xaa,0x59,0x00,0x04,0x39,0x27,0x44,0xd0,0x7f,0x8d,0xd1,0x96,0x01,0xc4,0x80, + 0x51,0xd5,0x9e,0xc5,0x1c,0xae,0x6e,0x9a,0x5b,0xa5,0x72,0x3f,0x72,0x6a,0x61,0xdd, + 0x82,0x32,0x98,0x66,0xf8,0xf3,0xf4,0x66,0x1b,0xbd,0x41,0x61,0x84,0xd1,0x1b,0xbc, + 0x36,0xdb,0xe8,0x9a,0x96,0xfe,0x9f,0x7a,0xa5,0x5a,0x9e,0x2e,0x16,0x38,0xc1,0xd0, + 0xd2,0x7f,0xf9,0x51,0xfb,0x01,0xb3,0xba,0x03,0x7a,0xd6,0x47,0x0f,0xef,0x79,0xf0, + 0x95,0x36,0xe8,0xf6,0xbb,0x20,0x88,0x46,0xba,0xd5,0x50,0x09,0x83,0xb2,0xf0,0x23, + 0x57,0xc5,0x2e,0xaa,0x63,0x50,0xf2,0xcc,0xea,0x56,0x6a,0x53,0x56,0x2e,0x4b,0x77, + 0x7f,0x9c,0x56,0x0b,0xbe,0x03,0xba,0xaa,0x7d,0x3a,0x3b,0x7b,0x8b,0xc5,0x12,0xd0, + 0x5f,0xa8,0x14,0x8c,0xf8,0xec,0xc3,0x27,0x8a,0xa9,0x4f,0xc3,0xa4,0x52,0x45,0xde, + 0x16,0x7c,0xfe,0x5b,0x01,0x42,0xe9,0x6f,0x5c,0x83,0xa1,0x2c,0xfc,0x49,0x29,0x9c, + 0xc0,0xf0,0x97,0x95,0x3f,0x15,0xd7,0x6c,0x60,0x79,0x28,0xe9,0x8d,0x6e,0x2c,0x3c, + 0xcf,0xa4,0xa1,0x40,0x3d,0x78,0x5c,0x52,0xe1,0x9c,0x2a,0xa0,0xfd,0xaf,0xba,0x1c, + 0x74,0xc9,0xda,0xa7,0xdf,0xb6,0xcf,0xf3,0x26,0x3f,0x53,0xbc,0x17,0xe8,0x9f,0xdb, + 0x3c,0x45,0xa8,0xc4,0xaf,0x2a,0x55,0x7f,0xf2,0xd1,0x0f,0xad,0x52,0xd7,0xa5,0x97, + 0xe5,0xca,0xfc,0xae,0x52,0xfb,0xfc,0x37,0x0d,0x94,0xfa,0x99,0x87,0x35,0xb3,0xdb, + 0xcf,0xf4,0x9c,0x17,0x51,0x24,0x81,0x63,0xb1,0xe4,0x98,0xe9,0x24,0xaf,0x80,0x1c, + 0x41,0x54,0xa1,0x44,0x39,0xa5,0x96,0x49,0x61,0x95,0x8b,0x86,0x4c,0xe3,0x73,0xbe, + 0x72,0xd5,0xa2,0x6f,0x4f,0xb1,0x8c,0x1f,0xdf,0xdf,0xeb,0x2d,0x29,0x36,0xc5,0xb3, + 0x74,0x42,0xfd,0x23,0xef,0x40,0x8c,0x02,0x3c,0x05,0x12,0xa5,0xbe,0x65,0x84,0x11, + 0x88,0x23,0x45,0x49,0xcc,0x4c,0x4c,0x12,0x86,0x92,0xf1,0xd8,0xb4,0xd4,0x86,0x80, + 0xda,0x54,0x0a,0x71,0x22,0xe3,0xa2,0x94,0xda,0x60,0x25,0x66,0xa4,0x10,0x7f,0x83, + 0x41,0x5a,0x44,0xb5,0xf6,0x4b,0x34,0x42,0x8a,0x36,0xca,0xde,0x4e,0x03,0x71,0xdc, + 0x76,0x0c,0x4f,0x53,0x27,0xae,0x47,0x5d,0x8b,0x83,0x75,0x4c,0xc7,0x07,0xb2,0xe6, + 0xad,0x1d,0x5d,0x73,0x14,0xec,0xfd,0xfd,0x2d,0x86,0x43,0xf3,0x0f,0x13,0x63,0x62, + 0x75,0x58,0x53,0xfc,0x0e,0xcb,0x1b,0x5d,0xd3,0x0a,0x53,0x09,0xf8,0x89,0x87,0xea, + 0x65,0x25,0xbd,0xc4,0x43,0xae,0x6f,0x88,0x83,0x08,0x87,0xb6,0x5b,0x3b,0x14,0x49, + 0x29,0xd0,0xff,0xee,0xb6,0xf0,0xbf,0x23,0xd6,0x52,0x4a,0x95,0x18,0xa3,0xb5,0x70, + 0x53,0x94,0x23,0xa2,0xaa,0x78,0xee,0x15,0x6f,0x44,0xb2,0xc4,0xcd,0x2d,0x9e,0x45, + 0xc9,0x61,0x4e,0x4a,0x99,0x79,0xe2,0xa4,0x1e,0xec,0x7f,0x99,0xdf,0x39,0xcf,0xe1, + 0xe2,0xb9,0xb6,0xae,0xf8,0xdc,0x2e,0x15,0xf8,0x0a,0xee,0x80,0x08,0xe1,0x60,0x8c, + 0x94,0x63,0xd3,0xf3,0x0d,0x49,0x43,0x8e,0x80,0xf6,0x11,0xf0,0x65,0xc7,0x4e,0x27, + 0x3e,0xaa,0xa8,0x2a,0xab,0x22,0x0c,0x80,0x7d,0x8b,0xc2,0x46,0x2e,0x64,0xb9,0x71, + 0x32,0x10,0x59,0xab,0x58,0x39,0x3f,0x05,0x02,0x1a,0x7c,0x42,0x0b,0xf7,0xfd,0x7d, + 0x07,0x03,0xa0,0x6f,0xf0,0x4c,0x03,0x36,0xd3,0xdf,0x30,0x21,0x23,0xc8,0x08,0x7b, + 0xfb,0xe4,0xe9,0x70,0x63,0x53,0x86,0xc6,0xc1,0xfc,0x07,0x68,0xa2,0x0f,0x3f,0x45, + 0x4e,0xec,0x09,0xfd,0x16,0xad,0x5f,0x61,0xf5,0x2b,0x8f,0xb9,0xc6,0x2d,0x88,0x6f, + 0x5d,0x3c,0xb4,0xbb,0xb2,0x53,0x3c,0x37,0x37,0xa0,0xa0,0x05,0xff,0xe2,0x0b,0xe2, + 0x94,0x9f,0x60,0xaf,0x1a,0x78,0x32,0x34,0xb7,0x26,0x52,0x36,0xc2,0xc1,0x8a,0x89, + 0x1d,0x76,0x2b,0xbe,0x1c,0xd3,0x90,0xf9,0x0e,0x10,0x6b,0x9b,0xdc,0x14,0xd8,0x90, + 0x4f,0xf3,0xb3,0xa6,0xb7,0xe2,0x23,0xc6,0xd2,0x56,0x3e,0x8a,0xb6,0xa7,0x28,0xf4, + 0x4d,0x43,0x73,0x31,0xbd,0xdd,0x02,0x61,0x7f,0x1a,0xb6,0x06,0x1d,0x5c,0x83,0x2b, + 0xcc,0x52,0x10,0x5d,0x7b,0x67,0x94,0x69,0x92,0x86,0x3f,0x8d,0x67,0x99,0xe7,0xd2, + 0x8b,0x22,0x85,0xa1,0x7c,0x10,0x8e,0x20,0x26,0xce,0xf4,0x34,0x41,0x57,0x88,0xec, + 0x8e,0x72,0x7a,0x19,0x7a,0xab,0xe5,0x8c,0x46,0xc8,0x3a,0x13,0x7f,0x6a,0x98,0xb0, + 0xd9,0xbf,0xeb,0x0e,0x5f,0xb8,0xde,0x4b,0x5a,0xf1,0x2b,0x1b,0x13,0xe6,0x12,0x5c, + 0x07,0xdd,0x3e,0xff,0xfa,0x6b,0x84,0xf7,0x8c,0x10,0x5b,0xd1,0xe1,0xd5,0xd0,0xbb, + 0xf2,0xc3,0x53,0x98,0x07,0xab,0x03,0x04,0x8d,0x9a,0xf8,0x79,0x23,0x5c,0xe0,0x18, + 0x2a,0xb7,0x03,0x7f,0x5b,0x05,0x5b,0xab,0x63,0xfe,0x60,0xcc,0x5b,0xbb,0xe6,0x56, + 0xd7,0xba,0x1b,0x4c,0x5a,0xbb,0x2d,0xe3,0xa6,0x05,0xb3,0xde,0x86,0xf9,0xe3,0xdf, + 0x1f,0x0c,0x28,0xd2,0x96,0x5b,0xff,0x98,0x87,0x71,0x1e,0x19,0xb7,0xd6,0x9d,0xd9, + 0xbb,0xb2,0x51,0xd1,0x16,0xbf,0x8a,0x74,0x5b,0x12,0x42,0x86,0xf8,0x35,0xf6,0x51, + 0x60,0x21,0x68,0xa9,0xb0,0x83,0x09,0x60,0x1e,0x4e,0xbc,0xd1,0x20,0xbe,0xd5,0xd2, + 0x3b,0x10,0xfa,0xa7,0xad,0x99,0x8f,0x13,0x43,0x74,0x38,0xc1,0xc4,0xac,0x30,0x59, + 0xc4,0x1b,0xbd,0x68,0xe8,0x1c,0x31,0x05,0x27,0x70,0x51,0x9a,0xc5,0xa5,0x35,0x6f, + 0x75,0x2d,0x1a,0x69,0x4d,0xfb,0x41,0x5a,0xcb,0x09,0xba,0x97,0x48,0xbf,0xe8,0x1a, + 0xf1,0x8b,0x77,0x47,0x89,0x68,0x0c,0xd6,0x29,0x54,0x0f,0x2a,0x7e,0xb3,0xb1,0xb1, + 0x2c,0xef,0xab,0xa8,0xd1,0xcf,0x63,0x8b,0x86,0xc1,0x2c,0x29,0x5c,0x83,0x1c,0xaf, + 0xb0,0x17,0x73,0xd2,0x1d,0x51,0x59,0x20,0x9a,0xe3,0x6d,0x6c,0x38,0xa8,0x35,0xe5, + 0xa5,0xf0,0xbc,0x1f,0xdf,0x51,0x43,0xa6,0xf8,0x6b,0x94,0xf7,0x79,0x7e,0x9b,0x85, + 0x32,0x5e,0x14,0x1a,0xf3,0xfc,0x9d,0x83,0x22,0x40,0x65,0x7b,0x5b,0x7b,0x8f,0x9e, + 0x37,0x64,0xb7,0xa3,0xd4,0xe2,0xf9,0xad,0x81,0x63,0xbc,0xd4,0x98,0x60,0xcc,0x37, + 0xe3,0xb1,0x2b,0x06,0xd5,0x54,0x32,0x96,0xf7,0x30,0x17,0xe4,0xb3,0x3c,0xa6,0x85, + 0xcd,0x72,0x63,0x18,0x6e,0xea,0xc9,0x08,0xa1,0x1b,0x76,0xaf,0xc7,0x30,0x22,0xd1, + 0xd3,0xb5,0x80,0x68,0x7e,0x12,0xc9,0x5d,0x53,0x5e,0xf4,0x67,0xe2,0xe4,0x05,0x65, + 0xef,0xbb,0x34,0xaf,0x8c,0xa9,0x4b,0x30,0x07,0x19,0xdb,0x6a,0x8b,0xcc,0x90,0xa0, + 0x32,0x43,0x57,0x74,0x21,0x21,0x5d,0xb4,0xa2,0x36,0x6f,0x3f,0xab,0x9b,0xe5,0x9b, + 0x17,0xc8,0xa2,0x01,0x50,0xc0,0x86,0x92,0x50,0xbd,0xc8,0x10,0x37,0xf0,0x82,0x8d, + 0x8d,0x9a,0xa7,0x98,0xc8,0x0b,0xb7,0xb1,0x31,0xb6,0xd1,0x91,0x27,0xcc,0xf0,0xf6, + 0x44,0x53,0xfd,0x01,0x5c,0x0d,0xc8,0x76,0xf6,0x23,0xdd,0xe0,0x6a,0x50,0xab,0x16, + 0x3a,0x52,0xde,0x66,0x67,0x30,0x2b,0x90,0x9d,0xa9,0x11,0x7a,0xbf,0x32,0x97,0x11, + 0x40,0x64,0x08,0xfd,0xb6,0xbc,0xf1,0x18,0x33,0x59,0xca,0x09,0xf2,0x94,0x0d,0xca, + 0xea,0x8e,0x36,0x9e,0xc4,0x73,0x02,0x91,0x32,0xba,0x5f,0x81,0x13,0xec,0x18,0x18, + 0x02,0xde,0x8d,0x8a,0x7e,0x1f,0x4d,0x68,0x8d,0xfd,0x34,0x1b,0x47,0x9b,0x4a,0x3f, + 0x58,0x9d,0x3d,0xde,0xfc,0x45,0x5a,0x77,0xf6,0x53,0x50,0x30,0x51,0xc9,0xba,0xbe, + 0xa8,0xc2,0x76,0x49,0xca,0xb3,0x52,0xf2,0x55,0x89,0xb5,0xa4,0xf8,0xd4,0xd4,0x32, + 0xea,0x32,0x4a,0x46,0x6c,0x11,0xcf,0xdb,0xa7,0xc4,0xed,0xaa,0x3c,0xb1,0xf9,0x2d, + 0x93,0xb8,0x6f,0xb2,0x23,0x0e,0x90,0xad,0xbc,0xfb,0x63,0xa1,0x63,0xc1,0x8b,0xe3, + 0x84,0x52,0xda,0x0f,0x3a,0x20,0x79,0xe4,0x2f,0x2b,0xd1,0x21,0x43,0x27,0x49,0x0d, + 0x94,0xef,0x58,0xa5,0x0e,0x07,0xf8,0x7c,0xd4,0xda,0xdb,0x3b,0xde,0xed,0xf1,0xe3, + 0xfe,0x8b,0xe3,0x1d,0xf1,0xf8,0xe2,0xe0,0xb8,0xdb,0xeb,0x50,0x84,0x6a,0xd4,0x1c, + 0xa3,0xb0,0x4b,0xf6,0xa5,0x08,0xbd,0xf7,0xd5,0xd4,0xe5,0xae,0x9f,0xc6,0x81,0x73, + 0xd7,0xe3,0x4b,0x08,0x5a,0x43,0x4c,0x77,0xdd,0x17,0x99,0x9b,0xe3,0x5b,0x99,0xce, + 0x3a,0xe1,0x94,0xeb,0xf0,0x62,0x18,0x25,0xb0,0xf9,0x28,0x45,0xdb,0x2c,0xa5,0x37, + 0x22,0xb7,0x33,0xa8,0xcb,0x3b,0x5b,0xfe,0x0f,0x3b,0x20,0xb3,0x60,0x31,0x90,0xce, + 0xaf,0x88,0xcb,0xe0,0x07,0xe8,0x3e,0x3c,0xd6,0x39,0x01,0x33,0xf0,0x2b,0x13,0x66, + 0xcd,0x3f,0xb0,0x4f,0x53,0x27,0xc1,0x47,0x0a,0x3a,0x7d,0x25,0x10,0x65,0xc5,0x40, + 0xf1,0x3e,0x82,0x3e,0xad,0x4f,0xcb,0x07,0xaa,0x9f,0xf6,0xf0,0x45,0x0b,0x54,0x52, + 0x39,0x1e,0x4c,0x18,0x4f,0x41,0x00,0x5b,0xba,0x68,0x5a,0xaf,0x6a,0xc0,0xb8,0x32, + 0xb3,0x24,0xc8,0x75,0x60,0x7c,0x7e,0x2c,0x8d,0x2a,0x27,0xc6,0x42,0x5f,0x14,0x71, + 0x72,0xad,0xa0,0xde,0x60,0xf9,0x99,0x01,0xf5,0xa5,0x2e,0x35,0xed,0x8b,0xca,0xc1, + 0x00,0x3b,0x59,0xa3,0xa8,0x9a,0xd8,0xf2,0x12,0x06,0x71,0xac,0xea,0x07,0x80,0x82, + 0x45,0xa3,0x61,0x9e,0xa6,0x28,0x24,0xe7,0x14,0x69,0x69,0xc4,0xda,0x4d,0x7e,0x8d, + 0xa2,0xf0,0x90,0x34,0xbd,0x96,0x43,0x7f,0x4a,0x86,0x67,0xcf,0x0b,0xf1,0x28,0x8d, + 0x7a,0xa7,0x46,0x1a,0x7a,0x44,0xb1,0x1d,0xca,0x5d,0x70,0x97,0x97,0xa6,0x9a,0x88, + 0xbc,0xaf,0x7e,0x01,0x84,0x13,0xdf,0xf0,0x00,0x54,0x3d,0x5f,0x6c,0xde,0x78,0xd4, + 0xa1,0x10,0xe5,0xe9,0xb9,0x24,0xcc,0x87,0xb9,0x30,0x2f,0xd1,0x42,0x5c,0xfd,0x50, + 0xbe,0xe7,0x41,0x26,0x9d,0xf7,0xf2,0x68,0x94,0x14,0x3d,0x1b,0x58,0x9e,0xe6,0x81, + 0x09,0x01,0x9b,0x36,0x56,0x48,0x30,0x30,0xf3,0x23,0xdd,0xad,0xcd,0xf2,0x8e,0x4e, + 0xa5,0x28,0x2e,0xab,0xe6,0x58,0xb4,0x05,0xaf,0xbc,0x70,0x74,0xac,0x6f,0x7c,0xd7, + 0xe9,0x1e,0x74,0x5f,0xec,0xf6,0x39,0xa5,0xa9,0x7e,0x98,0x4e,0x41,0x3b,0x87,0x02, + 0xdc,0x36,0xd5,0xa0,0x37,0xf9,0x15,0x12,0x7a,0x5d,0x62,0xdf,0x2c,0x32,0x8e,0xd7, + 0xd2,0x94,0xe7,0x14,0xa7,0x8b,0xa8,0x0c,0xa2,0xbd,0x44,0x0a,0xd8,0xdb,0xb0,0xb3, + 0x6c,0x4c,0x30,0xbe,0xb9,0xcc,0x8a,0xbb,0x8a,0xce,0xad,0xdb,0x25,0x62,0xad,0xf0, + 0x31,0xe1,0xce,0x0a,0x83,0xab,0xda,0xf4,0x9f,0xb7,0xba,0x22,0xba,0x37,0xd8,0x5c, + 0x1f,0x61,0xca,0x25,0x61,0x45,0x24,0x68,0x10,0x66,0xfb,0xc2,0xe4,0x49,0x09,0x59, + 0xf8,0x13,0x92,0x10,0x04,0x13,0x0d,0xd6,0xa0,0x64,0xc4,0xf4,0x24,0x23,0x75,0x17, + 0xc3,0xd9,0x10,0x64,0x88,0xb4,0xc7,0x68,0xcb,0x1e,0xd7,0x05,0x6b,0x6a,0xb4,0x89, + 0x8a,0x10,0xcd,0xb2,0x58,0x08,0x6c,0xf8,0x13,0xdd,0xf4,0x0d,0x6c,0x96,0x4c,0xbd, + 0x85,0xcf,0xf3,0xd0,0xb9,0xa6,0x2b,0x97,0x81,0x4f,0x8f,0x9c,0x99,0x70,0x9c,0xe3, + 0xfb,0x09,0x4e,0x4e,0x29,0x47,0x6a,0x18,0xf1,0x5d,0x9b,0x00,0x12,0xf2,0xb0,0x3a, + 0x8b,0x66,0xc0,0x3c,0x7a,0x1a,0x5e,0xc2,0x90,0xf6,0xb6,0x91,0x86,0xa0,0x9d,0x7c, + 0x32,0x02,0x3e,0x6d,0x87,0x5f,0x88,0xa6,0xdc,0x74,0xc4,0x29,0x9f,0x66,0xf0,0xdf, + 0xa2,0x84,0x1f,0x99,0x96,0xf6,0xd7,0x59,0x80,0xee,0x5d,0xfb,0xd4,0xe0,0x78,0xf0, + 0xfe,0xe7,0x2f,0x30,0x92,0xf9,0xe0,0xfa,0xe7,0x2f,0x78,0xec,0x2e,0x0f,0x2b,0x35, + 0xe8,0x1d,0x93,0x5e,0x89,0x31,0x8f,0xad,0xe1,0xdc,0xc2,0x53,0xa6,0xe4,0x9f,0x36, + 0xa5,0xa1,0x2f,0x05,0x91,0x0e,0x2e,0x00,0x3a,0x8b,0xac,0xa7,0xff,0x7a,0x76,0xb2, + 0xfd,0xca,0x09,0x1d,0xd7,0xd1,0x0c,0x3c,0x42,0x9f,0xc2,0xfa,0xb8,0x9e,0x6b,0xc2, + 0xf2,0xf7,0xf4,0x97,0x9d,0xb6,0xbd,0xd7,0xdd,0xd3,0xf1,0xd8,0x45,0xdf,0xef,0xda, + 0xf0,0x94,0xc2,0xeb,0x17,0x3a,0x9e,0xbe,0xe8,0x7b,0x9c,0xbf,0x0d,0x5b,0x39,0x99, + 0x81,0x5c,0x0f,0xf4,0xdc,0x11,0xd5,0xf6,0xec,0x83,0x76,0x9b,0xab,0x75,0xf7,0xda, + 0x5c,0xab,0xd3,0x5e,0x5e,0x4d,0x33,0x3e,0x38,0x49,0x12,0xcd,0x65,0xb7,0xfb,0xf6, + 0xde,0x8b,0x65,0xdd,0x1e,0x34,0xd6,0x7f,0xef,0xe7,0x63,0xde,0xb3,0xdb,0xb2,0x72, + 0xa7,0x2b,0xea,0xbe,0x5c,0xde,0x77,0x4f,0x3b,0x3b,0xb1,0xb4,0xbf,0x9d,0x70,0xed, + 0xee,0x8e,0xdd,0xa9,0xcf,0xf8,0x60,0x79,0xd7,0x3d,0xed,0x3f,0xde,0xfd,0xb4,0x46, + 0x5d,0xa5,0xeb,0x1f,0x13,0xe7,0x8b,0x1f,0x3c,0xad,0xbf,0xd7,0xbf,0x6e,0xff,0xfa, + 0x4b,0x19,0x4c,0x07,0xfb,0x2f,0xed,0xfd,0xce,0xc1,0xda,0x75,0x7f,0xf2,0x00,0x8f, + 0x31,0x10,0xdd,0x2d,0xea,0xe7,0xab,0x5b,0x2c,0x53,0xa7,0x36,0xde,0x57,0x5f,0xbc, + 0xd1,0x04,0xf6,0x84,0xb8,0xac,0xbd,0x36,0x88,0xdd,0x9d,0xee,0xe3,0x28,0xf2,0xfa, + 0x57,0x6d,0x77,0x67,0x07,0x70,0x57,0x33,0xde,0x45,0xe1,0x95,0xf6,0x09,0xfd,0x1a, + 0xb9,0x0d,0x78,0x6f,0xe3,0x2d,0x0c,0x8f,0x0e,0x44,0x69,0x43,0x1d,0x43,0xa9,0xfe, + 0x2a,0x40,0x7c,0x20,0x6f,0x74,0x74,0x68,0x4d,0x57,0x41,0xb0,0x3e,0xf8,0x0f,0x40, + 0x6d,0xfe,0x1b,0xc4,0x6e,0xa8,0x29,0x90,0xec,0x85,0xbd,0xf3,0x62,0x0d,0xd0,0x29, + 0x15,0xab,0x38,0xae,0xb4,0xb0,0xaa,0x6b,0x8c,0xee,0x9b,0x5d,0x01,0x29,0x82,0x59, + 0xe6,0xb3,0x6d,0xa8,0x29,0x30,0x7c,0xbf,0xa1,0xe6,0xc1,0xfe,0xc1,0x1a,0xd3,0x55, + 0x6a,0x9e,0xcd,0xfd,0xec,0x0b,0x03,0xea,0x69,0x98,0xf6,0x9f,0xbe,0x97,0x85,0xce, + 0xb4,0x32,0xd5,0x6e,0xdb,0xee,0x2e,0x5d,0x9e,0xbd,0x86,0xda,0x55,0x4c,0x2d,0xb5, + 0xd0,0x04,0xee,0x67,0x97,0x4a,0x24,0x7a,0x39,0xfc,0x7d,0x51,0x64,0xdf,0x59,0x91, + 0x7a,0x47,0x55,0x67,0xc4,0xb1,0x39,0x89,0x5e,0xa8,0x93,0x85,0xc0,0xbe,0x12,0xc0, + 0xfb,0x11,0x47,0x72,0xf4,0x34,0x9e,0x04,0x48,0x92,0xf8,0x17,0x06,0xa3,0xd1,0x98, + 0xe0,0x37,0xfc,0xb1,0xdb,0xba,0x08,0x37,0x74,0xee,0xef,0x73,0x76,0xa8,0xe5,0x4e, + 0x77,0x6c,0x83,0x71,0x94,0x43,0x71,0xeb,0x0e,0xf8,0x5d,0xf9,0x8c,0xbc,0x9c,0xe0, + 0x9d,0x65,0x7f,0x68,0x52,0x24,0x3a,0xbf,0xbd,0xf0,0x2f,0xcd,0xe7,0x83,0x81,0xf8, + 0x79,0x87,0x3f,0x6b,0xfd,0x28,0x22,0x5c,0xd9,0x3c,0x48,0xc0,0xf9,0x89,0x02,0x8c, + 0xcb,0xb0,0xd1,0x70,0x5f,0xe5,0xf9,0x96,0x7f,0xfc,0x9b,0x04,0x55,0xf1,0xee,0xec, + 0x8d,0x84,0x5a,0xf1,0xee,0xd5,0xa7,0x1a,0x00,0xe7,0x5f,0xe8,0x18,0xfd,0x8d,0x4f, + 0xa1,0xf1,0x79,0xbc,0x51,0xa0,0x66,0x4a,0xb0,0x46,0xb3,0xa4,0x9c,0x6f,0xe1,0xfe, + 0x9e,0x15,0x9f,0x3c,0x56,0xbb,0x1a,0xde,0x0c,0x02,0x0d,0x07,0x0d,0xf3,0x2d,0xa9, + 0xc4,0xa1,0x8b,0x7b,0x64,0xd4,0x48,0x67,0xb2,0xfe,0xa1,0xa8,0x80,0xb9,0x13,0xe0, + 0xf7,0x3a,0xe9,0x13,0x72,0x47,0xb8,0xe6,0x1c,0x0a,0x30,0x5c,0xd3,0xe4,0x36,0xfd, + 0x72,0x30,0x75,0x35,0x96,0xdb,0x57,0x23,0xb8,0x33,0x10,0x5a,0xf3,0xbb,0x8c,0x36, + 0xb7,0x4a,0xc0,0x57,0x03,0xbb,0x0b,0xdb,0x59,0x73,0xb3,0x94,0x83,0xe1,0x88,0x5c, + 0xa6,0x64,0x1a,0x4b,0xe9,0xf9,0x56,0x6e,0x04,0xc0,0x5c,0x8b,0x91,0xe5,0x98,0x1c, + 0xec,0x9a,0xc0,0x97,0x9b,0x57,0xd8,0x15,0x2e,0x40,0x1f,0xaa,0x3b,0x86,0x17,0xa6, + 0xa0,0x41,0x17,0xaf,0xb9,0xc4,0x93,0x3e,0x67,0xfc,0x9e,0xfb,0x7c,0xeb,0xc0,0x08, + 0x83,0xb7,0x46,0x93,0x88,0xbd,0xb4,0xd0,0x46,0x8a,0xb5,0x8e,0x06,0x6d,0x33,0xad, + 0x66,0x2e,0xa2,0x2f,0x4a,0x74,0x9c,0xc1,0x8e,0x4a,0xe4,0x1f,0x04,0x7f,0x45,0x8e, + 0x0d,0x79,0x25,0x49,0x8e,0x19,0x2d,0x98,0x5e,0x39,0x11,0xf8,0x2b,0x9e,0x6f,0x70, + 0xd7,0xc3,0xeb,0xa5,0xd8,0x34,0x3f,0x22,0xb7,0x1a,0xb3,0x09,0x67,0xf9,0xf3,0x70, + 0x6e,0x56,0x11,0x97,0x3f,0xa4,0x63,0xb3,0x8a,0xbd,0xfc,0x61,0x94,0x90,0x92,0x00, + 0x3d,0xd0,0xea,0x6e,0xf1,0x04,0x0e,0xdb,0xc7,0xba,0x66,0xb8,0x91,0x47,0x37,0x54, + 0xd1,0x2b,0x80,0x40,0x22,0xa5,0x43,0x06,0x92,0x49,0x1a,0x84,0x84,0xfe,0x8a,0x04, + 0x55,0xf5,0x3d,0x02,0x18,0x5d,0xca,0x63,0x70,0x33,0xc8,0x01,0x69,0x11,0x26,0xf6, + 0x8b,0x74,0x47,0xba,0x7e,0x7f,0x4f,0x7f,0x09,0x19,0xcc,0x05,0x7d,0x2f,0xef,0xa1, + 0x07,0x91,0xe5,0x6c,0xc1,0xe9,0xcb,0x4a,0x78,0x7f,0xb1,0x75,0x73,0xd9,0xe7,0x3a, + 0x65,0xdc,0x7e,0x60,0x04,0x71,0xd1,0x69,0x9a,0xe5,0x59,0x4c,0x48,0xe3,0x6a,0xff, + 0xa4,0x26,0x45,0x0a,0xd5,0x49,0x12,0xcd,0xae,0xa4,0xf7,0xe5,0x14,0x4d,0x79,0xd9, + 0x04,0x05,0x4e,0x7c,0xe1,0xb8,0x37,0x78,0x09,0x9b,0xab,0x79,0xae,0x9f,0x45,0xc9, + 0xb3,0xa7,0x78,0xf8,0x70,0xb4,0x1e,0x3a,0x13,0xd1,0xd0,0x54,0xb2,0xf8,0x54,0xb7, + 0x9f,0x1c,0x03,0xe3,0xc2,0xdf,0x67,0xf9,0x25,0x12,0xc2,0xe9,0x87,0x7a,0xad,0xa5, + 0x7e,0x29,0xb6,0x63,0xfa,0x75,0x95,0xeb,0x39,0x63,0xa8,0x6c,0x25,0x3e,0x53,0x49, + 0xcc,0x5d,0x72,0xba,0x11,0xe9,0xab,0xeb,0x7e,0x37,0x64,0xdd,0x50,0xc8,0xeb,0xe3, + 0xa7,0x41,0x94,0xf8,0x3e,0xcc,0x43,0xe0,0x8b,0x83,0xbb,0x8d,0x0d,0xcc,0x73,0xb2, + 0x63,0x86,0x83,0x5d,0x72,0x6a,0xe9,0x58,0x5d,0x6b,0xc7,0xda,0x6d,0xca,0x94,0x9d, + 0x47,0xb7,0xe0,0x1e,0xd5,0xb7,0xfc,0xe5,0x3e,0x2c,0x3e,0x00,0x23,0x54,0x0d,0x06, + 0x59,0xbc,0xa4,0x3c,0x3a,0xd5,0xa0,0x5d,0x49,0x01,0x35,0x8c,0x8f,0x87,0x94,0xa7, + 0x4f,0x3a,0x4d,0x3c,0xb4,0xf8,0x6b,0xd1,0x90,0x4f,0xc5,0xf8,0x4a,0xeb,0xdc,0x0d, + 0x98,0xe8,0x17,0xbd,0x6a,0x71,0xb4,0xca,0x7c,0xe2,0x63,0xb8,0x11,0x79,0xf2,0x53, + 0x7e,0x01,0xb4,0x3c,0xe7,0xb7,0x03,0x85,0x9a,0x77,0xcb,0x81,0x29,0x32,0xb3,0x30, + 0x7b,0xaa,0x3a,0xe1,0x0c,0xb6,0x32,0xe2,0x2e,0x25,0xe9,0xa2,0x10,0x02,0xe1,0x98, + 0xe9,0xb9,0xc5,0x35,0x41,0x11,0x5d,0x40,0x31,0xff,0xb2,0xa3,0x95,0x22,0xfa,0x65, + 0x6a,0xcf,0xdc,0xbd,0x9b,0xcc,0x40,0xd3,0x3c,0x8e,0x53,0x64,0xc9,0x97,0x28,0x22, + 0x72,0xe6,0xeb,0xf9,0x51,0x6a,0xb4,0xb1,0xf1,0x5c,0x64,0x41,0x82,0x25,0x99,0x62, + 0x8e,0x22,0xc6,0xe2,0x70,0xda,0x8f,0x9e,0xac,0xf3,0x3e,0x88,0xc8,0x21,0x01,0xcb, + 0x5d,0x93,0xf2,0xa5,0xf0,0xdd,0x09,0x7c,0x20,0x21,0x4e,0xf3,0xd2,0x11,0xde,0x72, + 0x74,0x1e,0xe1,0x81,0x5a,0xf5,0x42,0x9d,0xf8,0xe3,0x4c,0x24,0xf2,0x42,0xef,0x60, + 0x8f,0xa3,0xbe,0xf2,0x5b,0x27,0xf8,0x42,0x7a,0x27,0x4c,0xe7,0xec,0xb8,0x4a,0x77, + 0x44,0x89,0x8b,0x2b,0x93,0x68,0x28,0xbc,0x5b,0x35,0x5d,0x44,0x92,0xea,0xc2,0xf8, + 0x2f,0x6e,0x00,0x21,0x8b,0x5f,0xa8,0xdc,0xf7,0x34,0xf4,0x30,0x5c,0x68,0xea,0x63, + 0x48,0x52,0xee,0x17,0x8b,0xa6,0xe8,0x58,0xe6,0x39,0xc3,0x63,0x73,0x11,0x6d,0x3c, + 0x77,0x82,0xeb,0xa2,0x31,0x4c,0x18,0x90,0x53,0xa6,0xc4,0xc3,0x03,0x11,0x59,0x07, + 0xc3,0xff,0x9d,0x40,0x5c,0x15,0xe8,0x27,0x6c,0x32,0x17,0x37,0x06,0xda,0x25,0x9f, + 0x19,0x2e,0xb9,0x8d,0x37,0xf7,0x55,0x13,0x99,0xaf,0x0c,0x97,0xc1,0xdb,0x92,0xd0, + 0xe2,0x88,0x68,0x9e,0x5f,0xb5,0x58,0x76,0x82,0xa1,0x02,0xc5,0x66,0x90,0x37,0x75, + 0x9a,0x4b,0x2e,0x24,0x5b,0x62,0x09,0x12,0x3e,0x74,0xaf,0xa2,0x59,0xe0,0x22,0x13, + 0x82,0xa9,0x8c,0x14,0xfc,0x6f,0x69,0x18,0x00,0xe8,0x50,0x96,0x62,0xb3,0xb2,0xf9, + 0xbd,0xf1,0x18,0x83,0x8b,0x85,0x90,0x57,0xf1,0xd4,0x3d,0x56,0xfc,0x69,0x7b,0x46, + 0xe1,0xe2,0x8b,0xd8,0xa9,0x5a,0xe1,0xcb,0x21,0x70,0x63,0x3f,0xf4,0x53,0x0c,0x81, + 0x55,0x49,0x4a,0xfd,0x96,0x1a,0x35,0xdd,0x2e,0xf3,0x22,0x1c,0x4b,0xe1,0x75,0x0b, + 0x02,0xd5,0xa0,0x9c,0x3e,0x2c,0xff,0x94,0x67,0x7e,0x93,0x07,0x2f,0xb2,0x47,0x12, + 0x1d,0x4a,0x77,0x5f,0xc0,0xd6,0x89,0x65,0x57,0x67,0x1e,0x22,0x25,0x8f,0x36,0x0f, + 0x40,0xd6,0x65,0x66,0xee,0xf8,0xfe,0x7e,0x64,0x6e,0x6c,0xc4,0x40,0x9f,0x46,0xb2, + 0xc6,0x49,0xa9,0x68,0xaa,0xb9,0x11,0x65,0xe8,0x23,0x26,0xaf,0xe4,0x22,0xad,0x64, + 0x39,0x2d,0x6d,0x28,0xc9,0xb6,0x11,0xb1,0x06,0x17,0x97,0x96,0x9b,0x6f,0xfb,0x52, + 0xcc,0x19,0x7c,0x36,0x28,0x6b,0x29,0x96,0xe3,0x43,0xf9,0x26,0x2f,0x84,0xcd,0xad, + 0x00,0x45,0x10,0x72,0x36,0x60,0xb1,0xe4,0x46,0xc8,0x24,0x7c,0x28,0xc1,0x9e,0xcc, + 0xd8,0x98,0xae,0xde,0xfa,0xa2,0x5b,0xee,0x85,0x12,0xe8,0x78,0x99,0x13,0x9b,0xca, + 0x5b,0xdd,0xc0,0xe9,0x91,0xbc,0xc2,0x9e,0x79,0x79,0x43,0x45,0xea,0x1a,0xd9,0x12, + 0xc6,0x45,0x5e,0x1e,0x8b,0x8b,0x0c,0xf5,0x02,0x3d,0x4a,0x5f,0x0d,0x98,0x9f,0x88, + 0x86,0x04,0x09,0xc8,0xa0,0x10,0x0b,0x31,0x26,0x53,0xc4,0xa7,0xe0,0x11,0x7d,0x03, + 0x05,0x34,0xa9,0xef,0x0f,0x92,0x7e,0x43,0xbf,0x4d,0x85,0x24,0xf2,0x24,0xa3,0x81, + 0x2b,0x75,0x84,0x9a,0xca,0x60,0x25,0x31,0xa7,0x47,0x59,0x43,0xce,0x5f,0x95,0x24, + 0x2d,0x19,0x81,0x0e,0x15,0xcb,0x84,0x29,0x34,0xbe,0x4f,0xcc,0xb9,0x8d,0x24,0x3e, + 0x4e,0x40,0x94,0x07,0xb9,0xb1,0xa5,0xb1,0xb1,0x38,0x19,0x29,0x82,0x8a,0xb0,0x06, + 0xa3,0xdb,0x89,0xb9,0x65,0x88,0x91,0xb2,0x57,0x8a,0x0a,0x21,0x05,0x20,0xd9,0x2d, + 0x03,0xe0,0xfc,0x37,0x8d,0x32,0x46,0xe3,0xfc,0xb3,0x5b,0x68,0x9f,0x3c,0x81,0x8a, + 0xfb,0x9d,0x6a,0xfb,0x6a,0x63,0xc3,0xa8,0xee,0x81,0xfb,0xfb,0xca,0xce,0x32,0xb9, + 0xed,0x32,0x76,0xeb,0x56,0xa5,0xd4,0xb1,0x4e,0x69,0x84,0xab,0x38,0xd1,0xe4,0xdb, + 0x43,0xcd,0x7d,0x54,0x59,0x30,0xe1,0x49,0x89,0xf9,0x29,0x38,0x57,0x79,0x5f,0xed, + 0x41,0xb4,0xf7,0xf6,0xe4,0xfc,0x04,0x40,0x2b,0xdb,0xa1,0xec,0xd7,0xb5,0x56,0xe4, + 0x5b,0xb5,0x0d,0x90,0x20,0x7e,0x8d,0x63,0x2f,0x79,0x05,0xc2,0x9f,0x61,0x2a,0x3c, + 0x17,0x6f,0x88,0xce,0x87,0x45,0xf9,0x98,0xeb,0xa3,0xe2,0xd7,0x05,0xef,0x9d,0x87, + 0x0c,0xac,0x8f,0xf8,0x01,0x05,0x5d,0xdd,0x82,0x77,0xd2,0x53,0xa6,0xd3,0x3d,0xc6, + 0x5f,0x74,0x3d,0x25,0xf0,0xc9,0x4e,0x17,0x76,0x23,0x7a,0x41,0xf6,0xb0,0x5e,0xd1, + 0xb1,0x37,0xcd,0xfb,0xe5,0x3c,0xcf,0xb5,0x7e,0xc5,0xeb,0xbc,0x5f,0x6f,0xaa,0x76, + 0xcb,0x5f,0x2d,0x78,0xc9,0x05,0xd6,0xc8,0x4e,0x26,0x89,0xaa,0xe8,0x17,0x64,0x2c, + 0x18,0x99,0xcc,0x2a,0x54,0xe9,0xbd,0xfc,0x51,0x89,0xdc,0x88,0x99,0x1e,0x8a,0x4c, + 0x3f,0x34,0x20,0xf2,0x83,0x82,0x0a,0x56,0x5c,0xca,0xf3,0x27,0xe6,0x89,0x69,0x18, + 0x4a,0xd9,0xfc,0xdc,0xfa,0xd1,0x56,0xce,0x6e,0x9e,0x6f,0xff,0x83,0xe2,0xa5,0xff, + 0x6e,0xdf,0x4b,0xac,0xfb,0xfe,0x1e,0x91,0xe7,0xfb,0x7b,0xda,0x20,0xdf,0xdf,0x67, + 0xb7,0xdf,0xdf,0xe3,0x00,0xff,0x2e,0xb0,0x45,0xfc,0xc0,0x45,0x97,0xef,0x11,0x3e, + 0xe2,0x99,0xa0,0x44,0xcf,0x94,0x92,0xe6,0xef,0xb6,0x29,0x52,0x73,0x5c,0xe3,0x75, + 0x12,0x45,0x36,0x41,0x04,0x2f,0x0e,0x54,0x40,0x58,0x09,0x9f,0x4f,0x01,0xc6,0xf8, + 0xa5,0x14,0xc0,0x56,0xba,0x7e,0x8b,0xaf,0x91,0x2a,0xa9,0x9f,0x44,0xb1,0xd5,0x9b, + 0xed,0x0a,0xc9,0x9a,0x84,0xf4,0x22,0xf8,0x5c,0xf0,0x42,0x66,0xa9,0x2a,0xe5,0x05, + 0x2e,0xd5,0x44,0x90,0xf9,0x8c,0x07,0x7a,0x6d,0xb8,0x8f,0xe3,0x34,0xf0,0x9c,0x94, + 0x0e,0x16,0x40,0x40,0x2d,0xdd,0xf0,0x05,0xdb,0xd3,0x8b,0xb5,0x8e,0x59,0xf2,0xc5, + 0x85,0xae,0x15,0xd3,0x8a,0x90,0x1f,0x51,0x61,0x5c,0xa3,0x0f,0x71,0xa3,0xb5,0x53, + 0x3a,0xe9,0x10,0xdd,0x74,0x4d,0xad,0x25,0x2e,0xfb,0x4a,0x31,0x5b,0x0d,0x09,0x76, + 0x80,0x2e,0xad,0x5f,0xcf,0x30,0x2d,0x15,0xde,0x2f,0x93,0x96,0x06,0xb2,0x34,0x72, + 0x9e,0xae,0x82,0xaf,0x84,0xcc,0x2f,0x1d,0x1a,0x17,0x80,0x65,0x92,0xa3,0xa8,0x4e, + 0x56,0xd5,0x62,0x00,0xcf,0x2e,0x4e,0x5a,0xff,0xed,0xb4,0xbe,0x5c,0x2e,0x76,0x1e, + 0x64,0xbe,0x96,0x62,0x21,0xd6,0x21,0x2f,0xba,0x59,0x52,0x71,0x56,0x40,0x8b,0x7c, + 0x09,0x00,0x58,0x3b,0xad,0xc0,0x43,0x2f,0x63,0x0d,0xe9,0x98,0xb0,0x07,0x68,0x18, + 0x83,0xad,0x19,0xe4,0x10,0x8a,0x03,0x2f,0x0f,0x7b,0x79,0xd3,0xb9,0x90,0x34,0x94, + 0x06,0x32,0x91,0x5a,0x64,0x58,0xcf,0x0d,0xa2,0x66,0x04,0x58,0x70,0x1c,0x22,0x8b, + 0xfe,0xc5,0x61,0x5d,0x22,0x42,0xd5,0xc5,0xcd,0x35,0xcb,0x42,0xdb,0x9f,0x27,0x6a, + 0xca,0x0b,0xb5,0x8a,0x72,0x35,0x2b,0x5b,0x88,0x40,0x8e,0x8f,0xe9,0x92,0xef,0x3c, + 0x99,0x80,0x81,0xd7,0x83,0x72,0xcc,0xbb,0x14,0xd9,0x29,0x9e,0x92,0x63,0xd8,0xe6, + 0x32,0xdb,0x83,0xda,0x1c,0x5a,0x95,0xf2,0xdb,0x05,0x39,0xa7,0x00,0xfb,0x0d,0x21, + 0x75,0x80,0xc6,0x5f,0xbd,0x7b,0x8b,0xc6,0x2e,0xdf,0xa5,0xe8,0x79,0x3b,0xaf,0xda, + 0x18,0x17,0x5a,0x1a,0x92,0x34,0x84,0x51,0xe4,0xa1,0x48,0x6f,0x40,0x57,0xbb,0xe0, + 0xf0,0x38,0x4b,0x8d,0x12,0x96,0x4f,0xc1,0xb5,0x79,0x70,0xbe,0xad,0xbd,0x95,0x19, + 0x0e,0x44,0x33,0x62,0x3c,0x16,0x4d,0x48,0x5c,0x22,0x8e,0x63,0x48,0xb5,0xbf,0xeb, + 0xac,0x87,0x73,0x30,0xe8,0xf3,0xbf,0xeb,0x14,0x58,0xea,0x67,0x22,0x1e,0x94,0xf5, + 0x1e,0xe0,0xb9,0x3b,0x6d,0x91,0x3a,0x39,0xa5,0xd6,0x73,0x1f,0x27,0x4e,0x49,0x33, + 0x8b,0x35,0xbc,0xa0,0xcb,0x2a,0xf2,0xe0,0xa0,0x52,0x55,0xbe,0x35,0x9d,0x1a,0x16, + 0xe9,0x34,0x45,0x00,0xa4,0x88,0xb2,0x25,0x90,0xd9,0x22,0xc7,0x99,0x0c,0x11,0xaf, + 0x84,0xaa,0xa2,0x71,0xb0,0x25,0x32,0x0d,0xe4,0x7a,0x82,0xae,0xdc,0xcb,0xda,0x10, + 0x7d,0x5b,0x8e,0x60,0x2d,0xc2,0x9d,0x97,0x63,0xed,0x32,0x7d,0x44,0x64,0xc6,0xb1, + 0xb5,0x57,0x94,0xd3,0x40,0xb9,0xc5,0x5e,0xcb,0xf3,0x45,0x88,0x9b,0x03,0x2b,0x93, + 0x56,0xf4,0x3c,0x4a,0x7d,0x60,0xeb,0xcb,0xb3,0x99,0x14,0xb1,0xed,0xb5,0x10,0xf6, + 0x0a,0x43,0xba,0x55,0x18,0x52,0x11,0xb9,0x7e,0x2b,0xe2,0xbd,0x95,0x18,0x2f,0x6a, + 0xae,0x9a,0x81,0x77,0xe9,0xfc,0xb9,0x74,0xd5,0x6d,0x59,0x68,0x5a,0xb7,0x14,0xf2, + 0xad,0xf7,0xc8,0x0f,0x99,0x3a,0xca,0x7d,0x06,0xd0,0xbb,0x78,0x45,0x96,0x96,0x65, + 0x51,0xd0,0x58,0x19,0x91,0x73,0x13,0xf4,0x02,0x14,0xe4,0x0a,0x3e,0x92,0xa7,0x4b, + 0xd4,0x37,0xd5,0x18,0x68,0xb2,0x91,0x8b,0x10,0x5d,0xcc,0x80,0x16,0x67,0xe8,0x35, + 0x27,0xee,0x8c,0x0c,0x23,0xd8,0x0d,0x1c,0x88,0x6b,0x6b,0x6f,0xf0,0x5e,0x25,0xb9, + 0x7c,0x9b,0xa9,0xf6,0xf6,0x34,0xbf,0x1f,0x92,0xe3,0xa3,0x2d,0x6c,0x21,0x99,0x85, + 0xda,0x26,0xfa,0x04,0xce,0x3d,0xd0,0x80,0x36,0x39,0x0d,0x38,0x6e,0x11,0xdc,0xb7, + 0xb8,0xe6,0x9e,0x27,0x6c,0x81,0x32,0x84,0xfa,0xd9,0x12,0xf4,0x6c,0x8c,0xa4,0xc6, + 0xcd,0x21,0x83,0x86,0x95,0x18,0x48,0x15,0x4d,0x1b,0xe3,0xda,0x44,0xce,0x92,0x65, + 0x6e,0x9d,0x22,0xcc,0xbd,0x60,0xd0,0x82,0x14,0x60,0x32,0x5e,0xaa,0x58,0xdc,0xa7, + 0x09,0xe4,0xe9,0x58,0x37,0x15,0xef,0x05,0x25,0x0d,0x13,0xf7,0xd2,0x7c,0xcd,0xd5, + 0xb3,0xf5,0x82,0xd7,0x45,0xbe,0x14,0xba,0x8b,0x93,0x52,0xb1,0xa0,0xd6,0x4e,0x01, + 0xcc,0x72,0xc5,0x30,0x61,0x2e,0xd2,0x28,0x4c,0xa2,0x02,0xb8,0x6f,0xe5,0x2c,0xf3, + 0xe1,0xc9,0xf1,0x76,0x62,0x72,0xec,0xfa,0x81,0x58,0x98,0x17,0xad,0x5a,0x08,0x94, + 0x81,0x4f,0xd3,0x2b,0x2b,0x1f,0x8d,0x45,0x37,0x72,0xe6,0x1e,0x77,0x0c,0x80,0xfc, + 0x9a,0x4e,0x75,0x23,0xd0,0x3b,0xe0,0xa1,0x9f,0xe4,0x52,0x52,0x9c,0x50,0xb9,0x1e, + 0x34,0x5d,0xa9,0x25,0xf2,0xda,0x29,0x65,0x56,0x1b,0x46,0xd8,0x85,0x6e,0xa7,0xdd, + 0x57,0x6a,0x90,0x4b,0x7d,0x35,0x7d,0xb3,0x2c,0x9c,0x95,0x22,0xb4,0xaa,0xeb,0x15, + 0xb6,0x5a,0x8f,0xb6,0x74,0xd4,0x3e,0x0e,0x7b,0x8a,0xa1,0x2e,0x3c,0x1c,0xec,0x6d, + 0x6c,0xe4,0x10,0xca,0x49,0x84,0x7a,0x1f,0x9e,0xb8,0xa1,0xc7,0x5a,0xd0,0xad,0xe9, + 0xbd,0xe2,0x3a,0xf5,0x26,0x53,0x12,0x39,0xa5,0x45,0xd7,0xd5,0x38,0xb0,0xcc,0xec, + 0x63,0xe2,0x38,0xe2,0x82,0x9c,0x38,0xc6,0x30,0x1f,0x96,0xf9,0xfd,0x97,0x32,0x5a, + 0xc1,0x08,0xdb,0x0d,0xad,0x29,0xa4,0xac,0x32,0x5f,0x45,0xcc,0x95,0xd3,0x3a,0xde, + 0x3c,0x74,0xb4,0x49,0xe2,0x8d,0x07,0xfa,0xb6,0x7e,0xf4,0x89,0xfa,0x3f,0xdc,0x76, + 0x8e,0x36,0x7b,0xfa,0x7f,0x45,0x33,0xb2,0xd8,0x8d,0x44,0xc6,0x28,0x99,0x47,0x4e, + 0x0d,0x4f,0xb0,0x28,0xd0,0x8a,0x36,0x2a,0x6f,0xbc,0xfe,0xb3,0xc3,0x6d,0xe0,0xa2, + 0x7e,0x9c,0x1d,0xc1,0x13,0xfa,0x35,0xe3,0x5f,0x3c,0x97,0x3a,0x7a,0xf6,0xff,0x01, + 0x5f,0xd5,0xc9,0xd0,0xe7,0xef,0x00,0x00, }; diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index ee55af86..a1e29f9c 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -19,62 +19,23 @@ #endif #include "WebConfigHtml.h" +#include "helpers/WebConfigKeys.h" // Placeholder sent instead of stored secrets; POSTs carrying it are dropped // so an untouched password field never overwrites the stored value. static const char SECRET_SENTINEL[] = "********"; -// Keys the web UI may drive through the CLI `set` handlers. Everything else -// is rejected, so a crafted request can't reach arbitrary commands (`erase`, -// `password`, ...) through the batch. -static const char* const ALLOWED_SET_KEYS[] = { - // NodePrefs (radio / node) - "name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay", - "cad", "radio.rxgain", "radio.fem.rxgain", "repeat", "advert.interval", - "flood.advert.interval", - "flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect", - // MQTTPrefs (WiFi / MQTT / misc observer) - "wifi.ssid", "wifi.pwd", "wifi.powersave", - "mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw", - "mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.ntp", "mqtt.owner", "mqtt.email", - "timezone", "timezone.offset", "snmp", "snmp.community", -}; -static const char* const ALLOWED_SLOT_KEYS[] = { - "preset", "server", "port", "username", "password", "token", "topic", "audience", -}; - static bool isAllowedSetKey(const char* key, bool has_mqtt) { - if (!key) return false; - for (size_t i = 0; i < sizeof(ALLOWED_SET_KEYS) / sizeof(ALLOWED_SET_KEYS[0]); i++) { - if (strcmp(key, ALLOWED_SET_KEYS[i]) == 0) { - const bool mqtt_only = strncmp(key, "mqtt.", 5) == 0 - || strncmp(key, "timezone", 8) == 0 - || strncmp(key, "snmp", 4) == 0; - return has_mqtt || !mqtt_only; - } - } - // mqtt<1-6>. -#ifdef WITH_MQTT_BRIDGE - if (!has_mqtt) return false; - if (strlen(key) > 6 && strncmp(key, "mqtt", 4) == 0 - && key[4] >= '1' && key[4] <= ('0' + MAX_MQTT_SLOTS) - && key[5] == '.') { - for (size_t i = 0; i < sizeof(ALLOWED_SLOT_KEYS) / sizeof(ALLOWED_SLOT_KEYS[0]); i++) { - if (strcmp(&key[6], ALLOWED_SLOT_KEYS[i]) == 0) return true; - } - } -#else - (void)has_mqtt; -#endif - return false; + if (!key || !wcIsAllowedSetKey(key)) return false; + const bool mqtt_only = strncmp(key, "mqtt.", 5) == 0 + || strncmp(key, "mqtt", 4) == 0 + || strncmp(key, "timezone", 8) == 0 + || strncmp(key, "snmp", 4) == 0; + return has_mqtt || !mqtt_only; } static bool isSecretKey(const char* key) { - if (!key) return false; - if (strcmp(key, "wifi.pwd") == 0) return true; - if (strlen(key) > 6 && strncmp(key, "mqtt", 4) == 0 && key[5] == '.' - && (strcmp(&key[6], "password") == 0 || strcmp(&key[6], "token") == 0)) return true; - return false; + return key && wcIsSecretKey(key); } // Constant-time-ish comparison so login timing doesn't leak a prefix match. @@ -97,7 +58,9 @@ struct WCLock { }; WebConfigServer* WebConfigServer::_active = NULL; +AsyncWebServer* WebConfigServer::_host = NULL; static volatile bool webconfig_button_toggle_requested = false; +static portMUX_TYPE s_wc_route_mux = portMUX_INITIALIZER_UNLOCKED; WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool owns_wifi, const uint8_t* pub_key, const char* fw_ver, @@ -105,7 +68,6 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow : _cb(callbacks), _mqtt_prefs(mqtt_prefs), _owns_wifi(owns_wifi), _pub_key(pub_key), _fw_ver(fw_ver), _role(role), _board_name(board_name) { _mux = xSemaphoreCreateMutex(); - _active = this; #ifdef WITH_MQTT_BRIDGE MQTTPrefs* obs = static_cast(_mqtt_prefs); @@ -130,7 +92,7 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow } WebConfigServer::~WebConfigServer() { - if (_active == this) _active = NULL; + detachRoutes(); if (_mux) vSemaphoreDelete(_mux); } @@ -201,8 +163,9 @@ bool WebConfigServer::takeButtonToggleRequest() { bool WebConfigServer::isRebootPending() { WebConfigServer* w = _active; - return w != NULL && w->_reboot_at != 0 && w->_batch_reboot && - w->_batch_state == BATCH_DONE; + return w != NULL && WebConfigBatch::isConfigRebootPending( + w->_reboot_at, w->_batch_reboot, + toSpecState(w->_batch_state)); } bool WebConfigServer::getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len) { @@ -232,6 +195,10 @@ bool WebConfigServer::startSetupMode(char reply[]) { // the AP is up. STA stays unconnected - the bridge won't touch WiFi // while wifi_ssid is empty, and `start webconfig ap` requires it stopped. WiFi.mode(WIFI_AP_STA); + // Setup mode has no login. Drop any STA association so the open setup API is + // reachable only from the setup AP, not from the operator's LAN. + WiFi.setAutoReconnect(false); + WiFi.disconnect(false, true); snprintf(_ap_ssid, sizeof(_ap_ssid), "MeshCore-Setup-%02X%02X", _pub_key[0], _pub_key[1]); #ifdef WEBCONFIG_AP_PASSWORD bool ap_ok = WiFi.softAP(_ap_ssid, WEBCONFIG_AP_PASSWORD); @@ -249,9 +216,12 @@ bool WebConfigServer::startSetupMode(char reply[]) { _dns = new DNSServer(); _dns->start(53, "*", ip); // captive portal: every name resolves to us - if (!promote_lan) createServer(); _mode = MODE_SETUP; + if (!promote_lan) createServer(); _was_setup_ap = true; + NodeSnapshot node = {}; + _cb->getNodeSnapshot(node); + _initial_setup = _wifi_ssid[0] == 0 && node.admin_password[0] != 0; _last_activity = millis(); WiFi.scanNetworks(true); // pre-populate the SSID picker @@ -268,8 +238,8 @@ bool WebConfigServer::startLanMode(char reply[]) { strcpy(reply, "Err: WiFi not connected"); return false; } - createServer(); _mode = MODE_LAN; + createServer(); _last_activity = millis(); NodeSnapshot node = {}; @@ -306,36 +276,41 @@ bool WebConfigServer::startAutoMode(char reply[]) { } void WebConfigServer::createServer() { - _server = new AsyncWebServer(80); + if (_host == NULL) { + _host = new AsyncWebServer(80); + _server = _host; + registerRoutes(); + } else { + _server = _host; + } // iOS caches plain-HTTP GETs aggressively (keyed by URL, surviving even a // device reflash behind the same IP), which poisons /api/config/result and // friends with stale responses from earlier sessions. Forbid caching on // every response; the HTML is small enough to refetch per visit. - // DefaultHeaders is process-global and survives a stop/start cycle. Adding - // this on every start would retain duplicate header nodes indefinitely. static bool cache_header_added = false; if (!cache_header_added) { DefaultHeaders::Instance().addHeader("Cache-Control", "no-store"); cache_header_added = true; } - registerRoutes(); + attachRoutes(); _server->begin(); } void WebConfigServer::requestStop() { if (_mode == MODE_OFF && !_stopping) return; + detachRoutes(); if (_server) _server->end(); if (_dns) _dns->stop(); _mode = MODE_OFF; _stopping = true; - // Deleting an AsyncWebServer with live connections is a known crash source; - // give in-flight responses a grace period before freeing. - _delete_at = millis() + 2000; - if (_delete_at == 0) _delete_at = 1; + _stop_warn_at = WebConfigBatch::scheduleAt(millis(), STOP_WARN_MS); + _stop_warned = false; } void WebConfigServer::finalizeTeardown() { - delete _server; + // Async requests keep a pointer to their server until disconnect. Retain the + // listener and route table for the firmware lifetime; only detach and reclaim + // the per-session state. _server = NULL; delete _dns; _dns = NULL; @@ -355,8 +330,10 @@ void WebConfigServer::finalizeTeardown() { WiFi.disconnect(true); WiFi.mode(WIFI_OFF); } + _initial_setup = false; _stopping = false; - _delete_at = 0; + _stop_warn_at = 0; + _stop_warned = false; _connect_deadline = 0; _reboot_at = 0; _batch_state = BATCH_IDLE; @@ -369,15 +346,27 @@ void WebConfigServer::finalizeTeardown() { void WebConfigServer::tick(uint32_t now) { if (_stopping) { - if (_delete_at && (int32_t)(now - _delete_at) >= 0) finalizeTeardown(); + uint32_t refs = handlerRefCount(); + switch (WebConfigBatch::stopStep(refs, _stop_warned, _stop_warn_at, now)) { + case WebConfigBatch::StopAction::Finalize: + finalizeTeardown(); + break; + case WebConfigBatch::StopAction::Warn: + _stop_warned = true; + Serial.printf("WC: stop waiting for %lu handler(s); retaining session safely\n", + (unsigned long)refs); + break; + case WebConfigBatch::StopAction::Wait: + break; + } return; } if (_mode == MODE_OFF) return; if (_mode == MODE_CONNECTING) { if (WiFi.status() == WL_CONNECTED) { - createServer(); _mode = MODE_LAN; + createServer(); _connect_deadline = 0; _last_activity = now; Serial.printf("WebConfig ready: http://%s/\n", WiFi.localIP().toString().c_str()); @@ -397,7 +386,7 @@ void WebConfigServer::tick(uint32_t now) { if (_batch_state == BATCH_PENDING) drainBatch(now); - if (_reboot_at && (int32_t)(now - _reboot_at) >= 0) { + if (WebConfigBatch::rebootDue(_reboot_at, now)) { Serial.printf("WC: rebooting now (%s)\n", _batch_reboot_armed ? "confirmed" : "fallback"); _cb->rebootNow(); // does not return } @@ -432,7 +421,8 @@ void WebConfigServer::drainBatch(uint32_t now) { if (_batch_next == 0) { WCLock prefs_lock(_mux); _cb->onConfigBatchStart(); - } else if (_batch_next < _batch_count && (int32_t)(now - _batch_last_cmd) < 25) { + } else if (WebConfigBatch::drainMustWait(_batch_next, _batch_count, + now, _batch_last_cmd)) { return; // let the WiFi task breathe between flash writes } if (_batch_next < _batch_count) { @@ -440,7 +430,9 @@ void WebConfigServer::drainBatch(uint32_t now) { BatchEntry& e = _batch[_batch_next++]; e.reply[0] = 0; uint32_t t0 = millis(); - const char* value = e.cmd + strlen("set ") + strlen(e.key) + 1; + const bool admin_pwd = wcIsAdminPasswordKey(e.key); + const char* value = admin_pwd ? e.cmd + strlen("password ") + : e.cmd + strlen("set ") + strlen(e.key) + 1; if ((_mqtt_prefs == NULL || !_owns_wifi) && strcmp(e.key, "wifi.ssid") == 0) { if (!value[0] || strlen(value) >= sizeof(_wifi_ssid)) { strcpy(e.reply, "Error: WiFi SSID must be 1-31 characters"); @@ -470,6 +462,9 @@ void WebConfigServer::drainBatch(uint32_t now) { } } else { _cb->execCommand(e.cmd, e.reply); + // The CLI password command echoes the new secret. Never return it to a + // browser client, especially over the open setup AP. + if (admin_pwd) strcpy(e.reply, "OK"); // Keep the response snapshot current after MQTT/companion CLI handlers // persist a WiFi field. The browser can then soft-reload without a stale // value even before the requested reboot happens. @@ -484,14 +479,14 @@ void WebConfigServer::drainBatch(uint32_t now) { } } if (e.reply[0] == 0) strcpy(e.reply, "OK"); - // A wizard save must not reboot away from a rejected setting before the - // operator can correct it. A reboot-only batch (zero commands) is still - // allowed; otherwise every command must report success. - if (_batch_reboot && strncmp(e.reply, "OK", 2) != 0) _batch_reboot = false; + _batch_all_ok = WebConfigBatch::nextAllOk( + _batch_all_ok, strncmp(e.reply, "OK", 2) == 0); _batch_last_cmd = millis(); Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count, e.key, (unsigned long)(_batch_last_cmd - t0)); - if (_batch_next < _batch_count) return; // more commands next tick + if (!WebConfigBatch::drainFinished(_batch_next, _batch_count)) { + return; // more commands next tick + } } if (_standalone_wifi_dirty) { _standalone_wifi_dirty = false; @@ -504,7 +499,7 @@ void WebConfigServer::drainBatch(uint32_t now) { break; } } - _batch_reboot = false; + _batch_all_ok = false; } } { @@ -513,14 +508,12 @@ void WebConfigServer::drainBatch(uint32_t now) { } WCLock lock(_mux); _batch_state = BATCH_DONE; - if (_batch_reboot) { - // Fallback only: the real 3 s reboot timer is armed when the client reads - // /api/config/result (handleConfigResult), so the browser gets its - // confirmation before the AP/WiFi drops. This covers a client that - // disconnected and never polls - generous enough for a phone that got - // bounced off the AP mid-save to rejoin and fetch its confirmation. - _reboot_at = now + 30000; - if (_reboot_at == 0) _reboot_at = 1; + const uint32_t reboot_at = + WebConfigBatch::finishRebootAt(_batch_reboot, _batch_all_ok, now); + if (reboot_at != 0) { + // Fallback only: a successful result read replaces this with the shorter + // confirmation delay. Failed batches remain available for correction. + _reboot_at = reboot_at; } } @@ -535,26 +528,94 @@ static void wcLogReq(AsyncWebServerRequest* r) { Serial.printf("WC: http %s %s\n", r->methodToString(), r->url().c_str()); } +void WebConfigServer::attachRoutes() { + portENTER_CRITICAL(&s_wc_route_mux); + _active = this; + portEXIT_CRITICAL(&s_wc_route_mux); +} + +void WebConfigServer::detachRoutes() { + portENTER_CRITICAL(&s_wc_route_mux); + if (_active == this) _active = NULL; + portEXIT_CRITICAL(&s_wc_route_mux); +} + +uint32_t WebConfigServer::handlerRefCount() const { + portENTER_CRITICAL(&s_wc_route_mux); + uint32_t refs = _handler_refs; + portEXIT_CRITICAL(&s_wc_route_mux); + return refs; +} + +void WebConfigServer::dispatchRequest(AsyncWebServerRequest* req, + RequestHandler handler) { + wcLogReq(req); + WebConfigServer* target = NULL; + portENTER_CRITICAL(&s_wc_route_mux); + if (_active != NULL) { + target = _active; + target->_handler_refs++; + } + portEXIT_CRITICAL(&s_wc_route_mux); + + if (target == NULL) { + req->send(503, "application/json", "{\"error\":\"webconfig stopped\"}"); + return; + } + + (target->*handler)(req); + + portENTER_CRITICAL(&s_wc_route_mux); + if (target->_handler_refs > 0) target->_handler_refs--; + portEXIT_CRITICAL(&s_wc_route_mux); +} + void WebConfigServer::registerRoutes() { - _server->on("/", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleRoot(r); }); - _server->on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleStatus(r); }); - _server->on("/api/presets", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handlePresets(r); }); - _server->on("/api/login", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleLogin(r); }, + _server->on("/", HTTP_GET, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleRoot); + }); + _server->on("/api/status", HTTP_GET, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleStatus); + }); + _server->on("/api/presets", HTTP_GET, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handlePresets); + }); + _server->on("/api/login", HTTP_POST, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleLogin); + }, NULL, collectBody); - _server->on("/api/logout", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleLogout(r); }); + _server->on("/api/logout", HTTP_POST, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleLogout); + }); // NB: plain-string routes match sub-paths too ("/api/config" matches // "/api/config/result") and handlers run in registration order, so the more // specific route MUST be registered first or it never fires. This was why // save confirmations were lost: result polls were answered with config JSON. - _server->on("/api/config/result", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigResult(r); }); - _server->on("/api/config", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigGet(r); }); - _server->on("/api/config", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigPost(r); }, + _server->on("/api/config/result", HTTP_GET, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleConfigResult); + }); + _server->on("/api/config", HTTP_GET, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleConfigGet); + }); + _server->on("/api/config", HTTP_POST, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleConfigPost); + }, NULL, collectBody); - _server->on("/api/stats", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleStats(r); }); - _server->on("/api/scan", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleScan(r); }); - _server->on("/api/reboot", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleReboot(r); }); - _server->on("/api/portal/exit", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handlePortalExit(r); }); - _server->onNotFound([this](AsyncWebServerRequest* r) { wcLogReq(r); handleNotFound(r); }); + _server->on("/api/stats", HTTP_GET, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleStats); + }); + _server->on("/api/scan", HTTP_GET, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleScan); + }); + _server->on("/api/reboot", HTTP_POST, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleReboot); + }); + _server->on("/api/portal/exit", HTTP_POST, [](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handlePortalExit); + }); + _server->onNotFound([](AsyncWebServerRequest* r) { + dispatchRequest(r, &WebConfigServer::handleNotFound); + }); } // Accumulate a small JSON body into request->_tempObject (freed automatically @@ -627,6 +688,8 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) { doc["mode"] = (_mode == MODE_SETUP) ? "setup" : "lan"; doc["auth"] = authed; doc["needs_setup"] = (_wifi_ssid[0] == 0); + doc["needs_password"] = _initial_setup; + doc["password_supported"] = node.admin_password[0] != 0; doc["name"] = (const char*)node.name; char node_id[17]; for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]); @@ -638,9 +701,11 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) { #ifdef WITH_MQTT_BRIDGE doc["runtime_slots"] = has_mqtt ? RUNTIME_MQTT_SLOTS : 0; doc["max_slots"] = has_mqtt ? MAX_MQTT_SLOTS : 0; + doc["active_slots"] = has_mqtt ? MQTTBridge::getMaxActiveSlots() : 0; #else doc["runtime_slots"] = 0; doc["max_slots"] = 0; + doc["active_slots"] = 0; #endif doc["mqtt"] = has_mqtt; doc["capabilities"] = node.capabilities; @@ -763,6 +828,8 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) { : obs->mqtt_tx_enabled == 1 ? "on" : "off"; mqtt["rx"] = (bool)obs->mqtt_rx_enabled; mqtt["interval"] = obs->mqtt_status_interval / 60000; + mqtt["neighbors"] = (bool)obs->mqtt_neighbors_enabled; + mqtt["neighbors_interval"] = obs->mqtt_neighbors_interval / 3600000UL; mqtt["timezone"] = (const char*)obs->timezone_string; mqtt["timezone_offset"] = obs->timezone_offset; mqtt["ntp"] = (const char*)obs->mqtt_ntp_server; @@ -806,13 +873,42 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { return; } bool reboot_after = doc["reboot"] | false; + const char* reqid = doc["reqid"] | ""; + if (!wcIsValidReqId(reqid)) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } JsonObject set = doc["set"]; WCLock lock(_mux); - // A DONE batch stays readable until the next POST claims the slot, so a - // client that lost the result response can re-poll instead of failing. - if (_batch_state == BATCH_PENDING) { - req->send(409, "application/json", "{\"error\":\"busy\"}"); + const WebConfigBatch::State bstate = toSpecState(_batch_state); + const bool reqid_matches = strcmp(reqid, _batch_reqid) == 0; + const WebConfigBatch::PostOutcome pre = + WebConfigBatch::classifyPost(bstate, reqid_matches, 1, reboot_after); + if (pre == WebConfigBatch::PostOutcome::Replay) { + StaticJsonDocument<96> ack; + ack["state"] = WebConfigBatch::replayStateName(bstate); + ack["count"] = _batch_count; + ack["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(ack, out); + req->send(202, "application/json", out); + return; + } + if (pre == WebConfigBatch::PostOutcome::Busy) { + StaticJsonDocument<96> busy; + busy["error"] = "busy"; + busy["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(busy, out); + req->send(409, "application/json", out); + return; + } + + if (_mode == MODE_SETUP && _initial_setup && !set.containsKey("password") + && (reboot_after || set.containsKey("wifi.ssid"))) { + req->send(400, "application/json", + "{\"error\":\"admin password required for initial setup\"}"); return; } @@ -820,10 +916,22 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { for (JsonPair kv : set) { const char* key = kv.key().c_str(); const char* val = kv.value().as(); - if (!val || !isAllowedSetKey(key, _mqtt_prefs != NULL)) { - char err[96]; - snprintf(err, sizeof(err), "{\"error\":\"bad key\",\"key\":\"%.32s\"}", key); - req->send(400, "application/json", err); + const bool admin_pwd = wcIsAdminPasswordKey(key); + if (!val || (!isAllowedSetKey(key, _mqtt_prefs != NULL) && !admin_pwd)) { + char safe_key[33]; + strncpy(safe_key, key, sizeof(safe_key) - 1); + safe_key[sizeof(safe_key) - 1] = 0; + StaticJsonDocument<128> err; + err["error"] = "bad key"; + err["key"] = safe_key; + String out; + serializeJson(err, out); + req->send(400, "application/json", out); + return; + } + if (admin_pwd && !wcIsValidAdminPassword(val)) { + req->send(400, "application/json", + "{\"error\":\"admin password must be 1-15 characters with no line breaks\"}"); return; } if (isSecretKey(key) && strcmp(val, SECRET_SENTINEL) == 0) continue; // unchanged @@ -834,9 +942,10 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { BatchEntry& e = _batch[count]; strncpy(e.key, key, sizeof(e.key) - 1); e.key[sizeof(e.key) - 1] = 0; - // Build "set ", stripping CR/LF so a value can't smuggle in - // a second command. - int pos = snprintf(e.cmd, sizeof(e.cmd), "set %s ", key); + // Build the allowlisted CLI command, stripping CR/LF so a value cannot + // smuggle in a second command. Admin password uses the top-level command. + int pos = admin_pwd ? snprintf(e.cmd, sizeof(e.cmd), "password ") + : snprintf(e.cmd, sizeof(e.cmd), "set %s ", key); for (const char* p = val; *p && pos < (int)sizeof(e.cmd) - 1; p++) { if (*p == '\r' || *p == '\n') continue; e.cmd[pos++] = *p; @@ -844,7 +953,8 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { e.cmd[pos] = 0; count++; } - if (count == 0 && !reboot_after) { + if (WebConfigBatch::classifyPost(bstate, reqid_matches, count, reboot_after) == + WebConfigBatch::PostOutcome::NoChanges) { req->send(400, "application/json", "{\"error\":\"no changes\"}"); return; } @@ -852,6 +962,9 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { _batch_next = 0; _batch_reboot = reboot_after; _batch_reboot_armed = false; + _batch_all_ok = true; + strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1); + _batch_reqid[sizeof(_batch_reqid) - 1] = 0; _standalone_wifi_dirty = false; _batch_state = BATCH_PENDING; // tick() picks it up on the loop task uint32_t du = millis() + 60000; @@ -859,33 +972,67 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { _diag_until = du; Serial.printf("WC: config POST accepted, %d cmds, reboot=%d\n", count, (int)reboot_after); - char msg[64]; - snprintf(msg, sizeof(msg), "{\"state\":\"pending\",\"count\":%d}", count); - req->send(202, "application/json", msg); + StaticJsonDocument<96> ack; + ack["state"] = "pending"; + ack["count"] = count; + ack["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(ack, out); + req->send(202, "application/json", out); } void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) { if (_mode == MODE_OFF) { Serial.println("WC: result read -> 503 (mode off)"); req->send(503); return; } if (!checkAuth(req)) { Serial.println("WC: result read -> 401"); req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + if (!req->hasParam("reqid")) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } + String requested_reqid = req->getParam("reqid")->value(); + if (!wcIsValidReqId(requested_reqid.c_str())) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } // Entry print BEFORE the lock (racy state read is fine for diag): if this // fires but no branch print follows, the handler is blocked on _mux. Serial.printf("WC: result entry mode=%d state=%d\n", (int)_mode, (int)_batch_state); WCLock lock(_mux); - if (_batch_state == BATCH_IDLE) { + const WebConfigBatch::ResultOutcome outcome = WebConfigBatch::classifyResult( + toSpecState(_batch_state), + strcmp(requested_reqid.c_str(), _batch_reqid) == 0); + if (outcome == WebConfigBatch::ResultOutcome::Idle) { Serial.println("WC: result read -> idle"); - req->send(200, "application/json", "{\"state\":\"idle\"}"); + StaticJsonDocument<64> idle; + idle["state"] = "idle"; + idle["reqid"] = requested_reqid; + String out; + serializeJson(idle, out); + req->send(200, "application/json", out); return; } - if (_batch_state == BATCH_PENDING) { - req->send(200, "application/json", "{\"state\":\"pending\"}"); + if (outcome == WebConfigBatch::ResultOutcome::Unknown) { + req->send(404, "application/json", "{\"error\":\"unknown request\"}"); return; } - Serial.printf("WC: result read -> done (reboot=%d armed=%d)\n", - (int)_batch_reboot, (int)_batch_reboot_armed); + if (outcome == WebConfigBatch::ResultOutcome::Pending) { + StaticJsonDocument<96> pending; + pending["state"] = "pending"; + pending["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(pending, out); + req->send(200, "application/json", out); + return; + } + Serial.printf("WC: result read -> done (reboot=%d armed=%d all_ok=%d)\n", + (int)_batch_reboot, (int)_batch_reboot_armed, + (int)_batch_all_ok); DynamicJsonDocument doc(6144); doc["state"] = "done"; - doc["reboot"] = _batch_reboot; + doc["reboot"] = + WebConfigBatch::doneReportsReboot(_batch_reboot, _batch_all_ok); + doc["all_ok"] = _batch_all_ok; + doc["reqid"] = (const char*)_batch_reqid; JsonArray results = doc.createNestedArray("results"); for (int i = 0; i < _batch_count; i++) { JsonObject r = results.createNestedObject(); @@ -893,13 +1040,14 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) { r["reply"] = (const char*)_batch[i].reply; } // State stays DONE (re-readable) until the next POST claims the slot. - if (_batch_reboot && !_batch_reboot_armed) { + if (WebConfigBatch::shouldArmConfirmReboot( + toSpecState(_batch_state), _batch_reboot, + _batch_all_ok, _batch_reboot_armed)) { // Confirmation delivered - reboot 3 s from now (replaces the 30 s // drain-time fallback) so the UI can show its countdown first. Armed // once; re-reads must not keep pushing the deadline out. _batch_reboot_armed = true; - _reboot_at = millis() + 3000; - if (_reboot_at == 0) _reboot_at = 1; + _reboot_at = WebConfigBatch::confirmRebootAt(millis()); } AsyncResponseStream* res = req->beginResponseStream("application/json"); @@ -971,7 +1119,11 @@ void WebConfigServer::handlePresets(AsyncWebServerRequest* req) { // What the UI must collect for this preset to connect if (p.topic_style == MQTT_TOPIC_MESHRANK) { o["needs"] = "token"; - } else if (mqttPresetNeedsSlotCredentials(&p)) { + } else if (mqttPresetNeedsSlotUsername(&p) && mqttPresetNeedsSlotPassword(&p)) { + o["needs"] = "userpass"; + } else if (mqttPresetNeedsSlotPassword(&p)) { + o["needs"] = "password"; + } else if (mqttPresetNeedsSlotUsername(&p)) { o["needs"] = "userpass"; } else { o["needs"] = "none"; diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 1569c5e4..68482a5d 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -26,6 +26,7 @@ #include #include #include +#include class AsyncWebServer; class AsyncWebServerRequest; @@ -140,7 +141,7 @@ public: bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal bool startLanMode(char reply[]); // bind to existing STA connection bool startAutoMode(char reply[]); // use saved WiFi, or setup AP when absent - void requestStop(); // stop listening now, free after grace period + void requestStop(); // stop listening and detach this session void tick(uint32_t now); // call every loop iteration Mode mode() const { return _mode; } @@ -148,11 +149,19 @@ public: bool isStopping() const { return _stopping; } private: - static const int MAX_BATCH = 24; + static const int MAX_BATCH = WebConfigBatch::kMaxBatch; static const size_t MAX_BODY = 4096; + static const uint32_t STOP_WARN_MS = WebConfigBatch::kStopWarnMs; enum BatchState : uint8_t { BATCH_IDLE = 0, BATCH_PENDING, BATCH_DONE }; + static WebConfigBatch::State toSpecState(BatchState state) { + switch (state) { + case BATCH_PENDING: return WebConfigBatch::State::Pending; + case BATCH_DONE: return WebConfigBatch::State::Done; + default: return WebConfigBatch::State::Idle; + } + } struct BatchEntry { - char key[24]; // allowlisted `set` key (echoed back to the UI) + char key[24]; // allowlisted config key (echoed back to the UI) char cmd[160]; // full CLI command (may contain secrets - never echoed) char reply[160]; }; @@ -171,14 +180,18 @@ private: Mode _mode = MODE_OFF; bool _stopping = false; bool _was_setup_ap = false; + bool _initial_setup = false; uint32_t _connect_deadline = 0; char _wifi_ssid[32] = {0}; char _wifi_password[64] = {0}; uint8_t _wifi_power_save = 1; char _ap_ssid[33] = {0}; - // Most-recently-created instance, for the display's getSetupInfo() poll. + // Currently attached session, also used by the display's setup-info poll. static WebConfigServer* _active; + // Process-lifetime listener and route table. Async requests retain their + // server pointer until disconnect, so this object is not deleted on stop. + static AsyncWebServer* _host; // Command batch: filled by async_tcp under _mux, drained by tick(). volatile BatchState _batch_state = BATCH_IDLE; @@ -187,6 +200,8 @@ private: uint32_t _batch_last_cmd = 0; bool _batch_reboot = false; bool _batch_reboot_armed = false; + bool _batch_all_ok = true; + char _batch_reqid[24] = {0}; bool _standalone_wifi_dirty = false; BatchEntry _batch[MAX_BATCH]; @@ -207,14 +222,21 @@ private: uint32_t _diag_last = 0; volatile uint32_t _last_activity = 0; - uint32_t _reboot_at = 0; // 0 = none scheduled - uint32_t _delete_at = 0; // deferred teardown deadline + uint32_t _reboot_at = 0; // 0 = none scheduled + uint32_t _stop_warn_at = 0; + bool _stop_warned = false; + uint32_t _handler_refs = 0; volatile uint32_t _stats_wanted_until = 0; uint32_t _stats_built_at = 0; char _stats_json[1024] = {0}; void createServer(); void registerRoutes(); + typedef void (WebConfigServer::*RequestHandler)(AsyncWebServerRequest*); + static void dispatchRequest(AsyncWebServerRequest* req, RequestHandler handler); + void attachRoutes(); + void detachRoutes(); + uint32_t handlerRefCount() const; void drainBatch(uint32_t now); void finalizeTeardown(); bool checkAuth(AsyncWebServerRequest* req); diff --git a/src/helpers/sim/SimRadio.h b/src/helpers/sim/SimRadio.h new file mode 100644 index 00000000..ef5a01ec --- /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/STM32Board.h b/src/helpers/stm32/STM32Board.h index 06bc768f..cc33dfe5 100644 --- a/src/helpers/stm32/STM32Board.h +++ b/src/helpers/stm32/STM32Board.h @@ -41,5 +41,5 @@ public: } #endif - bool startOTAUpdate(const char* id, char reply[]) override { return false; }; + bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override { return false; }; }; \ No newline at end of file diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index 4cf25c10..632299a9 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -103,7 +103,11 @@ static TFT_eSPI lcd = TFT_eSPI(160, 80); static uint32_t curr_color; -#if defined(HELTEC_LORA_V3) || defined(HELTEC_TRACKER_V2) +// HELTEC_TRACKER_V1_1 added to upstream's HSPI guard (d30d8ed7): that board uses +// the same ST7735 panel and also needs a dedicated HSPI instance. Without it the +// #else branch references SPI1, which is not instantiated on ESP32, so every +// heltec_tracker_v1_1 env fails to compile. +#if defined(HELTEC_LORA_V3) || defined(HELTEC_TRACKER_V2) || defined(HELTEC_TRACKER_V1_1) static SPIClass tft_spi(HSPI); #define _spi (&tft_spi) #else diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..1a33fadf --- /dev/null +++ b/test/README.md @@ -0,0 +1,55 @@ +# Host unit tests + +Fast, hardware-free unit tests for the fork's pure logic, run on the host with +GoogleTest via PlatformIO's `native` environment. They cover the extractable +observer/WebConfig logic (validation, preset table, topic templates, key +parsing) -- the parts that don't depend on the ESP32, radio, or network stack. +Integration behavior (AsyncTCP transport, WiFi/MQTT, SoftAP) is exercised +separately; see "Local testing without hardware" in `MQTT_IMPLEMENTATION.md`. + +## Running + +```sh +pio test -e native # all suites +pio test -e native -f test_webconfig_keys # a single suite +``` + +A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions +passed). PlatformIO's "0 test cases" line is just its Unity-style counter and +does not reflect the GoogleTest count -- run the built binary directly +(`.pio/build/native/program`) to see the per-assertion breakdown. + +## Suites + +| Suite | Source under test | Covers | +|-------|-------------------|--------| +| `test_mqtt_presets` | `src/helpers/MQTTPresets.h` | preset lookup; table integrity (unique names, non-empty URLs, JWT-audience invariant, names fit the slot buffer); `mqttPresetNeedsSlotCredentials`; slot-count constants | +| `test_observer_validation` | `src/helpers/MQTTObserverValidation.h` | IATA (exactly 3 alphanumerics), owner key (64 hex), NTP hostname, and the buffer-fit check behind the #17 length validation -- including boundaries and nulls | +| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, admin-password classification/validation, slot-index bounds, and the short-key out-of-bounds guard (attacker-supplied keys) | +| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz | +| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank packets-only behavior; required identifiers; invalid inputs/slots; exact buffer boundaries | +| `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover | +| `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover | +| `test_mqtt_runtime_buffer_lifecycle` | `src/helpers/MQTTRuntimeBufferLifecycle.h` | idempotent allocation/release; partial-allocation degradation; retry of only missing buffers | +| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation | +| `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation | +| `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads | +| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) | + +## Conventions (and how to add a suite) + +- Each `test/test_/` 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/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 00000000..496a1c43 --- /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 00000000..aeda454a --- /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_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 00000000..ed282415 --- /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 00000000..20b5781d --- /dev/null +++ b/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp @@ -0,0 +1,316 @@ +#include + +#include +#include +#include +#include +#include + +#include "helpers/MQTTPayloadBuilder.h" + +namespace { + +constexpr const char* kTimestamp = "2026-07-18T12:34:56.123456+00:00"; + +static int buildMinimalStatus(JsonDocument& scratch, char* buffer, size_t buffer_size) { + return MQTTPayloadBuilder::buildStatusMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", "Heltec V3", "v1.16.0", + "915.000000,62.5,7,5", "MeshCore", "online", kTimestamp, + buffer, buffer_size); +} + +static int buildRepresentativePacket(JsonDocument& scratch, const char* direction, + float score, const uint8_t* path, int path_hops, + int path_hash_size, const char* raw, + char* buffer, size_t buffer_size) { + return MQTTPayloadBuilder::buildPacketMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, direction, + "12:34:56", "18/07/2026", 42, 4, "D", 20, raw, + 10.26f, -87, score, "89ABCDEF01234567", path, path_hops, + path_hash_size, 64, buffer, buffer_size); +} + +TEST(MQTTPayloadBuilder, MinimalStatusHasExactRequiredContract) { + JsonDocument scratch; + char buffer[768]; + int len = buildMinimalStatus(scratch, buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + EXPECT_EQ(static_cast(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)); + + 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()); + + 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, NeighborsMessageHandlesEmptyTableAndNullScopes) { + JsonDocument scratch; + char buffer[256]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "node", "id", kTimestamp, nullptr, nullptr, 0, + buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("", parsed["self"]["scopes"].as()); + 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].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); + // Kept entries are the head of the input, in order. + EXPECT_STREQ(keys[0], arr[0]["pubkey"].as()); +} + +} // 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 00000000..67880a82 --- /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 00000000..0068e1a5 --- /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 00000000..6d1fba4b --- /dev/null +++ b/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp @@ -0,0 +1,399 @@ +#include + +#include +#include + +#define WITH_MQTT_BRIDGE 1 +#include "helpers/MQTTPrefsCodec.h" + +namespace Codec = MQTTPrefsCodec; + +namespace { + +MQTTPrefs defaults() { + MQTTPrefs prefs = {}; + prefs.mqtt_status_enabled = 1; + prefs.mqtt_packets_enabled = 1; + prefs.mqtt_tx_enabled = 2; + prefs.mqtt_rx_enabled = 1; + prefs.mqtt_status_interval = 300000; + prefs.wifi_power_save = 1; + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { + strncpy(prefs.mqtt_slot_preset[i], "none", sizeof(prefs.mqtt_slot_preset[i]) - 1); + } + strncpy(prefs.snmp_community, "public", sizeof(prefs.snmp_community) - 1); + prefs.radio_watchdog_minutes = 5; + prefs.alert_wifi_minutes = 30; + prefs.alert_mqtt_minutes = 240; + prefs.alert_min_interval_min = 60; + return prefs; +} + +void writeText(std::vector* 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); + } + } +} + +TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) { + MQTTPrefs source = defaults(); + strncpy(source.mqtt_origin, "current-node", sizeof(source.mqtt_origin) - 1); + strncpy(source.mqtt_slot_password[2], "preserve-me", sizeof(source.mqtt_slot_password[2]) - 1); + strncpy(source.alert_region, "PNW", sizeof(source.alert_region) - 1); + source.mqtt_neighbors_enabled = 1; + source.mqtt_neighbors_interval = MQTT_NEIGHBORS_MAX_INTERVAL_MS; + std::vector bytes(Codec::kEncodedSize); + ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size())); + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + ASSERT_FALSE(plan.preserve_file); + ASSERT_TRUE(plan.observer_fields_present); + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source))); +} + +TEST(MQTTPrefsCodec, CompatibleShortV1PayloadPreservesDefaultsBeyondObserverBoundary) { + MQTTPrefs source = defaults(); + strncpy(source.mqtt_origin, "short-v1-node", sizeof(source.mqtt_origin) - 1); + strncpy(source.mqtt_ntp_server, "ntp.short.example", sizeof(source.mqtt_ntp_server) - 1); + source.snmp_enabled = 1; + strncpy(source.snmp_community, "do-not-copy", sizeof(source.snmp_community) - 1); + source.radio_watchdog_minutes = 99; + + std::vector 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); +} + +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; + } +} + +TEST(MQTTPrefsCodec, NewerAndSameVersionExtendedPayloadsAreHeld) { + std::vector newer(sizeof(MQTTPrefsHeader), 0); + writeHeader(&newer, MQTT_PREFS_VERSION + 1, 0); + Codec::DecodePlan plan = classify(newer); + EXPECT_EQ(Codec::Source::UnsupportedVersion, plan.source); + EXPECT_TRUE(plan.preserve_file); + + std::vector extended(sizeof(MQTTPrefsHeader) + Codec::kV1BaselinePayloadSize + 1, 0); + writeHeader(&extended, MQTT_PREFS_VERSION, + static_cast(Codec::kV1BaselinePayloadSize + 1)); + plan = classify(extended); + EXPECT_EQ(Codec::Source::UnsupportedVersion, plan.source); + EXPECT_TRUE(plan.preserve_file); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} 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 00000000..1750696b --- /dev/null +++ b/test/test_mqtt_presets/test_mqtt_presets.cpp @@ -0,0 +1,170 @@ +// Host tests for the MQTT observer preset table and lookup helpers +// (src/helpers/MQTTPresets.h). Pure logic -- no ESP/radio dependencies. +// +// The preset table and lookup functions are compiled only for observer builds, +// so opt into that feature flag before including the header (the definitions are +// pure C++/data with no ESP dependencies). +#define WITH_MQTT_BRIDGE 1 +#define MQTT_PRESETS_IMPLEMENTATION +#define PROGMEM // host build: the CA-cert strings in MQTTPresets.h are PROGMEM-qualified +#include +#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 00000000..4b3726af --- /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 00000000..959a501d --- /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 00000000..09e63669 --- /dev/null +++ b/test/test_mqtt_topic_router/test_mqtt_topic_router.cpp @@ -0,0 +1,198 @@ +// Host contract tests for the complete MQTT publication-topic routing policy. +#define WITH_MQTT_BRIDGE 1 +#define MQTT_PRESETS_IMPLEMENTATION +#define PROGMEM + +#include +#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; + } + } +} + +TEST(MQTTTopicRouter, MeshRankContractIsPacketsOnly) { + const MQTTPresetDef* preset = findMQTTPreset("meshrank"); + ASSERT_NE(nullptr, preset); + ASSERT_EQ(MQTT_TOPIC_MESHRANK, preset->topic_style); + + char topic[128]; + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_STATUS, + nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + ASSERT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS, + nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("meshrank/uplink/account-token/0123456789ABCDEF/packets", topic); + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_RAW, + nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_NEIGHBORS, + nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("", topic); +} + +TEST(MQTTTopicRouter, MeshCoreRequiresUsableIataAndDevice) { + char topic[64]; + const char* invalid_iatas[] = {nullptr, "", "XX", "XXXX", "X/X", "XXX"}; + for (const char* iata : invalid_iatas) { + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, iata, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + } + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, nullptr, TOKEN, topic, sizeof(topic))); + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, "", TOKEN, topic, sizeof(topic))); +} + +TEST(MQTTTopicRouter, MeshRankRequiresTokenAndDeviceButNotIata) { + char topic[128]; + const char* missing_tokens[] = {nullptr, ""}; + for (const char* token : missing_tokens) { + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS, + nullptr, nullptr, DEVICE, token, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + } + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS, + nullptr, nullptr, nullptr, TOKEN, topic, sizeof(topic))); + EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHRANK, MQTT_PUBLICATION_PACKETS, + nullptr, nullptr, DEVICE, TOKEN, topic, sizeof(topic))); +} + +TEST(MQTTTopicRouter, CustomTemplateExpandsEveryType) { + for (const TypeCase& type : kTypes) { + char topic[128]; + ASSERT_TRUE(mqttBuildPublicationTopic( + MQTT_ROUTE_CUSTOM, type.type, "custom/{iata}/{token}/{device}/{type}", + IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_EQ(std::string("custom/DEN/account-token/0123456789ABCDEF/") + type.name, topic); + } +} + +TEST(MQTTTopicRouter, CustomLiteralDoesNotRequireIataTokenOrDevice) { + char topic[32]; + ASSERT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_RAW, + "private/raw", nullptr, nullptr, nullptr, + topic, sizeof(topic))); + EXPECT_STREQ("private/raw", topic); +} + +TEST(MQTTTopicRouter, EmptyCustomTemplateFailsRatherThanFallingBackImplicitly) { + char topic[64]; + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_STATUS, + "", IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + + // MQTTBridge selects the MeshCore style explicitly for a custom slot whose + // template is empty; make that default contract visible here. + EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("meshcore/DEN/0123456789ABCDEF/status", topic); +} + +TEST(MQTTTopicRouter, FormattedTopicsRequireRoomForTerminator) { + const char* expected = "meshcore/DEN/0123456789ABCDEF/status"; + const size_t exact_size = strlen(expected) + 1; + char exact[64]; + ASSERT_LE(exact_size, sizeof(exact)); + EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, DEVICE, TOKEN, exact, exact_size)); + EXPECT_STREQ(expected, exact); + + char short_buf[64]; + memset(short_buf, 0x7f, sizeof(short_buf)); + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, MQTT_PUBLICATION_STATUS, + nullptr, IATA, DEVICE, TOKEN, + short_buf, exact_size - 1)); + EXPECT_EQ('\0', short_buf[exact_size - 2]); +} + +TEST(MQTTTopicRouter, CustomTopicHonorsExactBoundary) { + const char* expected = "custom/DEN/raw"; + char exact[15]; + static_assert(sizeof(exact) == 15, "fixture includes the terminator"); + EXPECT_TRUE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_RAW, + "custom/{iata}/{type}", IATA, DEVICE, TOKEN, + exact, sizeof(exact))); + EXPECT_STREQ(expected, exact); + + char short_buf[14]; + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, MQTT_PUBLICATION_RAW, + "custom/{iata}/{type}", IATA, DEVICE, TOKEN, + short_buf, sizeof(short_buf))); + EXPECT_LT(strlen(short_buf), sizeof(short_buf)); +} + +TEST(MQTTTopicRouter, RejectsInvalidStyleTypeSlotAndOutput) { + char topic[64] = "dirty"; + EXPECT_FALSE(mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, 99, nullptr, + IATA, DEVICE, TOKEN, topic, sizeof(topic))); + EXPECT_STREQ("", topic); + EXPECT_FALSE(mqttBuildPublicationTopic(static_cast(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 00000000..3045caed --- /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 00000000..5ebb553d --- /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_webconfig_batch/test_webconfig_batch.cpp b/test/test_webconfig_batch/test_webconfig_batch.cpp new file mode 100644 index 00000000..9faddc4f --- /dev/null +++ b/test/test_webconfig_batch/test_webconfig_batch.cpp @@ -0,0 +1,206 @@ +// Host contract tests for the pure WebConfig config-batch / reboot / stop state +// machine. This spec mirrors src/helpers/esp32/WebConfigServer.cpp; it is not +// yet wired into the bridge (see WebConfigBatch.h scope note). +#include + +#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)); +} + +// -------------------------------------------------------------------------- +// 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 00000000..b51d8ece --- /dev/null +++ b/test/test_webconfig_keys/test_webconfig_keys.cpp @@ -0,0 +1,140 @@ +// Host tests for the WebConfig key allowlist / secret / slot-prefix helpers +// (src/helpers/WebConfigKeys.h). These parse attacker-supplied POST keys, so +// coverage of the length-guard and boundary cases matters for safety. +#include +#include "helpers/WebConfigKeys.h" + +// ---- allowlist ------------------------------------------------------------ + +TEST(WebConfigKeys, AllowsKnownScalarKeys) { + EXPECT_TRUE(wcIsAllowedSetKey("name")); + EXPECT_TRUE(wcIsAllowedSetKey("radio")); + EXPECT_TRUE(wcIsAllowedSetKey("repeat")); + EXPECT_TRUE(wcIsAllowedSetKey("wifi.ssid")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt.iata")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt.neighbors")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt.neighbors.interval")); + EXPECT_TRUE(wcIsAllowedSetKey("snmp.community")); + EXPECT_TRUE(wcIsAllowedSetKey("timezone.offset")); +} + +TEST(WebConfigKeys, AllowsPerSlotKeys) { + EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.preset")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.server")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.token")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.audience")); // MAX_MQTT_SLOTS == 6 +} + +TEST(WebConfigKeys, RejectsDangerousOrUnknownKeys) { + EXPECT_FALSE(wcIsAllowedSetKey("erase")); + EXPECT_FALSE(wcIsAllowedSetKey("password")); + EXPECT_FALSE(wcIsAllowedSetKey("reboot")); + EXPECT_FALSE(wcIsAllowedSetKey("bogus")); + EXPECT_FALSE(wcIsAllowedSetKey("mqtt1.bogus")); // unknown slot field + EXPECT_FALSE(wcIsAllowedSetKey("")); +} + +TEST(WebConfigKeys, AdminPasswordIsNotAnAllowlistedSetKey) { + EXPECT_TRUE(wcIsAdminPasswordKey("password")); + EXPECT_FALSE(wcIsAdminPasswordKey("admin.password")); + EXPECT_FALSE(wcIsAdminPasswordKey("")); + EXPECT_FALSE(wcIsAllowedSetKey("password")); // never reachable as `set password` +} + +TEST(WebConfigKeys, AdminPasswordFitsNodePrefsAndRejectsLineBreaks) { + EXPECT_FALSE(wcIsValidAdminPassword(NULL)); + EXPECT_FALSE(wcIsValidAdminPassword("")); + EXPECT_TRUE(wcIsValidAdminPassword("new-password")); + EXPECT_TRUE(wcIsValidAdminPassword("123456789012345")); + EXPECT_FALSE(wcIsValidAdminPassword("1234567890123456")); + EXPECT_FALSE(wcIsValidAdminPassword("line\nbreak")); + EXPECT_FALSE(wcIsValidAdminPassword("line\rbreak")); +} + +TEST(WebConfigKeys, SlotIndexBoundsMatchMaxSlots) { + EXPECT_FALSE(wcIsAllowedSetKey("mqtt0.preset")); // slot 0 invalid + EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.preset")); // last valid slot + EXPECT_FALSE(wcIsAllowedSetKey("mqtt7.preset")); // beyond MAX_MQTT_SLOTS + EXPECT_FALSE(wcIsAllowedSetKey("mqtt9.preset")); +} + +TEST(WebConfigKeys, IsCaseSensitive) { + EXPECT_FALSE(wcIsAllowedSetKey("Name")); + EXPECT_FALSE(wcIsAllowedSetKey("MQTT1.preset")); +} + +// ---- short-key OOB guard -------------------------------------------------- +// The slot-prefix probe indexes key[4..6]; these short strings must be rejected +// without ever reading past the terminator. + +TEST(WebConfigKeys, ShortKeysRejectedSafely) { + EXPECT_FALSE(wcIsSlotKeyPrefix("")); + EXPECT_FALSE(wcIsSlotKeyPrefix("m")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mq")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqt")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt")); // 4 chars -- no digit/dot + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt1")); // 5 chars -- no dot + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt1.")); // 6 chars -- no field char + EXPECT_TRUE(wcIsSlotKeyPrefix("mqtt1.x")); // 7 chars -- minimum valid + // Same guard via the public allowlist/secret entry points: + EXPECT_FALSE(wcIsAllowedSetKey("mqtt")); + EXPECT_FALSE(wcIsSecretKey("m")); + EXPECT_FALSE(wcIsSecretKey("mqtt")); +} + +TEST(WebConfigKeys, SlotPrefixDigitRange) { + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt0.x")); + EXPECT_TRUE(wcIsSlotKeyPrefix("mqtt6.x")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt7.x")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqttA.x")); // non-digit +} + +// ---- secret classification ------------------------------------------------ + +TEST(WebConfigKeys, SecretKeysDetected) { + EXPECT_TRUE(wcIsSecretKey("wifi.pwd")); + EXPECT_TRUE(wcIsSecretKey("mqtt1.password")); + EXPECT_TRUE(wcIsSecretKey("mqtt3.token")); + EXPECT_TRUE(wcIsSecretKey("mqtt6.password")); +} + +TEST(WebConfigKeys, NonSecretKeysNotFlagged) { + EXPECT_FALSE(wcIsSecretKey("wifi.ssid")); + EXPECT_FALSE(wcIsSecretKey("mqtt1.username")); // username is not masked + EXPECT_FALSE(wcIsSecretKey("mqtt1.server")); + EXPECT_FALSE(wcIsSecretKey("mqtt.origin")); + EXPECT_FALSE(wcIsSecretKey("name")); + EXPECT_FALSE(wcIsSecretKey("")); +} + +TEST(WebConfigKeys, EverySecretKeyIsAlsoAllowed) { + // A secret key must be one the portal can actually set, or the masking is moot. + const char* secrets[] = {"wifi.pwd", "mqtt1.password", "mqtt1.token", + "mqtt6.password", "mqtt6.token"}; + for (const char* k : secrets) { + EXPECT_TRUE(wcIsSecretKey(k)) << k; + EXPECT_TRUE(wcIsAllowedSetKey(k)) << k; + } +} + +// ---- request correlation ------------------------------------------------- + +TEST(WebConfigKeys, AcceptsExactHexRequestIds) { + EXPECT_TRUE(wcIsValidReqId("0123456789abcdef")); + EXPECT_TRUE(wcIsValidReqId("ABCDEF0123456789")); +} + +TEST(WebConfigKeys, RejectsMissingMalformedOrWrongLengthRequestIds) { + EXPECT_FALSE(wcIsValidReqId(NULL)); + EXPECT_FALSE(wcIsValidReqId("")); + EXPECT_FALSE(wcIsValidReqId("0123456789abcde")); + EXPECT_FALSE(wcIsValidReqId("0123456789abcdef0")); + EXPECT_FALSE(wcIsValidReqId("0123456789abcdeg")); + EXPECT_FALSE(wcIsValidReqId("01234567-9abcdef")); + EXPECT_FALSE(wcIsValidReqId("01234567 9abcdef")); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/heltec_t190/platformio.ini b/variants/heltec_t190/platformio.ini index 5a9e8db2..9b0db55e 100644 --- a/variants/heltec_t190/platformio.ini +++ b/variants/heltec_t190/platformio.ini @@ -112,7 +112,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -128,7 +127,7 @@ lib_deps = ${Heltec_T190_base.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -205,7 +204,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -221,7 +220,7 @@ lib_deps = ${Heltec_T190_base.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index 99b1cdfe..31db3221 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -36,9 +36,14 @@ void HeltecTrackerV2Board::begin() { } void HeltecTrackerV2Board::powerOff() { - // Turn off PA + // Turn off PA. Guarded because this board file is also compiled for the + // heltec_tracker_v1_1 envs, which do not define P_LORA_PA_POWER (it is set + // only in variants/heltec_tracker_v2/platformio.ini). Same guard idiom + // LoRaFEMControl.cpp already uses for this macro. +#if defined(P_LORA_PA_POWER) digitalWrite(P_LORA_PA_POWER, LOW); rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); +#endif ESP32Board::powerOff(); } @@ -59,7 +64,14 @@ void HeltecTrackerV2Board::begin() { } const char* HeltecTrackerV2Board::getManufacturerName() const { + // The v1.1 environment reuses this V2 board implementation (same variant + // dir), so report the correct identity per build flag -- this string feeds + // WebConfig and MQTT status/board metadata. +#ifdef HELTEC_TRACKER_V1_1 + return "Heltec Tracker V1.1"; +#else return "Heltec Tracker V2"; +#endif } bool HeltecTrackerV2Board::setLoRaFemLnaEnabled(bool enable) { diff --git a/variants/heltec_tracker_v2/LoRaFEMControl.cpp b/variants/heltec_tracker_v2/LoRaFEMControl.cpp index b846465d..83f6ff03 100644 --- a/variants/heltec_tracker_v2/LoRaFEMControl.cpp +++ b/variants/heltec_tracker_v2/LoRaFEMControl.cpp @@ -5,6 +5,7 @@ void LoRaFEMControl::init(void) { +#if defined(P_LORA_PA_POWER) && defined(P_LORA_KCT8103L_PA_CSD) && defined(P_LORA_KCT8103L_PA_CTX) pinMode(P_LORA_PA_POWER, OUTPUT); digitalWrite(P_LORA_PA_POWER, HIGH); rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER); @@ -16,32 +17,40 @@ void LoRaFEMControl::init(void) pinMode(P_LORA_KCT8103L_PA_CTX, OUTPUT); digitalWrite(P_LORA_KCT8103L_PA_CTX, lna_enabled ? LOW : HIGH); setLnaCanControl(true); +#endif } void LoRaFEMControl::setSleepModeEnable(void) { +#if defined(P_LORA_KCT8103L_PA_CSD) // shutdown the PA digitalWrite(P_LORA_KCT8103L_PA_CSD, LOW); +#endif } void LoRaFEMControl::setTxModeEnable(void) { +#if defined(P_LORA_KCT8103L_PA_CSD) && defined(P_LORA_KCT8103L_PA_CTX) digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH); +#endif } void LoRaFEMControl::setRxModeEnable(void) { +#if defined(P_LORA_KCT8103L_PA_CSD) && defined(P_LORA_KCT8103L_PA_CTX) digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); if (lna_enabled) { digitalWrite(P_LORA_KCT8103L_PA_CTX, LOW); } else { digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH); } +#endif } void LoRaFEMControl::setRxModeEnableWhenMCUSleep(void) { +#if defined(P_LORA_KCT8103L_PA_CSD) && defined(P_LORA_KCT8103L_PA_CTX) digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CSD); if (lna_enabled) { @@ -50,6 +59,7 @@ void LoRaFEMControl::setRxModeEnableWhenMCUSleep(void) digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH); } rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CTX); +#endif } void LoRaFEMControl::setLNAEnable(bool enabled) diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index 5a2642f4..b2cf4077 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -58,6 +58,56 @@ lib_deps = ${sensor_base.lib_deps} bodmer/TFT_eSPI @ ^2.4.31 +[Heltec_tracker_v1_1] +extends = Heltec_tracker_v2 +board = heltec_tracker_v1_1 +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/heltec_tracker_v2 + -D HELTEC_TRACKER_V1_1 + -D ESP32_CPU_FREQ=240 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D P_LORA_TX_LED=18 + -D P_LORA_DIO_1=14 + -D P_LORA_NSS=8 + -D P_LORA_RESET=12 + -D P_LORA_BUSY=13 + -D P_LORA_SCLK=9 + -D P_LORA_MISO=11 + -D P_LORA_MOSI=10 + -D LORA_TX_POWER=22 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D SX126X_REGISTER_PATCH=1 + -D PIN_BOARD_SDA=6 + -D PIN_BOARD_SCL=17 + -D PIN_USER_BTN=0 + -D PIN_TFT_SDA=42 ; SDIN + -D PIN_TFT_SCL=41 ; SCLK + -D PIN_TFT_DC=40 ; RS (register select) + -D PIN_TFT_RST=39 ; RES + -D PIN_TFT_CS=38 + -D USE_PIN_TFT=1 + -D PIN_VEXT_EN=3 ; Vext is connected to VDD which is also connected to OLED & GPS + -D PIN_VEXT_EN_ACTIVE=HIGH + -D PIN_TFT_LEDA_CTL=21 ; LEDK (switches on/off via mosfet to create the ground) + -D DISPLAY_ROTATION=1 + -D PIN_GPS_RX=34 + -D PIN_GPS_TX=33 + -D PIN_GPS_RESET=35 + -D PIN_GPS_RESET_ACTIVE=LOW + -D GPS_BAUD_RATE=115200 + -D ENV_INCLUDE_GPS=1 + -D PIN_ADC_CTRL=2 + -D PIN_VBAT_READ=1 +build_src_filter = ${Heltec_tracker_v2.build_src_filter} +lib_deps = ${Heltec_tracker_v2.lib_deps} + [env:heltec_tracker_v2_repeater] extends = Heltec_tracker_v2 build_flags = @@ -119,6 +169,152 @@ lib_deps = ${Heltec_tracker_v2.lib_deps} ${esp32_ota.lib_deps} +[env:heltec_tracker_v1_1_repeater_observer_mqtt] +extends = Heltec_tracker_v1_1 +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${Heltec_tracker_v1_1.build_flags} + -D DISPLAY_CLASS=ST7735Display + -D ADVERT_NAME='"MQTT Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_BRIDGE=1 + -D MAX_MQTT_BROKERS=3 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm +build_src_filter = ${Heltec_tracker_v1_1.build_src_filter} + + + + + + + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_tracker_v1_1.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + +[env:heltec_tracker_v2_repeater_observer_mqtt] +extends = Heltec_tracker_v2 +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${Heltec_tracker_v2.build_flags} + -D DISPLAY_CLASS=ST7735Display + -D ADVERT_NAME='"MQTT Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_BRIDGE=1 + -D MAX_MQTT_BROKERS=3 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm +build_src_filter = ${Heltec_tracker_v2.build_src_filter} + + + + + + + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_tracker_v2.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + +[env:heltec_tracker_v1_1_room_server_observer_mqtt] +extends = Heltec_tracker_v1_1 +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${Heltec_tracker_v1_1.build_flags} + -D DISPLAY_CLASS=ST7735Display + -D ADVERT_NAME='"Heltec Tracker Room Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WITH_MQTT_BRIDGE=1 + -D MAX_NEIGHBOURS=50 + -D MAX_MQTT_BROKERS=3 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm +build_src_filter = ${Heltec_tracker_v1_1.build_src_filter} + + + + + + + + + +<../examples/simple_room_server> +lib_deps = + ${Heltec_tracker_v1_1.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + +[env:heltec_tracker_v2_room_server_observer_mqtt] +extends = Heltec_tracker_v2 +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${Heltec_tracker_v2.build_flags} + -D DISPLAY_CLASS=ST7735Display + -D ADVERT_NAME='"Heltec Tracker Room Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WITH_MQTT_BRIDGE=1 + -D MAX_NEIGHBOURS=50 + -D MAX_MQTT_BROKERS=3 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm +build_src_filter = ${Heltec_tracker_v2.build_src_filter} + + + + + + + + + +<../examples/simple_room_server> +lib_deps = + ${Heltec_tracker_v2.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + [env:heltec_tracker_v2_terminal_chat] extends = Heltec_tracker_v2 build_flags = diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index dd9a7ffb..da51943a 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -129,11 +129,11 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 - -D MQTT_MEMORY_DEBUG=1 +; Periodic 30s heap/pub-stats serial log -- enable only for debugging (use `get mqtt.stats` on demand instead). +; -D MQTT_MEMORY_DEBUG=1 ; Keep default observer profile less verbose to reduce runtime contention. ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 @@ -158,12 +158,22 @@ lib_deps = ${Heltec_lora32_v3.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone 0neblock/SNMP_Agent paulstoffregen/Time@1.6.1 +; Emulator build (Wokwi): identical to the observer above but with the LoRa radio +; stubbed (SIM_BUILD -> SimRadio) and WiFi pre-seeded to the Wokwi network so it +; boots straight into WiFi/MQTT/CLI without hardware or flashing. See wokwi/. +[env:Heltec_v3_repeater_observer_mqtt_sim] +extends = env:Heltec_v3_repeater_observer_mqtt +build_flags = + ${env:Heltec_v3_repeater_observer_mqtt.build_flags} + -D SIM_BUILD=1 + -D SIM_WIFI_SSID='"Wokwi-GUEST"' + [env:Heltec_v3_room_server] extends = Heltec_lora32_v3 build_flags = @@ -199,7 +209,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -208,20 +218,23 @@ build_flags = -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y -D ESP32_CPU_FREQ=160 -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm + -D WITH_SNMP=1 build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + + + + +<../examples/simple_room_server> lib_deps = ${Heltec_lora32_v3.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent [env:Heltec_v3_terminal_chat] extends = Heltec_lora32_v3 @@ -329,7 +342,7 @@ lib_deps = ${env:Heltec_v3_companion_radio_wifi.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -521,11 +534,11 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 - -D MQTT_MEMORY_DEBUG=1 +; Periodic 30s heap/pub-stats serial log -- enable only for debugging (use `get mqtt.stats` on demand instead). +; -D MQTT_MEMORY_DEBUG=1 ; Keep default observer profile less verbose to reduce runtime contention. ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 @@ -549,7 +562,7 @@ lib_deps = ${Heltec_lora32_v3.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone 0neblock/SNMP_Agent @@ -570,7 +583,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -580,19 +593,22 @@ build_flags = -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y -D ESP32_CPU_FREQ=160 -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm + -D WITH_SNMP=1 build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + + + +<../examples/simple_room_server> lib_deps = ${Heltec_lora32_v3.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent [env:Heltec_v3_kiss_modem] extends = Heltec_lora32_v3 diff --git a/variants/heltec_v3/target.cpp b/variants/heltec_v3/target.cpp index 372385ea..94f8e796 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,6 +46,11 @@ bool radio_init() { } mesh::LocalIdentity radio_new_identity() { +#ifdef SIM_BUILD + SimRNG rng; + return mesh::LocalIdentity(&rng); +#else RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng); // create new random identity +#endif } diff --git a/variants/heltec_v3/target.h b/variants/heltec_v3/target.h index 2944b384..07b04fca 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/platformio.ini b/variants/heltec_v4/platformio.ini index 73692767..0a27e14a 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -163,7 +163,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -193,7 +192,7 @@ lib_deps = ${heltec_v4_oled.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -215,7 +214,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -247,7 +245,7 @@ lib_deps = ${heltec_v4_oled.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -318,7 +316,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -327,20 +325,23 @@ build_flags = -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y -D ESP32_CPU_FREQ=160 -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm + -D WITH_SNMP=1 build_src_filter = ${heltec_v4_oled.build_src_filter} + + + + + + +<../examples/simple_room_server> lib_deps = ${heltec_v4_oled.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent [env:heltec_v4_expansionkit_room_server_observer_mqtt] extends = heltec_v4_oled @@ -358,7 +359,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -369,20 +370,23 @@ build_flags = -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm -D ENV_PIN_SDA=4 -D ENV_PIN_SCL=3 + -D WITH_SNMP=1 build_src_filter = ${heltec_v4_oled.build_src_filter} + + + + + + +<../examples/simple_room_server> lib_deps = ${heltec_v4_oled.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent [env:heltec_v4_room_server] extends = heltec_v4_oled @@ -523,7 +527,7 @@ lib_deps = ${env:heltec_v4_companion_radio_wifi_femon.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/lilygo_t3s3/platformio.ini b/variants/lilygo_t3s3/platformio.ini index 5cf07b26..48a5c1cc 100644 --- a/variants/lilygo_t3s3/platformio.ini +++ b/variants/lilygo_t3s3/platformio.ini @@ -118,7 +118,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 ; -D MQTT_DEBUG=1 @@ -142,7 +141,7 @@ lib_deps = ${LilyGo_T3S3_sx1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -164,7 +163,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -182,7 +181,7 @@ lib_deps = ${LilyGo_T3S3_sx1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/lilygo_tbeam_1w/platformio.ini b/variants/lilygo_tbeam_1w/platformio.ini index 2d2de8bd..80d72dc7 100644 --- a/variants/lilygo_tbeam_1w/platformio.ini +++ b/variants/lilygo_tbeam_1w/platformio.ini @@ -213,7 +213,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D PERSISTANT_GPS=1 @@ -239,7 +238,7 @@ lib_deps = ${LilyGo_TBeam_1W.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -260,7 +259,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D PERSISTANT_GPS=1 @@ -280,7 +279,7 @@ lib_deps = ${LilyGo_TBeam_1W.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/lilygo_tbeam_SX1262/platformio.ini b/variants/lilygo_tbeam_SX1262/platformio.ini index d79e3038..44ca697e 100644 --- a/variants/lilygo_tbeam_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_SX1262/platformio.ini @@ -193,7 +193,7 @@ lib_deps = ${LilyGo_TBeam_SX1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -213,7 +213,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -230,7 +230,7 @@ lib_deps = ${LilyGo_TBeam_SX1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/lilygo_tbeam_SX1276/platformio.ini b/variants/lilygo_tbeam_SX1276/platformio.ini index 7f6d6e6e..c77587a4 100644 --- a/variants/lilygo_tbeam_SX1276/platformio.ini +++ b/variants/lilygo_tbeam_SX1276/platformio.ini @@ -189,7 +189,7 @@ lib_deps = ${LilyGo_TBeam_SX1276.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -209,7 +209,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -226,7 +226,7 @@ lib_deps = ${LilyGo_TBeam_SX1276.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini index 354ece65..04773e64 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini @@ -121,7 +121,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -144,7 +143,7 @@ lib_deps = ${T_Beam_S3_Supreme_SX1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 @@ -164,7 +163,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -181,7 +180,7 @@ lib_deps = ${T_Beam_S3_Supreme_SX1262.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index d0eedf22..4a98635e 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -155,7 +155,16 @@ lib_deps = ; Use ONE active WSS broker preset at a time. Two concurrent TLS sessions usually exhaust ; contiguous internal heap; the second slot fails (mbedtls_ssl_setup / esp-tls 0x8017). ; Set extra slots to "none" (e.g. set mqtt2.preset none). See MQTT_IMPLEMENTATION.md. -[env:LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt] +; TEMPORARILY EXCLUDED FROM AUTOMATIC BUILDS (trailing underscore, same +; convention as the nibble_screen_connect envs). This board does not fit: +; flex baseline (no webconfig, no upstream merge): 2,069,397 / 1,966,080 = 105.3% +; It has been silently failing on production for some time -- the observer release +; ships 30 envs, not 32, and build.sh swallowing pio's exit code hid it. Dropping +; webconfig would recover only ~46 KB of a ~101 KB (flex) / ~242 KB (dev) deficit, +; so a NO_WEBCONFIG flag cannot rescue it. ESP32 (not S3), 4 MB flash, already on +; min_spiffs.csv. Real options: single-app partition (fits, but loses OTA), or +; trim ~250 KB for this board. See .scratch/tlora-v2-oversize.md. +[env:LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_] extends = LilyGo_TLora_V2_1_1_6_core ; Preserve two OTA app slots while reclaiming coredump/half of SPIFFS for the MQTT repeater image. board_build.partitions = variants/lilygo_tlora_v2_1/dual_ota_1984k.csv @@ -200,13 +209,14 @@ build_src_filter = ${LilyGo_TLora_V2_1_1_6_core.build_src_filter} lib_deps = ${LilyGo_TLora_V2_1_1_6_core.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 ; Same single-broker guidance as LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt (see comment block above). -[env:LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt] +; TEMPORARILY EXCLUDED -- see the repeater env above. +[env:LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt_] extends = LilyGo_TLora_V2_1_1_6_core extra_scripts = ${esp32_base.extra_scripts} @@ -242,7 +252,7 @@ build_src_filter = ${LilyGo_TLora_V2_1_1_6_core.build_src_filter} lib_deps = ${LilyGo_TLora_V2_1_1_6_core.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 diff --git a/variants/rak11310/RAK11310Board.cpp b/variants/rak11310/RAK11310Board.cpp index f45d8148..2a5266ab 100644 --- a/variants/rak11310/RAK11310Board.cpp +++ b/variants/rak11310/RAK11310Board.cpp @@ -25,6 +25,9 @@ void RAK11310Board::begin() { delay(10); // give sx1262 some time to power up } -bool RAK11310Board::startOTAUpdate(const char *id, char reply[]) { +bool RAK11310Board::startOTAUpdate(const char *id, char reply[], bool force_ap) { + (void)id; + (void)reply; + (void)force_ap; return false; } diff --git a/variants/rak11310/RAK11310Board.h b/variants/rak11310/RAK11310Board.h index ea0f15e2..119c3992 100644 --- a/variants/rak11310/RAK11310Board.h +++ b/variants/rak11310/RAK11310Board.h @@ -45,5 +45,5 @@ public: void reboot() override { rp2040.reboot(); } - bool startOTAUpdate(const char *id, char reply[]) override; + bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override; }; diff --git a/variants/rak3112/platformio.ini b/variants/rak3112/platformio.ini index 130b8344..6f6df909 100644 --- a/variants/rak3112/platformio.ini +++ b/variants/rak3112/platformio.ini @@ -113,7 +113,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -139,7 +138,7 @@ lib_deps = ${rak3112.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone 0neblock/SNMP_Agent @@ -177,7 +176,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' + -D MAX_NEIGHBOURS=50 -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -186,19 +185,22 @@ build_flags = -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y -D ESP32_CPU_FREQ=160 -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm + -D WITH_SNMP=1 build_src_filter = ${rak3112.build_src_filter} + + + + + +<../examples/simple_room_server> lib_deps = ${rak3112.lib_deps} ${esp32_ota.lib_deps} elims/PsychicMqttClient@^0.2.4 - bblanchon/ArduinoJson + bblanchon/ArduinoJson @ 7.4.3 arduino-libraries/NTPClient JChristensen/Timezone paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent [env:RAK_3112_terminal_chat] extends = rak3112 diff --git a/variants/rpi_picow/PicoWBoard.cpp b/variants/rpi_picow/PicoWBoard.cpp index f345f96d..e9be8ebd 100644 --- a/variants/rpi_picow/PicoWBoard.cpp +++ b/variants/rpi_picow/PicoWBoard.cpp @@ -37,6 +37,9 @@ void PicoWBoard::begin() { delay(10); // give sx1262 some time to power up } -bool PicoWBoard::startOTAUpdate(const char* id, char reply[]) { +bool PicoWBoard::startOTAUpdate(const char* id, char reply[], bool force_ap) { + (void)id; + (void)reply; + (void)force_ap; return false; } diff --git a/variants/rpi_picow/PicoWBoard.h b/variants/rpi_picow/PicoWBoard.h index 708e9655..c8ca24ff 100644 --- a/variants/rpi_picow/PicoWBoard.h +++ b/variants/rpi_picow/PicoWBoard.h @@ -47,5 +47,5 @@ public: rp2040.reboot(); } - bool startOTAUpdate(const char* id, char reply[]) override; + bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override; }; diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini index 779149d6..c9b6f2ac 100644 --- a/variants/station_g2/platformio.ini +++ b/variants/station_g2/platformio.ini @@ -201,27 +201,30 @@ 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 MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y -D ESP32_CPU_FREQ=160 + -D WITH_SNMP=1 build_src_filter = ${Station_G2.build_src_filter} + + + + + + +<../examples/simple_room_server> 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 + 0neblock/SNMP_Agent [env:Station_G2_room_server] extends = Station_G2 @@ -344,7 +347,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 MESH_PACKET_LOGGING=1 @@ -369,7 +371,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/waveshare_rp2040_lora/WaveshareBoard.cpp b/variants/waveshare_rp2040_lora/WaveshareBoard.cpp index 2e622933..6c2f4d23 100644 --- a/variants/waveshare_rp2040_lora/WaveshareBoard.cpp +++ b/variants/waveshare_rp2040_lora/WaveshareBoard.cpp @@ -25,6 +25,9 @@ void WaveshareBoard::begin() { delay(10); // give sx1262 some time to power up } -bool WaveshareBoard::startOTAUpdate(const char *id, char reply[]) { +bool WaveshareBoard::startOTAUpdate(const char *id, char reply[], bool force_ap) { + (void)id; + (void)reply; + (void)force_ap; return false; } diff --git a/variants/waveshare_rp2040_lora/WaveshareBoard.h b/variants/waveshare_rp2040_lora/WaveshareBoard.h index 694b8bd1..37d06c7a 100644 --- a/variants/waveshare_rp2040_lora/WaveshareBoard.h +++ b/variants/waveshare_rp2040_lora/WaveshareBoard.h @@ -57,5 +57,5 @@ public: void reboot() override { rp2040.reboot(); } - bool startOTAUpdate(const char *id, char reply[]) override; + bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override; }; diff --git a/variants/xiao_rp2040/XiaoRP2040Board.cpp b/variants/xiao_rp2040/XiaoRP2040Board.cpp index bb439706..07932bf4 100644 --- a/variants/xiao_rp2040/XiaoRP2040Board.cpp +++ b/variants/xiao_rp2040/XiaoRP2040Board.cpp @@ -25,6 +25,9 @@ void XiaoRP2040Board::begin() { delay(10); // give sx1262 some time to power up } -bool XiaoRP2040Board::startOTAUpdate(const char *id, char reply[]) { +bool XiaoRP2040Board::startOTAUpdate(const char *id, char reply[], bool force_ap) { + (void)id; + (void)reply; + (void)force_ap; return false; } diff --git a/variants/xiao_rp2040/XiaoRP2040Board.h b/variants/xiao_rp2040/XiaoRP2040Board.h index d2951c75..e146c364 100644 --- a/variants/xiao_rp2040/XiaoRP2040Board.h +++ b/variants/xiao_rp2040/XiaoRP2040Board.h @@ -56,5 +56,5 @@ public: void reboot() override { rp2040.reboot(); } - bool startOTAUpdate(const char *id, char reply[]) override; + bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override; }; diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 9df3d213..ce4eefd2 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -104,7 +104,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 @@ -128,7 +127,7 @@ lib_deps = ${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 @@ -148,7 +147,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 @@ -165,7 +164,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 diff --git a/webui/index.html b/webui/index.html index 7447f374..bc73b921 100644 --- a/webui/index.html +++ b/webui/index.html @@ -173,6 +173,15 @@ canvas{width:100%;height:56px;display:block}
Maximum legal power varies by region — check local rules.
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. It is used to sign in here later and to administer the node remotely over the mesh.

+
+ +
Maximum 15 characters.
+
+
+
@@ -237,6 +246,14 @@ canvas{width:100%;height:56px;display:block}
+
+

Admin password

+
Signs in here and authenticates remote admin commands over the mesh. Leave blank to keep the current password. 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.
@@ -313,12 +330,18 @@ canvas{width:100%;height:56px;display:block}
Received packetsReport packets heard over the air
+
Publish neighborsPeriodic neighbor table and scopes (PSRAM boards only) +
Report packets this node sends.
+
+
+
How often to publish the neighbor table (12-336, default 24).
+

Servers

@@ -381,7 +404,7 @@ canvas{width:100%;height:56px;display:block}
- +
@@ -422,7 +445,7 @@ canvas{width:100%;height:56px;display:block}