Merge observer firmware branch into keymindCascade

Integrate compatible observer board, preset, WebConfig, display, CI, and policy updates. Preserve the checkout's newer MQTT binary preferences and WiFi bridge runtime where the forked implementations conflict. Keep ThinkNode observer networking WiFi-based until the Ethernet transport is wired into that runtime.
This commit is contained in:
mikecarper committed 2026-09-24 01:20:56 -07:00
commit 1e8ef78a2e
51 files changed
+6495 -1973

No files matched your search

@@ -312,8 +312,16 @@ jobs:
# 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).
# --prune deletes manifests for envs this build no longer produces.
# Without it the dir only ever grew: the retired
# LilyGo_TLora_V2_1_1_6_*_observer_mqtt_ manifests sat here for two
# months pointing at release assets that had since been pruned, so any
# node still on that build got a 404 from `ota update`. The generator
# caps the prune (--prune-limit, default 4) and fails the run instead
# of pruning when a build looks like it silently lost envs.
python3 flasher/scripts/gen-slim-manifests.py \
--bin-dir out \
--prune \
--static-path "$STATIC_PATH" \
--out-dir "flasher/$MANIFEST_DIR" \
--base-version "$FIRMWARE_VERSION" \
@@ -48,6 +48,12 @@ env:
# repo). Baked into the slim OTA manifests' file URLs; must stay consistent
# with config.json's staticPath.
STATIC_PATH: https://observer-fw.gessaman.com
# Where the firmware fetches <OTA_VARIANT>.json from — this URL *is* the release
# channel. Stated explicitly (it equals build.sh's default) rather than left
# unset, so "Verify production channel is baked in" below can assert against the
# same value the build was handed instead of a second hardcoded copy. Must match
# build.sh's OTA_MANIFEST_BASE_URL default and the Pages path serving flasher/v.
OTA_MANIFEST_BASE_URL: https://observer.gessaman.com/v
jobs:
@@ -137,6 +143,23 @@ jobs:
FIRMWARE_BUILD_NUMBER: ${{ needs.enumerate.outputs.build_number }}
run: /usr/bin/env bash build.sh build-firmware ${{ matrix.shard.envs }}
- name: Verify production channel is baked in
shell: bash
run: |
# Mirror of the beta workflow's guard, which production lacked. Fail fast
# rather than publish firmware that would OTA itself onto the wrong
# channel: the manifest base is a compile-time -D, so a build that lost
# it (or picked up beta's) is invisible until a node tries `ota check`.
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: production manifest base missing from $BIN" >&2; exit 1
fi
if strings "$BIN" | grep -qF 'https://observer.gessaman.com/beta/v'; then
echo "ERROR: beta manifest base present in a production build" >&2; exit 1
fi
echo "OK: $BIN carries $OTA_MANIFEST_BASE_URL"
- name: Upload Shard Artifact
uses: actions/upload-artifact@v4
with:
@@ -253,8 +276,15 @@ jobs:
# in out/ -- the assets actually uploaded to the release -- stamping this
# build's number. Then persist the counter so the next run increments
# from here.
# --prune deletes manifests for envs this build no longer produces, so
# a retired env cannot leave behind a manifest pointing at a release
# asset that later gets pruned (which is a 404 for `ota update` on any
# node still running it). The generator caps the prune (--prune-limit,
# default 4) and fails the run rather than pruning when a build looks
# like it silently lost envs.
python3 flasher/scripts/gen-slim-manifests.py \
--bin-dir out \
--prune \
--static-path "$STATIC_PATH" \
--out-dir flasher/v \
--base-version "$FIRMWARE_VERSION" \
@@ -1,7 +1,11 @@
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.
# Ensures observer-firmware and observer-firmware-dev ship an identical
# src/helpers/MQTTPresets.h. A slot's preset is stored in /mqtt.json by name, and
# firmware that does not know a name repairs it to "none" and writes the file
# back, so a node that rolls back between channels loses that slot for good.
# Behaviour that differs per channel belongs in MQTTPresetPolicy.h, which is not
# compared. See scripts/check_mqtt_preset_parity.py.
permissions:
contents: read
@@ -81,9 +85,9 @@ jobs:
- name: Self-test checker
run: python3 scripts/check_mqtt_preset_parity.py --self-test
- name: Compare preset names
- name: Compare preset tables
run: |
python3 scripts/check_mqtt_preset_parity.py \
python3 scripts/check_mqtt_preset_parity.py --exact \
/tmp/preset-parity/prod.h \
/tmp/preset-parity/dev.h \
--label-a observer-firmware \
+4
View File
@@ -67,3 +67,7 @@ __pycache__/
.build-wt-*/
.wt-*/
scripts/__pycache__/*
# Reduced-TLS mbedTLS archives, ~6 MB per arch, fetched by scripts/fetch_mbedtls_4k.sh.
# Not committed because they must be rebuilt for every espressif32 platform bump.
.mbedtls-4k/
+5 -1
View File
@@ -283,6 +283,8 @@ pio run -e Station_G2_repeater_observer_mqtt
# Station G3 (ESP32)
pio run -e Station_G3_ESP32_repeater_observer_mqtt
pio run -e Station_G3_ESP32_room_server_observer_mqtt
pio run -e Station_G3_ESP32_r2_repeater_observer_mqtt
pio run -e Station_G3_ESP32_r2_room_server_observer_mqtt
# LilyGo T-LoRa V2.1-1.6 (TTGO LoRa32 V1.0)
pio run -e LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_
@@ -323,6 +325,8 @@ the installed application slots and satisfies the updater's compatibility checks
| `Station_G2_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board |
| `Station_G3_ESP32_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board |
| `Station_G3_ESP32_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board |
| `Station_G3_ESP32_r2_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | Second RF slot |
| `Station_G3_ESP32_r2_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | Second RF slot |
| `LilyGo_TBeam_1W_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | Set in `boards/t_beam_1w.json`; required vs implicit `default.csv` |
| `LilyGo_TBeam_1W_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | same |
@@ -449,7 +453,7 @@ Each slot (1-6) supports the following commands:
- `set mqttN.audience` - Clear JWT audience (reverts to username/password auth)
- `set mqttN.filter <all|none|list>` - Select payload types uploaded to this slot
**Note:** Custom server/port settings only apply when the slot's preset is `custom`. Username/password also apply to built-in presets that use per-slot credentials (e.g. `inwmesh`); other userpass presets (`tennmesh`, `nashmesh`, `ctmesh`) ship fixed credentials in firmware.
**Note:** Custom server/port settings only apply when the slot's preset is `custom`. Username/password also apply to built-in presets that use per-slot credentials (e.g. `inwmesh`); other userpass presets (`tennmesh`, `nashmesh`, `ctmesh`, `marwoj`) ship fixed credentials in firmware.
#### Per-broker packet filters
+30
View File
@@ -136,6 +136,36 @@ fix was `rm -rf` the package and `pio pkg install` to re-download stock. A prepe
search path keeps the change scoped to one env, because the linker takes each archive member
from the first archive that satisfies an undefined symbol.
### How the archives are distributed
They are not committed: ~6 MB per architecture, and they have to be rebuilt for every
`platformio/espressif32` bump, so committing them would grow history permanently and go
stale silently. Instead they are published as a release asset and fetched on demand:
```
scripts/fetch_mbedtls_4k.sh esp32s3 # download + verify against the manifest
MESHCORE_REDUCED_TLS=1 pio run -e Heltec_v3_repeater_observer_mqtt
```
- `scripts/mbedtls_4k_manifest.txt` — per-arch sha256 of each archive. **Update it on every
platform bump**, together with the published asset.
- `scripts/fetch_mbedtls_4k.sh` — downloads into `.mbedtls-4k/<arch>/` (gitignored) and
verifies. `MBEDTLS_4K_LOCAL=<dir>` copies from a local build tree instead of downloading.
- `scripts/mbedtls_4k.py` — wired into `esp32_base.extra_scripts`, but a **no-op unless
`MESHCORE_REDUCED_TLS=1`**, so ordinary builds need no artifact and behave as before.
The opt-in path is deliberately loud, because both ways this can go wrong produce a
firmware that looks correct and silently lacks the change:
| failure | what happens without a guard | guard |
|---|---|---|
| directory missing or partial | linker ignores an unusable `-L` and resolves mbedTLS from the framework | pre-build: hard error |
| archives stale after a platform bump | links the wrong build | pre-build: sha256 vs manifest |
| `-L` present but outranked | framework archives win, flag is inert | post-link: `firmware.map` must resolve every `libmbed*.a` to `.mbedtls-4k/` |
That last one is the reason the post-link check exists rather than trusting the flag: a
build flag reaching the compiler proves nothing about what got linked.
## How to verify it worked
1. `strings`/`grep` the new `sdkconfig.h` for the four settings.
+1 -1
View File
@@ -2281,7 +2281,7 @@ void UITask::loop() {
? checkDisplayOn(KEY_ENTER)
: handleMultiClick(KEY_SELECT, ev == BUTTON_EVENT_QUADRUPLE_CLICK);
}
#endif
#endif
#endif
#if defined(UI_HAS_ROTARY_INPUT)
RotaryInputEvent rotaryEv = rotary_input.poll();
+102 -5
View File
@@ -1,13 +1,25 @@
#!/usr/bin/env python3
"""Compare MQTT built-in preset *names* between two MQTTPresets.h files.
"""Compare the MQTT broker preset table 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.
With ``--exact`` (what CI uses) the two files must be byte-identical. The table
is fleet state, not just a build detail: a slot's preset is stored in
/mqtt.json by NAME, and firmware that does not know a name does not merely
ignore it — MQTTPrefsSerializer repairs it to "none" and the repaired file is
written back to flash. A node that rolls back from one channel to the other
therefore loses that slot permanently. Byte equality also catches URL, CA and
credential drift, which a name-only check passes silently even though the two
channels are meant to dial the same brokers.
Channel-specific behaviour belongs in MQTTPresetPolicy.h, which is not compared,
so this file can stay identical while the channels differ elsewhere.
Without ``--exact`` only the first string field of each ``MQTT_PRESETS`` entry
is compared (set equality, case-sensitive). That is the older, weaker check,
kept for ad-hoc use.
Usage::
python3 scripts/check_mqtt_preset_parity.py FILE_A FILE_B \\
python3 scripts/check_mqtt_preset_parity.py FILE_A FILE_B --exact \\
--label-a observer-firmware --label-b observer-firmware-dev
python3 scripts/check_mqtt_preset_parity.py --self-test
@@ -138,6 +150,49 @@ def compare(
]
def compare_exact(
path_a: Path,
path_b: Path,
*,
label_a: str,
label_b: str,
) -> list[str]:
"""Return error lines if the two files differ byte for byte."""
data_a = path_a.read_bytes()
data_b = path_b.read_bytes()
if data_a == data_b:
return []
import difflib
errors = [f"{label_a} and {label_b} do not match byte for byte."]
try:
diff = list(
difflib.unified_diff(
data_a.decode("utf-8").splitlines(),
data_b.decode("utf-8").splitlines(),
fromfile=label_a,
tofile=label_b,
lineterm="",
n=1,
)
)
except UnicodeDecodeError:
errors.append("(binary difference; cannot render a text diff)")
return errors
# Enough to identify the drift without pasting the whole table into a log.
errors.extend(diff[:60])
if len(diff) > 60:
errors.append(f"... {len(diff) - 60} more diff line(s)")
errors.append(
"Preset rows must be identical on both channels. Behaviour that differs "
"per channel belongs in MQTTPresetPolicy.h."
)
return errors
def load_names(path: Path) -> set[str]:
text = path.read_text(encoding="utf-8")
names, _ = parse_presets(text, source=str(path))
@@ -238,6 +293,26 @@ static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {{
print("self-test failed: URL scheme was treated as a comment.", file=sys.stderr)
return 1
# --exact: identical bytes pass, any drift fails — including a change
# that a name-only comparison waves through.
if compare_exact(equal_a, equal_a, label_a="a", label_b="a-copy"):
print("self-test failed: a file was not equal to itself.", file=sys.stderr)
return 1
if compare(load_names(equal_a), load_names(equal_b), label_a="a", label_b="b"):
print("self-test failed: fixture assumption broken.", file=sys.stderr)
return 1
exact_errs = compare_exact(equal_a, equal_b, label_a="a", label_b="b")
if not exact_errs or "byte for byte" not in exact_errs[0]:
print(
"self-test failed: --exact accepted files that differ only in "
f"config: {exact_errs!r}",
file=sys.stderr,
)
return 1
if not any("MQTTPresetPolicy.h" in line for line in exact_errs):
print("self-test failed: --exact failure omitted the remedy.", file=sys.stderr)
return 1
# Silence unused path in fixture layout.
dupes.write_text("// unused\n", encoding="utf-8")
@@ -251,6 +326,11 @@ def main(argv: list[str] | None = None) -> int:
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(
"--exact",
action="store_true",
help="Require the two files to be byte-identical (what CI enforces)",
)
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args(argv)
@@ -260,6 +340,23 @@ def main(argv: list[str] | None = None) -> int:
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")
if args.exact:
try:
errors = compare_exact(
args.file_a, args.file_b, label_a=args.label_a, label_b=args.label_b
)
except OSError as error:
print(f"MQTT preset parity check could not run: {error}", file=sys.stderr)
return 2
if errors:
print("MQTT preset parity check failed:", *errors, sep="\n ", file=sys.stderr)
return 1
print(
f"MQTT preset parity check passed: {args.label_a} and {args.label_b} "
"have identical preset tables."
)
return 0
try:
names_a = load_names(args.file_a)
names_b = load_names(args.file_b)
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Fetch the reduced-TLS mbedTLS archives into .mbedtls-4k/<arch>/.
#
# These archives are built from the shipped sdkconfig plus three lines
# (CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=y, IN_CONTENT_LEN 16384,
# OUT_CONTENT_LEN 4096) and save ~12 KiB of internal DRAM per TLS connection.
# See docs/mbedtls-tls-footprint.md for the rationale and the build recipe.
#
# They are not committed: ~6 MB per architecture, and they must be rebuilt for
# every platform bump, so they are published as a release asset keyed on the
# espressif32 platform version instead.
#
# scripts/fetch_mbedtls_4k.sh [arch] # default: esp32s3
#
# Set MBEDTLS_4K_LOCAL to skip the download and copy from a local build tree:
# MBEDTLS_4K_LOCAL=~/mbedtls-4k-esp32s3/staged scripts/fetch_mbedtls_4k.sh
set -euo pipefail
ARCH="${1:-esp32s3}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEST="$REPO_ROOT/.mbedtls-4k/$ARCH"
MANIFEST="$REPO_ROOT/scripts/mbedtls_4k_manifest.txt"
BASE_URL="${MBEDTLS_4K_BASE_URL:-https://github.com/agessaman/MeshCore/releases/download/mbedtls-4k}"
if [ ! -f "$MANIFEST" ]; then
echo "error: missing $MANIFEST" >&2
exit 1
fi
# Manifest lines: <arch> <sha256> <filename>. Blank lines and # comments ignored.
# The "platform" and "stock:<arch>" lines bind the archives to a framework version;
# they are the build check's business, not ours, and are not architectures.
expected="$(awk -v a="$ARCH" '$1 == a && $0 !~ /^#/ {print $2" "$3}' "$MANIFEST")"
if [ -z "$expected" ]; then
echo "error: no manifest entries for arch '$ARCH'" >&2
echo "known arches: $(awk '$0 !~ /^#/ && NF && $1 != "platform" && $1 !~ /^stock:/ {print $1}' \
"$MANIFEST" | sort -u | tr '\n' ' ')" >&2
exit 1
fi
mkdir -p "$DEST"
if [ -n "${MBEDTLS_4K_LOCAL:-}" ]; then
echo "copying from $MBEDTLS_4K_LOCAL"
while read -r _sha name; do
cp "$MBEDTLS_4K_LOCAL/$name" "$DEST/$name"
done <<< "$expected"
else
TARBALL="mbedtls-4k-$ARCH.tar.gz"
echo "downloading $BASE_URL/$TARBALL"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl -fsSL "$BASE_URL/$TARBALL" -o "$tmp/$TARBALL"
tar -xzf "$tmp/$TARBALL" -C "$tmp"
while read -r _sha name; do
# Accept the archive whether or not the tarball has a leading directory.
found="$(find "$tmp" -name "$name" -type f | head -1)"
if [ -z "$found" ]; then
echo "error: $name missing from $TARBALL" >&2
exit 1
fi
cp "$found" "$DEST/$name"
done <<< "$expected"
fi
# Verify every archive against the manifest. A wrong or truncated archive would
# otherwise link silently and produce a firmware without the reduced buffers.
cd "$DEST"
if command -v shasum >/dev/null 2>&1; then
echo "$expected" | shasum -a 256 -c -
else
echo "$expected" | sha256sum -c -
fi
echo "ok: $ARCH archives verified in $DEST"
+244
View File
@@ -0,0 +1,244 @@
"""Link the reduced-TLS mbedTLS archives, and prove they were actually linked.
Opt in per build with MESHCORE_REDUCED_TLS=1. Off by default, so an ordinary build
needs no 6 MB artifact and behaves exactly as before.
MESHCORE_REDUCED_TLS=1 pio run -e Heltec_v3_repeater_observer_mqtt
The archives lower the mbedTLS outbound record buffer from 16 KiB to 4 KiB, saving
~12 KiB of internal DRAM per TLS connection on non-PSRAM observers. The inbound
buffer stays at 16 KiB, so the contiguous allocation a handshake needs is unchanged
— this buys headroom, it does not move that floor. See docs/mbedtls-tls-footprint.md.
Three failure modes this guards against, each of which produces a firmware that looks
fine and is silently wrong:
- a -L pointing at a missing or partial directory. The linker ignores an
unusable search path and quietly resolves mbedTLS from the framework instead.
- archives that do not match the manifest, e.g. left over from an earlier
platform version.
- a manifest and archives that agree with each other but not with the installed
framework, which is what a platform bump without a rebuild leaves behind. That
one links cleanly and drifts on struct layout at runtime.
So the opt-in path verifies every archive by sha256 before the build, checks the
framework's own mbedTLS archives still fingerprint as the ones these were built
against, and after the link re-reads firmware.map to confirm every libmbed*.a came
from our directory.
"""
Import("env")
import hashlib
import os
import sys
REQUIRED = ("libmbedcrypto.a", "libmbedtls_2.a", "libmbedtls.a", "libmbedx509.a")
def _fail(msg):
print("\n*** reduced-TLS build failed ***", file=sys.stderr)
print(msg, file=sys.stderr)
print(
"\nFetch the archives with: scripts/fetch_mbedtls_4k.sh <arch>"
"\nOr build without them by unsetting MESHCORE_REDUCED_TLS.",
file=sys.stderr,
)
env.Exit(1)
def _sha256(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _manifest(project_dir, arch):
path = os.path.join(project_dir, "scripts", "mbedtls_4k_manifest.txt")
if not os.path.isfile(path):
_fail("missing scripts/mbedtls_4k_manifest.txt")
wanted = {}
stock = {}
platform_id = ""
with open(path) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) == 2 and parts[0] == "platform":
platform_id = parts[1]
elif len(parts) == 3 and parts[0] == "stock:" + arch:
stock[parts[2]] = parts[1]
elif len(parts) == 3 and parts[0] == arch:
wanted[parts[2]] = parts[1]
return wanted, stock, platform_id
# Where each espressif32 generation keeps the archives we displace. First directory
# holding all four wins, so this resolves without knowing which platform is in play.
FRAMEWORK_LIB_DIRS = (
("framework-arduinoespressif32", "tools/sdk/%s/lib"),
("framework-arduinoespressif32-libs", "%s/lib"),
("framework-arduinoespressif32", "tools/esp32-arduino-libs/%s/lib"),
)
def _framework_lib_dir(platform, arch):
for package, layout in FRAMEWORK_LIB_DIRS:
try:
base = platform.get_package_dir(package)
except Exception:
base = None
if not base:
continue
path = os.path.join(base, *(layout % arch).split("/"))
if all(os.path.isfile(os.path.join(path, name)) for name in REQUIRED):
return path
return None
if os.environ.get("MESHCORE_REDUCED_TLS", "") not in ("1", "true", "yes"):
Return()
project_dir = env.subst("$PROJECT_DIR")
arch = env.BoardConfig().get("build.mcu", "")
if not arch:
_fail("could not determine board MCU, so cannot pick an archive set")
staged = os.path.join(project_dir, ".mbedtls-4k", arch)
if not os.path.isdir(staged):
_fail("no archives for %s at %s" % (arch, staged))
wanted, stock, manifest_platform = _manifest(project_dir, arch)
if not wanted:
_fail("manifest has no entries for arch '%s'" % arch)
for name in REQUIRED:
archive = os.path.join(staged, name)
if not os.path.isfile(archive):
_fail("missing %s" % archive)
if name not in wanted:
_fail("%s is not in the manifest for %s" % (name, arch))
actual = _sha256(archive)
if actual != wanted[name]:
_fail(
"%s does not match the manifest\n expected %s\n actual %s\n"
"Rebuild it for this platform version, or re-run the fetch script."
% (archive, wanted[name], actual)
)
# Bind the staged archives to the framework they were built against. The hashes above
# only prove the staged files are the ones the manifest names; they say nothing about
# whether the manifest is still current. Bump the platform without rebuilding, and
# every check above still passes while the link takes mbedTLS built against a
# different IDF — a struct-layout drift that corrupts silently at runtime. So
# fingerprint the framework's own archives, the ones being displaced: if those moved,
# the staged pair is stale by construction.
platform = env.PioPlatform()
platform_id = "%s@%s" % (platform.name, platform.version)
stock_dir = _framework_lib_dir(platform, arch)
if stock_dir is None:
_fail("cannot locate the framework's own mbedTLS archives for %s, so the staged "
"ones cannot be tied to a framework version" % arch)
if not stock:
_fail("manifest has no stock:%s fingerprints — it predates the framework binding.\n"
"Add these lines for the framework now installed (%s):\n%s"
% (arch, platform_id,
"\n".join("stock:%s %s %s" % (arch, _sha256(os.path.join(stock_dir, n)), n)
for n in REQUIRED)))
for name in REQUIRED:
actual = _sha256(os.path.join(stock_dir, name))
if name not in stock:
_fail("manifest has no stock:%s entry for %s" % (arch, name))
if actual != stock[name]:
_fail(
"the framework's mbedTLS archives are not the ones these were built against.\n"
" %s\n manifest %s\n installed %s\n"
"Manifest records %s; installed is %s.\n"
"Rebuild the reduced-TLS archives against this framework "
"(docs/mbedtls-tls-footprint.md), then update every hash in the manifest."
% (os.path.join(stock_dir, name), stock[name], actual,
manifest_platform or "no platform", platform_id)
)
if manifest_platform and manifest_platform != platform_id:
# Hashes are the check; the version string is orientation. Identical archives
# under a renamed platform are not a compatibility problem.
print("reduced-TLS: manifest says %s, installed is %s — archives match, so this is "
"only a stale label" % (manifest_platform, platform_id))
# Prepend so these satisfy mbedTLS symbols ahead of the framework's own copies:
# the linker takes each archive member from the first archive that resolves it.
env.Prepend(LIBPATH=[staged])
print("reduced-TLS: linking mbedTLS from %s (verified)" % staged)
def _verify_map(source, target, env):
"""Confirm every mbedTLS archive in the link came from our directory.
Fails closed. Anything that stops this from *proving* the link — no map, an
unparsable map, a short archive list — is a failure, not a warning. A warning
here would leave exactly the hole the check exists to close: an opt-in build
that succeeds while silently linking the framework's 16 KiB buffers.
"""
# Derive the map name from PROGNAME rather than hardcoding firmware.map, so a
# renamed program cannot leave us inspecting a stale or absent file.
map_path = os.path.join(env.subst("$BUILD_DIR"),
env.subst("${PROGNAME}") + ".map")
if not os.path.isfile(map_path):
legacy = os.path.join(env.subst("$BUILD_DIR"), "firmware.map")
map_path = legacy if os.path.isfile(legacy) else map_path
if not os.path.isfile(map_path):
print("\n*** reduced-TLS: no linker map at %s ***" % map_path,
file=sys.stderr)
print("Cannot prove the reduced-TLS archives were linked. Ensure the env "
"emits a map (-Wl,-Map), or unset MESHCORE_REDUCED_TLS.",
file=sys.stderr)
env.Exit(1)
return
# The map records whatever the linker was given, which for a -L hit is a path
# relative to the linker's cwd (the project dir). Resolve before comparing, or
# every one of our own archives reads as stray.
staged_real = os.path.realpath(staged)
stray = set()
seen = set()
with open(map_path, errors="replace") as fh:
for line in fh:
for token in line.split():
if "libmbed" not in token or ".a" not in token:
continue
path = token.split("(")[0]
base = os.path.basename(path)
if not base.startswith("libmbed") or not base.endswith(".a"):
continue
seen.add(base)
resolved = os.path.realpath(os.path.join(project_dir, path))
if os.path.dirname(resolved) != staged_real:
stray.add(path)
if stray:
print("\n*** reduced-TLS: archives linked from the WRONG place ***",
file=sys.stderr)
for path in sorted(stray):
print(" " + path, file=sys.stderr)
env.Exit(1)
return
# Every required archive must appear. Seeing only some of them means the rest
# resolved somewhere this parse did not recognise, which is not proof of anything.
missing = [name for name in REQUIRED if name not in seen]
if missing:
print("\n*** reduced-TLS: %s names only %d of %d archives ***"
% (os.path.basename(map_path), len(seen), len(REQUIRED)),
file=sys.stderr)
print(" missing: " + ", ".join(missing), file=sys.stderr)
print("Either the map format changed or mbedTLS was resolved elsewhere; "
"the reduced buffers cannot be assumed.", file=sys.stderr)
env.Exit(1)
return
print("reduced-TLS: confirmed all %d archives linked from %s"
% (len(REQUIRED), staged))
env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _verify_map)
+23
View File
@@ -0,0 +1,23 @@
# Reduced-TLS mbedTLS archives (OUT_CONTENT_LEN 4096, IN_CONTENT_LEN 16384).
# Format: <arch> <sha256> <filename>
#
# Rebuild these for every espressif32 platform bump — the archives must match the
# rest of the framework they link against. Built as of platformio/espressif32@6.11.0
# from the recipe in docs/mbedtls-tls-footprint.md.
#
# Fetch with: scripts/fetch_mbedtls_4k.sh esp32s3
esp32s3 01629f635b33ffa2c1fdfcd8ac52327cd3a92e8d4e7c9a32d92974e0cfdfe398 libmbedcrypto.a
esp32s3 07e7a09847589fefc35bc8d2f739d556535d535acd7185a9824b2af8edd5b05a libmbedtls_2.a
esp32s3 e12bcc8d76a368e819987f266e73c265178d6b6675f05d0e29014bb543c402af libmbedtls.a
esp32s3 c1e10324e19f6d7763737f72d9032cec832bdfce60c7a7d381a5fcfc7dcad573 libmbedx509.a
# Framework binding, read by scripts/mbedtls_4k.py and ignored by the fetch script.
# "stock:" lines fingerprint the framework's own archives — the ones the entries above
# displace. They are what makes "rebuild on every platform bump" enforceable instead of
# advisory: if the framework's copies move, the entries above are stale by construction
# and the build stops. Regenerate both sets together, never one alone.
platform espressif32@6.11.0
stock:esp32s3 abdaf759ee17aa697468427b55a86c6c0082ac4cdeb643a63d8b3ac2df324633 libmbedcrypto.a
stock:esp32s3 9a580d2ff7c885e1bf59479a14422e3d10a485ae9083acb5db943df714c0ac35 libmbedtls_2.a
stock:esp32s3 a0f331245feb5cf4fe8d9f99b03d34d64f10e38d8a8d680e35b34ec82aa3cf9e libmbedtls.a
stock:esp32s3 3d56277224b066118b6c48a973ae18fdf8078448bf54b482ac5557699e52f223 libmbedx509.a
+167 -23
View File
@@ -12,9 +12,17 @@ that come back an error, so the two stay honest about each other.
python3 scripts/webconfig_mock_server.py --port 8137 &
python3 scripts/webconfig_cli_audit.py
Board-specific commands (Board::handleCommand) are offered only when the node
says it answers them, so one run exercises one shape of board. The mock claims
none by default, like a Heltec V3; drive the other shapes with --board-cmds:
python3 scripts/webconfig_mock_server.py --port 8137 \
--board-cmds radio.fem.rxgain,radio.fem.txgain,fan &
Exits non-zero if anything fails that is not in EXPECTED_FAILURES. Stdlib only.
"""
import glob
import json
import os
import re
@@ -30,12 +38,7 @@ INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html")
# Errors that are the correct answer, not a gap.
EXPECTED_FAILURES = {
# Runtime-gated on the real device by Board::canControlLoRaFemLna(); the
# command exists in every build and the board answers for itself. The mock
# board is a Heltec V3, which has no front-end module.
"get radio.fem.rxgain": "unsupported",
"set radio.fem.rxgain on": "unsupported",
# Guarded by the firmware the same way when no alert PSK is configured.
# Guarded by the firmware when no alert PSK is configured.
"alert test": "not configured",
}
@@ -45,8 +48,11 @@ SKIP = {"reboot", "clkreboot", "poweroff", "shutdown", "erase", "start ota",
"log", "get acl"} # Listings use /api/terminal, not this bounded API audit.
def table():
"""The commands autocomplete offers, read straight out of the page."""
def table(board_cmds=()):
"""The commands autocomplete offers, read straight out of the page.
`board_cmds` is what /api/status said this board answers; the CLI_BOARD_KEYS
entries gated on anything else are not offered and so not driven."""
html = open(INDEX_HTML, encoding="utf-8").read()
def section(start, end):
@@ -54,15 +60,25 @@ def table():
verbs = re.findall(r'\["([^"]+)","', section("var CLI_VERBS=", "var CLI_KEYS="))
keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)',
section("var CLI_KEYS=", "var CLI_SLOT="))
section("var CLI_KEYS=", "var CLI_BOARD_KEYS="))
fields = re.findall(r'\["(\w+)","', section("var CLI_SLOT=", "var CLI_TYPES="))
gets = ["get " + k for k, mode in keys if mode != "2"]
gets += ["get mqtt%d.%s" % (n, f) for n in (1, 3) for f in fields]
# Verbs taking an argument need a value the node will accept; those are
# covered by the round-trip probes below rather than guessed at here.
gets += ["get " + k for gate, k, mode in board_table()
if mode != "2" and gate in board_cmds]
plain = [v for v in verbs if not v.endswith(" ") and v not in SKIP]
return gets + plain
return [command for command in gets if command not in SKIP] + plain
def board_table():
"""[(gate, key, mode)] from CLI_BOARD_KEYS — the keys the page offers only
when /api/status says this board answers for their gate."""
html = open(INDEX_HTML, encoding="utf-8").read()
section = html[html.index("var CLI_BOARD_KEYS="):html.index("// Per-slot keys")]
return re.findall(r'\["([^"]+)","([^"]+)","(?:[^"\\]|\\.)*",(\d)', section)
# Where top-level commands are implemented. MyMesh handles a few before
@@ -74,15 +90,74 @@ COMMAND_SOURCES = [
"examples/simple_repeater/MyMesh.cpp",
]
# Firmware commands the table deliberately does not offer. Everything below
# except tls.bundletest is also rejected by /api/cli (wcCliUnavailable), so the
# portal never pretends to run something it cannot.
# Board::handleCommand() is dispatched BEFORE CommonCLI's own table, and the FEM
# commands now live there rather than in CommonCLI. A board file contributes
# whole commands ("get radio.fem.rxgain"), not the bare verbs the sources above
# yield, so the two are collected separately and merged.
BOARD_SOURCES = "variants/*/*Board.cpp"
# Firmware commands the table deliberately does not offer.
NOT_OFFERED = {
"tls.bundletest", # TLS debugging, not an operator command
"start ota", # binds port 80, which the portal is already using
"stop ota", # nothing to stop: `start ota` cannot run from here
"clock sync", # takes its time from the caller; a web request has none
"get acl", # requires the streaming terminal endpoint
}
# Of those, the ones /api/cli does NOT reject at POST (wcCliUnavailable). They
# are left out of the table rather than blocked, because running them is
# harmless — `stop ota` just reports that no OTA server is running, which is
# always true here. Everything else in NOT_OFFERED must come back a 400 with a
# reason, so the portal never pretends to run something it cannot.
NOT_REFUSED = {"tls.bundletest", "stop ota"}
# The local firmware has additional management and diagnostics commands. They
# remain available for manual entry in the terminal; its compact autocomplete
# table intentionally omits them. Keep the board-command parity check focused
# on controls the page actually offers.
ADVANCED_UNLISTED = {
"clear recent.repeater", "clock.sync.mesh now",
"get battery.alert", "get battery.alert.critical",
"get battery.alert.low", "get battery.alert.region", "get clock.sync",
"get clock.sync.drift", "get clock.sync.internet", "get clock.sync.mesh",
"get clock.sync.mesh.edge", "get clock.sync.samples", "get flood.channel.data",
"get flood.channel.data.hops", "get host", "get outpath path",
"get recent.repeater", "get recent.repeaters", "get rx.watchdog",
"get tempradio", "powerlog", "send text.flood", "set battery.alert",
"set battery.alert.critical", "set battery.alert.low", "set clock.sync.drift",
"set clock.sync.internet", "set clock.sync.mesh", "set clock.sync.mesh.edge",
"set clock.sync.samples", "set outpath path", "set rx.watchdog",
"set tempradio",
}
def webconfig_variants():
"""Variant directories whose build serves the portal (ESP32 + MQTT bridge).
Only these boards can put a command in front of this page; a board command
on an nRF52 variant is real but unreachable from here, so it is not a gap.
"""
out = set()
for ini in glob.glob(os.path.join(HERE, "..", "variants", "*", "platformio.ini")):
if "WITH_MQTT_BRIDGE" in open(ini, encoding="utf-8").read():
out.add(os.path.basename(os.path.dirname(ini)))
return out
def board_commands():
"""Whole commands Board::handleCommand() answers, across portal variants."""
variants = webconfig_variants()
found = set()
for path in glob.glob(os.path.join(HERE, "..", *BOARD_SOURCES.split("/"))):
if os.path.basename(os.path.dirname(path)) not in variants:
continue
src = open(path, encoding="utf-8").read()
body = src[src.find("::handleCommand"):]
for lit in re.findall(r'(?:mem|str)n?cmp\(\s*command\s*,\s*"([^"]+)"', body):
found.add(lit.strip())
return found
def firmware_commands():
"""Top-level command literals the firmware dispatches on."""
@@ -95,7 +170,7 @@ def firmware_commands():
continue
for lit in re.findall(r'(?:mem|str)n?cmp\(\s*command\s*,\s*"([^"]+)"', src):
found.add(lit.strip())
return found - NOT_OFFERED
return (found | board_commands()) - NOT_OFFERED - ADVANCED_UNLISTED
ROUND_TRIPS = [
("set mqtt1.port 0", "get mqtt1.port", "0"),
("set usb.logging on", "get usb.logging", "on"),
@@ -126,6 +201,29 @@ ROUND_TRIPS = [
]
# Gated on /api/status's board_cmds, keyed by the gate the page probes for.
BOARD_ROUND_TRIPS = {
"radio.fem.rxgain": [("set radio.fem.rxgain off", "get radio.fem.rxgain", "off")],
"radio.fem.txgain": [("set radio.fem.txgain on", "get radio.fem.txgain", "on")],
"fan": [("set fan on", "get fan", "on 41.0C fan=on cd=0s")],
}
# Board commands with no getter to read back, so a round-trip cannot reach them.
# Run in order, and drive the rejection path too: a set-only key that silently
# accepted anything would look identical to one that works.
# [(command, should_succeed, expected substring of the reply)]
BOARD_SET_PROBES = {
"fan": [
("set fan.lo 40", True, "OK"),
("set fan.hi 70", True, "OK"),
("set fan.lo 200", False, "0..100"), # out of range
("set fan.lo 90", False, "< fan.hi"), # crosses the upper bound
("set fan.hi 10", False, "> fan.lo"), # crosses the lower bound
("set fan.hi 130", False, "<= 120"), # out of range
],
}
class Client:
def __init__(self, base):
self.base = base
@@ -133,7 +231,19 @@ class Client:
self.cookie = r.headers["Set-Cookie"].split(";")[0]
# The node caps a sequence at MAX_BATCH and reports it; chunk to match
# rather than hardcoding a number that drifts when the slot is resized.
self.max_cmds = json.load(self._open("/api/status")).get("max_cmds", 24)
status = json.load(self._open("/api/status"))
self.max_cmds = status.get("max_cmds", 24)
# Board::handleCommand() commands this node probed for at startup. The
# field is absent until the probe has run, which is NOT the same as an
# empty list, so wait for it rather than audit a board half-known.
for _ in range(20):
if "board_cmds" in status:
break
time.sleep(0.25)
status = json.load(self._open("/api/status"))
else:
sys.exit("node never reported board_cmds; it has not probed its board")
self.board_cmds = [c for c in status["board_cmds"].split(",") if c]
def _open(self, path, data=None):
headers = {"Content-Type": "application/json"}
@@ -186,7 +296,7 @@ def main():
failures = []
cmds = table()
cmds = table(cli.board_cmds)
unexpected = []
for cmd, res in cli.run(cmds):
if res["ok"]:
@@ -196,6 +306,7 @@ def main():
continue
unexpected.append((cmd, res["reply"]))
print("commands offered by autocomplete : %d" % len(cmds))
print("board commands this node answers : %s" % (", ".join(cli.board_cmds) or "none"))
print("answered : %d" % (len(cmds) - len(unexpected)))
print("sequence cap reported by the node: %d" % cli.max_cmds)
for cmd, reply in unexpected:
@@ -205,17 +316,39 @@ def main():
# The reverse direction: a command the firmware implements but the table
# never offers is invisible to the check above, because the check only ever
# drives what the table already knows about.
offered = " ".join(cmds) + " " + " ".join(
re.findall(r'\["([^"]+)","', open(INDEX_HTML, encoding="utf-8").read()))
# A board command is a whole `get`/`set` line, so the get-only list the audit
# drives cannot decide it is offered: `set fan.lo` has no `get` counterpart in
# the table at all, and a board key this mock does not claim is absent from
# `cmds` while still being offerable. Both are expanded from the page here.
html = open(INDEX_HTML, encoding="utf-8").read()
keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)',
html[html.index("var CLI_KEYS="):html.index("var CLI_BOARD_KEYS=")])
offered = " ".join(cmds) + " " \
+ " ".join("set " + k for k, mode in keys if mode != "1") + " " \
+ " ".join("get %s set %s" % (k, k) for _, k, _ in board_table()) + " " \
+ " ".join(re.findall(r'\["([^"]+)","', html))
missing = sorted(c for c in firmware_commands() if c not in offered)
print("\nfirmware commands not in the table: %d" % len(missing))
for c in missing:
print(" MISSING %s" % c)
failures += [(c, "not offered by autocomplete") for c in missing]
results = cli.run([c for probe in ROUND_TRIPS for c in probe[:2]])
print("\nround-trips : %d" % len(ROUND_TRIPS))
for i, (setc, getc, want) in enumerate(ROUND_TRIPS):
# The page asks the node for each gate by name (`get <gate>`), so a gate that
# no board getter answers would hide its keys on every board, silently.
fw = firmware_commands()
gates = sorted({gate for gate, _, _ in board_table()})
bad_gates = [g for g in gates if "get " + g not in fw]
print("\nboard-command gates : %d" % len(gates))
for g in bad_gates:
print(" FAIL %-30s no board answers `get %s`" % (g, g))
failures += [(g, "gate has no getter") for g in bad_gates]
probes = list(ROUND_TRIPS)
for gate in cli.board_cmds:
probes += BOARD_ROUND_TRIPS.get(gate, [])
results = cli.run([c for probe in probes for c in probe[:2]])
print("\nround-trips : %d" % len(probes))
for i, (setc, getc, want) in enumerate(probes):
setr, getr = results[i * 2][1], results[i * 2 + 1][1]
# `get` answers "> value"; compare the value, as the terminal displays it
got = re.sub(r"^>\s?", "", getr["reply"])
@@ -225,10 +358,20 @@ def main():
% (getc, got, want, setr["reply"]))
failures.append((getc, got))
# Set-only board commands, which no round-trip can reach.
probes = [p for gate in cli.board_cmds for p in BOARD_SET_PROBES.get(gate, [])]
if probes:
print("\nset-only board commands : %d" % len(probes))
for (cmd, want_ok, want), (_, res) in zip(probes, cli.run([p[0] for p in probes])):
if res["ok"] == want_ok and want in res["reply"]:
continue
print(" FAIL %-30s %s" % (cmd, res["reply"]))
failures.append((cmd, res["reply"]))
# Commands the portal refuses must be refused clearly, not run and fudged.
print("\nrefused with a reason : ", end="")
refused = []
for cmd in sorted(NOT_OFFERED - {"tls.bundletest"}):
for cmd in sorted(NOT_OFFERED - NOT_REFUSED):
try:
cli._sequence([cmd])
refused.append((cmd, "was accepted, expected a 400"))
@@ -236,7 +379,8 @@ def main():
body = json.load(e) if e.code == 400 else {}
if e.code != 400 or not body.get("error"):
refused.append((cmd, "HTTP %d, expected 400 with a reason" % e.code))
print("%d/%d" % (len(NOT_OFFERED) - 1 - len(refused), len(NOT_OFFERED) - 1))
checked = len(NOT_OFFERED) - len(NOT_REFUSED)
print("%d/%d" % (checked - len(refused), checked))
for cmd, why in refused:
print(" FAIL %-30s %s" % (cmd, why))
failures += refused
+95 -2
View File
@@ -103,6 +103,7 @@ def default_config(setup_mode):
"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,
"fem_rxgain": False, "fem_txgain": False,
"rxps_enabled": True, "rxps_level": 8, "rxps_preamble": 16,
"rxps_rx_us": 18205, "rxps_sleep_us": 20423,
"powersaving": True,
@@ -157,6 +158,10 @@ class State:
self.lock = threading.Lock()
self.setup_mode = args.setup
self.active_slots = args.active_slots
# Board::handleCommand() commands this "board" answers. Empty by default:
# the mock is a Heltec V3, which implements no such hook at all.
self.board_cmds = [c for c in args.board_cmds.split(",") if c]
self.probe_delay = args.probe_delay
self.cfg = default_config(args.setup)
# latched at AP start, like WebConfigServer::_initial_setup
self.initial_setup = args.setup and self.cfg["wifi"]["ssid"] == ""
@@ -188,7 +193,7 @@ class State:
return c
def status_json(self, authed):
return {
out = {
"mode": "setup" if self.setup_mode else "lan",
"auth": authed,
"needs_setup": self.cfg["wifi"]["ssid"] == "",
@@ -207,6 +212,12 @@ class State:
"capabilities": 0x37FF,
"max_cmds": CLI_MAX_CMDS,
}
# ABSENT, not empty, until the board has been probed: the node's routes
# go live a tick before probeBoardCommands() runs, and the page has to
# retry rather than latch "this board has no board commands".
if time.time() - self.start >= self.probe_delay:
out["board_cmds"] = ",".join(self.board_cmds)
return out
# ---------------------------------------------------------------------------
@@ -282,7 +293,7 @@ def apply_set(cfg, key, val):
ADMIN_PASSWORD = val
return True, "OK"
if key == "radio.fem.rxgain":
if key == "radio.fem.rxgain" and key not in ST.board_cmds:
return False, "Error: unsupported" # no FEM on the mock board, see GETTERS
if key == "radio.rxps":
@@ -406,13 +417,74 @@ def apply_set(cfg, key, val):
sec, f = STR_KEYS[key]
cfg[sec][f] = val
return True, "OK"
gate = BOARD_CMD_GATE.get(key)
if gate:
if gate not in ST.board_cmds:
return False, "unknown config: %s" % key # no such command on this board
return apply_board_set(cfg, key, val)
# Strict fallthrough: this function is the single authority on what can be
# set, for the batch and the CLI alike. Accepting unknown keys here once hid
# the fact that the CLI could not reach `dutycycle` or `radio.fem.rxgain`.
# Verbatim shape from CommonCLI::handleSetCmd's fallthrough.
#
# This is also where the board-specific commands land. `radio.fem.*` and
# `fan*` come from Board::handleCommand(), which is dispatched ahead of
# CommonCLI; the mock board is a Heltec V3 and implements no such hook, so
# they reach this fallthrough exactly as they do on the real thing.
return False, "unknown config: %s" % key
# Commands that reach Board::handleCommand() rather than CommonCLI. Which ones a
# node answers is a property of the board, so the portal probes for them at
# startup and reports the answers in /api/status; --board-cmds picks which ones
# this mock claims. `fan.lo` / `fan.hi` have no getter and ride on `fan`.
BOARD_CMD_GATE = {
"radio.fem.rxgain": "radio.fem.rxgain",
"radio.fem.txgain": "radio.fem.txgain",
"fan": "fan",
"fan.lo": "fan",
"fan.hi": "fan",
}
BOARD_STATE = {"fan": "auto", "fan.lo": 45, "fan.hi": 60}
FEM_FIELD = {"radio.fem.rxgain": "fem_rxgain", "radio.fem.txgain": "fem_txgain"}
def apply_board_set(cfg, key, val):
if key in FEM_FIELD:
if val not in ("on", "off"):
return False, "Error: state must be on or off"
cfg["radio"][FEM_FIELD[key]] = val == "on"
return True, "OK - LoRa FEM %s gain %s" % ("RX" if "rx" in key else "TX", val)
if key == "fan":
if val not in ("on", "off", "auto"):
return False, "Error: fan must be on, off, or auto"
BOARD_STATE["fan"] = val
return True, "OK - fan %s" % val
try:
n = int(val)
except ValueError:
return False, "Error: expected a number"
if key == "fan.lo" and not (0 <= n <= 100 and n < BOARD_STATE["fan.hi"]):
return False, "Error: fan.lo must be 0..100 and < fan.hi"
if key == "fan.hi" and not (BOARD_STATE["fan.lo"] < n <= 120):
return False, "Error: fan.hi must be > fan.lo and <= 120"
BOARD_STATE[key] = n
return True, "OK - %s %d" % (key, n)
def board_get(cfg, key):
"""Getter reply, or None when this board does not answer the command."""
if BOARD_CMD_GATE.get(key) not in ST.board_cmds:
return None
if key == "fan":
return "%s 41.0C fan=%s cd=0s" % (
BOARD_STATE["fan"], "on" if BOARD_STATE["fan"] == "on" else "off")
if key in FEM_FIELD:
return "on" if cfg["radio"][FEM_FIELD[key]] else "off"
return None # fan.lo / fan.hi are set-only on the board too
# Payload-type names accepted alongside the decimal form. Mirrors
# namedPacketTypes() in src/helpers/MQTTPacketFilter.h; 12-14 are reserved
# upstream and stay reachable by number only.
@@ -552,6 +624,12 @@ GETTERS = {
"wifi.status": lambda c: (
"SSID: %s\nIP: 192.168.1.42\nRSSI: -58 dBm\nUptime: %dm"
% (c["wifi"]["ssid"] or "(not set)", int(time.time() - ST.start) // 60)),
"link.status": lambda c: (
"wifi: connected, IP: 192.168.1.42, RSSI: -58 dBm, Uptime: %dm"
% (int(time.time() - ST.start) // 60)),
"link.diag": lambda c: (
"why:ethernet-not-enabled selected:wifi\n"
"wifi:state:connected ip:192.168.1.42"),
"mqtt.status": lambda c: cli_mqtt_status(c),
"mqtt.presets": lambda c: "\n".join(
"%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd)
@@ -599,6 +677,11 @@ def cli_get(cfg, key):
def _cli_get_value(cfg, key):
if key in BOARD_CMD_GATE:
val = board_get(cfg, key)
# Not answered: the board has no hook for it, so CommonCLI's own getter
# fallthrough is what replies — exactly as on the real thing.
return (True, val) if val is not None else (False, "??: %s" % key)
if key in GETTERS:
val = GETTERS[key](cfg)
return (True, val) if val is not None else (False, "Error: unsupported")
@@ -633,6 +716,10 @@ def cli_read_key(cfg, key):
"on" if r["rxps_enabled"] else "off", r["rxps_rx_us"], r["rxps_sleep_us"]),
"bw": r["bw"], "sf": r["sf"], "cr": r["cr"],
"mqtt.iata": cfg["mqtt"]["iata"], "mqtt.owner": cfg["mqtt"]["owner"],
"display.mode": "button", "display.timeout": "15",
"display.usb.mode": "button", "display.usb.timeout": "15",
"display.inbox": "off", "bluetooth.mac": "",
"bluetooth.stealth": "off",
}.get(key)
@@ -1110,6 +1197,12 @@ def main():
ap.add_argument("--port", type=int, default=8080)
ap.add_argument("--setup", action="store_true", help="first-boot setup wizard mode")
ap.add_argument("--active-slots", type=int, default=5, help="server slots to expose (2 or 5)")
ap.add_argument("--probe-delay", type=float, default=0.0,
help="seconds to withhold board_cmds from /api/status, simulating "
"the gap before the node has probed its board")
ap.add_argument("--board-cmds", default="",
help="comma list of Board::handleCommand() commands to answer, e.g. "
"radio.fem.rxgain,radio.fem.txgain,fan (default: none, like a Heltec V3)")
ap.add_argument("--fw-version", default=FW_VERSION,
help="version string to report, shaped like build.sh's embedded one")
ap.add_argument("--minify", action="store_true",
+2 -2
View File
@@ -8,8 +8,8 @@ protected:
CommonRadioPrefs() { }
public:
void markDirty() { _is_dirty = true; }
void clearDirty() { _is_dirty = false; }
bool isDirty() const { return _is_dirty; }
void clearDirty() override { _is_dirty = false; }
bool isDirty() const override { return _is_dirty; }
virtual float getFreq() const = 0;
virtual void setFreq(float f) = 0;
+15 -4
View File
@@ -33,13 +33,24 @@
// 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
// allocated 1024-byte buffer with a (WS_BUFFER_SIZE + 1)-byte one, so the
// out-of-bounds index WS_BUFFER_SIZE lands on our extra byte instead of the
// heap canary. 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.
//
// Scope: this stops the heap corruption and NOTHING else. It does not make an
// oversized response fail the handshake — v4.4's read loop also exits on
// `header_len < WS_BUFFER_SIZE` going false, and then still accepts the
// connection if "Sec-WebSocket-Accept:" was inside those first bytes (it comes
// early, so it usually is). ws_connect() therefore returns success on a partial
// header block and the unread remainder arrives as the first "payload" read,
// where the deframer parses HTTP bytes as a frame header. Observed on hardware
// 2026-08-12: a large Cloudflare CSP header produced exactly that, surfacing as
// `Invalid MSG_TYPE response: 3`. Only IDF 5.2+ fixes it (it requires the
// "\r\n\r\n" delimiter, preserves the bytes after it, and fails cleanly when the
// buffer fills); it cannot be patched here because transport_ws.c is precompiled.
//
// 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
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <stdint.h>
// What a slot's MQTT client is doing, as opposed to whether its network is up,
// and the two decisions that hang off it.
//
// Pure logic: no Arduino, esp-mqtt or bridge dependency, so the shutdown
// contract below is a host test rather than a hardware run.
//
// `client->connected()` answers "is the network up" and was being used for "is
// there anything to stop": a client resolving DNS, negotiating TLS or waiting
// after a failed CONNECT reports not-connected, so teardown skipped it and left
// its task running (F04).
enum class MqttClientState : uint8_t {
Absent = 0, // no client object
Configured, // client allocated, never started
Starting, // start/reconnect requested, awaiting CONNECTED
Connected, // CONNECTED received
Disconnected, // started, no session (our reconnect ladder governs it)
Stopped, // stop completed; the SDK task is joined and gone
Quarantined, // stop failed: the SDK task was NOT joined. Never destroy,
// never reuse, never free anything it still points at.
};
static inline const char* mqttClientStateName(MqttClientState s) {
switch (s) {
case MqttClientState::Absent: return "absent";
case MqttClientState::Configured: return "configured";
case MqttClientState::Starting: return "starting";
case MqttClientState::Connected: return "connected";
case MqttClientState::Disconnected: return "disconnected";
case MqttClientState::Stopped: return "stopped";
case MqttClientState::Quarantined: return "quarantined";
}
return "?";
}
// The client has been started and not proven stopped: it may still own a task,
// a socket and a TLS context. Quarantined is deliberately NOT live — it is
// worse: it may own them and we can never find out.
static inline bool mqttClientStateIsLive(MqttClientState s) {
return s == MqttClientState::Starting || s == MqttClientState::Connected ||
s == MqttClientState::Disconnected;
}
// A connection attempt is in flight, so an event for it can still arrive.
// Closing the transport is not enough here: PsychicMqttClient::softDisconnect()
// returns immediately when the client is not yet connected, which means an
// in-progress DNS/TLS/CONNECT is left to complete on its own. Cancelling one
// requires a real stop.
static inline bool mqttClientStateHasAttemptInFlight(MqttClientState s) {
return s == MqttClientState::Starting;
}
// The SDK task is known to be gone (or never existed), so the object may be
// destroyed and anything it pointed at may be freed.
static inline bool mqttClientStateIsProvenStopped(MqttClientState s) {
return s == MqttClientState::Absent || s == MqttClientState::Configured ||
s == MqttClientState::Stopped;
}
// The bridge's cooperative stop may only be acknowledged when EVERY client is
// proven stopped.
//
// This is the invariant the whole shutdown contract rests on: the owner treats
// the acknowledgement as proof that the task destroyed its clients, and only
// then frees the queue and buffers and allows a restart. One client whose
// esp_mqtt_client_stop() never completed makes that false — its SDK task may
// still be running — so the acknowledgement must be withheld and the bridge
// left in its unproven state, however many other slots stopped cleanly.
static inline bool mqttStopMayBeAcknowledged(const MqttClientState* states, int count) {
// An empty set is trivially proven (no slots, nothing to stop); a null array
// with a nonzero count is a caller bug and must not read as proof.
if (count <= 0) return true;
if (states == nullptr) return false;
for (int i = 0; i < count; i++) {
if (!mqttClientStateIsProvenStopped(states[i])) return false;
}
return true;
}
+213
View File
@@ -0,0 +1,213 @@
#pragma once
#include <stdint.h>
#include <string.h>
// The complete, owned description of what a slot's MQTT client should be
// configured with, plus the decision of whether the existing SDK client can be
// reconfigured in place or has to be recreated.
//
// Pure logic: no Arduino, esp-mqtt or PsychicMqttClient dependency, so every
// transition in the table below is exercised on the host
// (test/test_mqtt_effective_config).
//
// Why this exists (F02/F03). The bridge used to apply configuration field by
// field, skipping the cleanup of stale fields whenever
// `slot.initial_connect_done` was false — which `teardownSlot()` had just
// cleared, so normal live reconfiguration always skipped it. On hardware that
// sent the previous broker's JWT username in the CONNECT to a newly configured
// anonymous endpoint. Two rules fix it:
//
// 1. Every field is written on every apply. "Absent" is a value that gets
// written (the empty string), never an omission — IDF's
// `esp_mqtt_set_if_config()` treats NULL as "leave unchanged", so a
// cleared wrapper pointer cannot erase an SDK-held credential, while an
// empty string both overwrites it and leaves the CONNECT's username flag
// clear (verified on an ESP32-S3 against a local broker, IDF 4.4).
// 2. Where a field cannot be overwritten safely, the client is recreated.
// That is the exception, not the rule: esp-mqtt's stop/start cycle is the
// fork's documented internal-heap fragmentation driver, so recreation must
// never happen on the reconnect or token-renewal path.
// Matches MQTTSlot::broker_uri so an effective config can hold its own copy.
#define MQTT_EFFECTIVE_URI_MAX 128
enum class MqttTransport : uint8_t {
Unknown = 0,
Tcp, // mqtt://
Tls, // mqtts://
Ws, // ws://
Wss, // wss://
};
enum class MqttTrust : uint8_t {
Plaintext = 0, // unencrypted transport: no server verification exists
Bundle, // shared CA bundle attach callback
PemCert, // one specific CA certificate
};
enum class MqttAuth : uint8_t {
None = 0,
UserPass,
Jwt,
};
struct MqttEffectiveConfig {
char uri[MQTT_EFFECTIVE_URI_MAX] = {0};
MqttTransport transport = MqttTransport::Unknown;
MqttTrust trust = MqttTrust::Plaintext;
MqttAuth auth = MqttAuth::None;
// Borrowed pointers with a lifetime at least as long as the client's: preset
// certificates live in flash, credentials in the slot/bridge storage.
const char* pem = nullptr;
const char* username = nullptr; // nullptr == absent, written as ""
const char* password = nullptr;
uint16_t buffer_size = 0;
uint16_t keepalive = 0;
bool valid = false; // false until successfully built
};
// Client certificates are deliberately absent: `setClientCertificate()` has no
// caller in the firmware (no broker preset uses mutual TLS), so modelling it
// here would be untested configuration surface. Adding mutual TLS means adding
// it to this struct and to the recreate decision below.
static inline MqttTransport mqttTransportFromUri(const char* uri) {
if (uri == nullptr) return MqttTransport::Unknown;
if (strncmp(uri, "mqtts://", 8) == 0) return MqttTransport::Tls;
if (strncmp(uri, "mqtt://", 7) == 0) return MqttTransport::Tcp;
if (strncmp(uri, "wss://", 6) == 0) return MqttTransport::Wss;
if (strncmp(uri, "ws://", 5) == 0) return MqttTransport::Ws;
return MqttTransport::Unknown;
}
static inline bool mqttTransportIsEncrypted(MqttTransport t) {
return t == MqttTransport::Tls || t == MqttTransport::Wss;
}
static inline const char* mqttTransportName(MqttTransport t) {
switch (t) {
case MqttTransport::Tcp: return "mqtt";
case MqttTransport::Tls: return "mqtts";
case MqttTransport::Ws: return "ws";
case MqttTransport::Wss: return "wss";
default: return "?";
}
}
// A credential the SDK must be told to forget is written as "" rather than
// left NULL. See rule 1 above.
static inline const char* mqttFieldOrEmpty(const char* v) { return v ? v : ""; }
// Builds an effective config, deriving the transport from the URI and
// normalising the trust policy against it: an unencrypted transport verifies
// nothing, whatever certificate material was requested, so recording anything
// else would make a plaintext endpoint look like a trust change away from a
// verified one (and vice versa). Returns false for an empty or unrecognised
// URI, leaving *out invalid.
static inline bool mqttBuildEffectiveConfig(const char* uri,
MqttAuth auth,
const char* username,
const char* password,
MqttTrust requested_trust,
const char* pem,
uint16_t buffer_size,
uint16_t keepalive,
MqttEffectiveConfig* out) {
if (out == nullptr) return false;
*out = MqttEffectiveConfig();
if (uri == nullptr || uri[0] == '\0') return false;
if (strlen(uri) >= MQTT_EFFECTIVE_URI_MAX) return false;
const MqttTransport transport = mqttTransportFromUri(uri);
if (transport == MqttTransport::Unknown) return false;
strncpy(out->uri, uri, MQTT_EFFECTIVE_URI_MAX - 1);
out->uri[MQTT_EFFECTIVE_URI_MAX - 1] = '\0';
out->transport = transport;
out->auth = auth;
out->username = username;
out->password = password;
out->buffer_size = buffer_size;
out->keepalive = keepalive;
if (!mqttTransportIsEncrypted(transport)) {
out->trust = MqttTrust::Plaintext;
out->pem = nullptr;
} else if (requested_trust == MqttTrust::PemCert && pem != nullptr) {
out->trust = MqttTrust::PemCert;
out->pem = pem;
} else if (requested_trust == MqttTrust::Bundle) {
out->trust = MqttTrust::Bundle;
} else {
// Encrypted transport with no usable trust material. Recorded as Plaintext
// trust so it is never confused with a verified policy; whether to connect
// at all is the caller's decision.
out->trust = MqttTrust::Plaintext;
}
out->valid = true;
return true;
}
struct MqttRecreateDecision {
bool recreate = false;
const char* reason = "reuse"; // static literal, for logs and tests
};
// Whether the SDK client that `applied` was written to can be reconfigured in
// place to reach `desired`.
//
// transport (scheme) change -> recreate. One `wss`->`mqtt` transition was
// observed working by reuse on hardware, but that proves one direction
// once, not that no websocket transport state survives the switch; the
// retained-`frame_state` bug class is real and unfixed upstream even in
// IDF 5.3, and this transition only happens when an operator changes the
// endpoint.
// trust change -> recreate. `cert_pem` cannot be cleared by
// writing "" (an empty PEM is a parse failure, not "no certificate"), and
// the bundle attach is a function pointer whose set_config semantics are
// not established on this build. Verification policy is the one field
// where a stale value silently weakens security.
// capacity growth -> recreate. IDF 4.4 allocates the MQTT buffers
// in `esp_mqtt_client_init()` and `esp_mqtt_set_config()` does not resize
// them; the wrapper's own reassembly buffer is likewise allocated once.
// everything else -> reuse, including every credential and
// auth-mode change, because those are rewritten unconditionally.
static inline MqttRecreateDecision mqttConfigRecreateDecision(
const MqttEffectiveConfig& applied,
const MqttEffectiveConfig& desired,
uint16_t allocated_buffer_size) {
MqttRecreateDecision d;
if (!desired.valid) {
d.recreate = false;
d.reason = "invalid-desired";
return d;
}
if (!applied.valid) {
// Nothing applied yet: a fresh client needs configuring, not recreating.
d.reason = "first-apply";
return d;
}
if (applied.transport != desired.transport) {
d.recreate = true;
d.reason = "transport-change";
return d;
}
if (applied.trust != desired.trust) {
d.recreate = true;
d.reason = "trust-change";
return d;
}
if (desired.trust == MqttTrust::PemCert && applied.pem != desired.pem) {
d.recreate = true;
d.reason = "ca-cert-change";
return d;
}
if (desired.buffer_size > allocated_buffer_size) {
d.recreate = true;
d.reason = "buffer-growth";
return d;
}
return d;
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "MQTTPresets.h"
#include <string.h>
// Policy that keys off a preset rather than describing one.
//
// MQTTPresets.h is data: it is the broker table, and it is kept byte-identical
// between the observer-firmware and observer-firmware-dev channels so a node
// that rolls back cannot meet a preset name its firmware does not know. An
// unknown name is not merely ignored — MQTTPrefsSerializer repairs it to "none"
// and the repaired /mqtt.json is written back, so the operator's slot is gone
// for good. Channel-specific behaviour therefore lives here instead, where the
// two channels are free to differ.
// True when the broker tears down a live session once its JWT passes exp, so the
// renewal must proactively bounce the connection to present a fresh token.
//
// Default true, because getting this wrong the safe way costs a re-handshake and
// getting it wrong the unsafe way costs an outage. waev is the exception: its
// operator confirmed (2026-08-11) that their servers do not disconnect on expiry,
// so a live session there needs only its credentials refreshed for the next
// reconnect. waev is also the only preset with a short token_lifetime, so it was
// the only one bouncing often — every ~47 min, and each bounce's re-handshake can
// cost ~10 KB of contiguous internal DRAM on a non-PSRAM board.
//
// Keyed by name rather than a struct field on purpose: adding a field would mean
// re-ordering a dozen positional initialisers in the table, where a mistake is
// silent.
static inline bool mqttPresetEnforcesTokenExp(const MQTTPresetDef* preset) {
if (!preset || !preset->name) return true; // custom/audience slots: assume enforced
return strcmp(preset->name, "waev") != 0;
}
+2 -1
View File
@@ -87,7 +87,7 @@ static inline bool mqttPresetNeedsSlotCredentials(const MQTTPresetDef* preset) {
}
// Number of built-in presets
static const int MQTT_PRESET_COUNT = 36;
static const int MQTT_PRESET_COUNT = 37;
// Keep the certificate and preset tables in one translation unit. Defining
// these as header-local constants created a complete flash copy in every MQTT
@@ -199,6 +199,7 @@ extern const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {
{ "idahomesh", "wss://mqtt.idahomesh.org:443/mqtt", "mqtt.idahomesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "ntxmesh", "wss://ntxmesh.dhovin.me:8883", "ntxmesh.dhovin.me", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "bsmesh", "wss://mqtt.bsmesh.de:8885", "mqtt.bsmesh.de", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "marwoj", "mqtts://mqtt.marwoj.net:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 55, "observer-agessaman", "ipRwCEclZkX47K" },
};
#else
+107
View File
@@ -0,0 +1,107 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
namespace NetworkHostname {
// ESP-IDF documents a 32-byte hostname limit. Keep one byte for the trailing
// NUL so the same buffer is safe through Arduino and lwIP APIs.
static constexpr size_t kMaxLength = 31;
static constexpr size_t kBufferSize = kMaxLength + 1;
static inline bool isAsciiAlphaNumeric(uint8_t ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9');
}
static inline char asciiLower(uint8_t ch) {
return ch >= 'A' && ch <= 'Z' ? static_cast<char>(ch + ('a' - 'A'))
: static_cast<char>(ch);
}
/**
* Build a DHCP-safe hostname from the MeshCore node name.
*
* The result is lowercase, starts with "meshcore-", contains only letters,
* digits and hyphens, and never exceeds ESP-IDF's practical 31-character
* payload limit. Six hex digits from the stable node identity are retained
* whenever non-ASCII bytes are removed, a fallback is needed, or the readable
* name must be shortened. This keeps lossy sanitization from assigning the
* same DHCP identity to unrelated nodes.
*/
static inline bool build(char* dest, size_t dest_size, const char* node_name,
const uint8_t* stable_id, size_t stable_id_size) {
if (!dest || dest_size == 0) return false;
dest[0] = '\0';
static constexpr char kPrefix[] = "meshcore-";
static constexpr char kFallback[] = "node";
static constexpr char kHex[] = "0123456789abcdef";
static constexpr size_t kPrefixLength = sizeof(kPrefix) - 1;
static constexpr size_t kSuffixLength = 7; // '-' plus six hex digits.
// NodePrefs::node_name currently holds at most 31 bytes. A larger scratch
// buffer keeps this helper safe and independently testable with other input.
char slug[64];
size_t slug_length = 0;
bool separator_pending = false;
bool removed_non_ascii = false;
if (node_name) {
for (size_t i = 0; node_name[i] != '\0' && slug_length < sizeof(slug) - 1;
++i) {
const uint8_t ch = static_cast<uint8_t>(node_name[i]);
if (isAsciiAlphaNumeric(ch)) {
if (separator_pending && slug_length > 0 &&
slug_length < sizeof(slug) - 1) {
slug[slug_length++] = '-';
}
if (slug_length < sizeof(slug) - 1) {
slug[slug_length++] = asciiLower(ch);
}
separator_pending = false;
} else {
if (ch >= 0x80) removed_non_ascii = true;
if (slug_length > 0) separator_pending = true;
}
}
}
const bool used_fallback = slug_length == 0;
if (slug_length == 0) {
for (size_t i = 0; i < sizeof(kFallback) - 1; ++i) {
slug[slug_length++] = kFallback[i];
}
}
slug[slug_length] = '\0';
size_t max_length = dest_size - 1;
if (max_length > kMaxLength) max_length = kMaxLength;
if (max_length == 0) return false;
const bool needs_truncation = kPrefixLength + slug_length > max_length;
const bool needs_identity = needs_truncation || removed_non_ascii || used_fallback;
const bool can_add_identity = needs_identity && stable_id &&
stable_id_size >= 3 && max_length > kPrefixLength + kSuffixLength;
const size_t suffix_length = can_add_identity ? kSuffixLength : 0;
const size_t prefix_length = kPrefixLength < max_length
? kPrefixLength : max_length;
size_t slug_budget = max_length - prefix_length - suffix_length;
if (slug_budget > slug_length) slug_budget = slug_length;
while (slug_budget > 0 && slug[slug_budget - 1] == '-') --slug_budget;
size_t out = 0;
for (size_t i = 0; i < prefix_length; ++i) dest[out++] = kPrefix[i];
for (size_t i = 0; i < slug_budget; ++i) dest[out++] = slug[i];
if (can_add_identity) {
dest[out++] = '-';
for (size_t i = 0; i < 3; ++i) {
dest[out++] = kHex[(stable_id[i] >> 4) & 0x0f];
dest[out++] = kHex[stable_id[i] & 0x0f];
}
}
dest[out] = '\0';
return true;
}
} // namespace NetworkHostname
+979
View File
@@ -0,0 +1,979 @@
#include "NetworkLink.h"
#if defined(ESP_PLATFORM)
#include "MQTTConnectionPolicy.h"
#include "WifiPowerSavePolicy.h"
#include <atomic>
#include <climits>
#include <cstring>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_netif.h>
#include <lwip/dns.h>
#if defined(NETWORK_PREFER_ETHERNET)
#include "ethernet/ch390/CH390Config.h"
// Arduino-ESP32's built-in ETH implementation calls this before bringing up
// Ethernet. It creates the shared Arduino network event group/task used by
// WiFiGenericClass::hostByName(), even when no Wi-Fi interface is started.
// ESP32-CH390 initializes esp_netif directly and omits this Arduino layer.
extern void tcpipInit();
#endif
// Same "MQTT: " prefix as MQTT_DEBUG_PRINTLN so existing log greps keep matching.
#if defined(MQTT_DEBUG)
#define NETWORK_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F "\n", ##__VA_ARGS__); } } while (0)
#else
#define NETWORK_DEBUG_PRINTLN(...) do {} while (0)
#endif
namespace {
class NetworkLinkBase : public NetworkLink {
protected:
std::atomic<uint64_t> _outage_bits{AlertFaultPolicy::packOutageSnapshot({false, 0, 0})};
std::atomic<unsigned long> _connected_at{0};
std::atomic<unsigned long> _last_disconnect_time{0};
std::atomic<uint8_t> _last_disconnect_reason{0};
bool _status_initialized = false;
bool _last_connected = false;
unsigned long _last_status_check = 0;
static constexpr int kDnsServers = 2;
// Only ever touched on the lwIP thread (see snapshotDns()), so no atomics.
ip_addr_t _dns_snapshot[kDnsServers] = {};
bool _dns_captured = false;
AlertFaultPolicy::OutageSnapshot outage() const {
return AlertFaultPolicy::unpackOutageSnapshot(
_outage_bits.load(std::memory_order_acquire));
}
void setOutage(AlertFaultPolicy::OutageSnapshot snapshot) {
_outage_bits.store(AlertFaultPolicy::packOutageSnapshot(snapshot),
std::memory_order_release);
}
void noteConnected(unsigned long now_ms) {
if (_connected_at.load(std::memory_order_relaxed) == 0) {
_connected_at.store(now_ms, std::memory_order_relaxed);
}
setOutage(AlertFaultPolicy::applyWifiGotIp(outage()));
}
void noteDisconnected(unsigned long now_ms, uint8_t reason) {
_last_disconnect_reason.store(reason, std::memory_order_relaxed);
_last_disconnect_time.store(now_ms, std::memory_order_relaxed);
setOutage(AlertFaultPolicy::applyWifiDisconnectEvent(
(uint32_t)now_ms, reason, outage()));
}
public:
unsigned long connectedAtMillis() const override {
return _connected_at.load(std::memory_order_relaxed);
}
uint8_t lastDisconnectReason() const override {
return _last_disconnect_reason.load(std::memory_order_relaxed);
}
unsigned long lastDisconnectTime() const override {
return _last_disconnect_time.load(std::memory_order_relaxed);
}
AlertFaultPolicy::OutageSnapshot outageSnapshot() const override {
return outage();
}
private:
// Remember the resolver this link's DHCP lease installed, so switching back
// to it can put that resolver back.
//
// lwIP keeps ONE global server list, not one per interface: a DHCP lease on
// any medium calls dns_setserver() and overwrites whatever the other medium
// had. IDF 4.4 has no per-interface retention either — esp_netif_get_dns_info()
// reads the same globals — so the medium that leased last owns DNS for every
// socket. Without this, an Ethernet node that falls back to Wi-Fi and then
// fails back keeps asking the Wi-Fi network's DNS server. When the two are on
// different subnets that server is unreachable from Ethernet, and the node
// reads as connected while every broker and NTP hostname fails to resolve,
// until Ethernet's own DHCP renewal happens to fix it hours later.
//
// Both halves run through esp_netif_tcpip_exec() because lwIP's DNS functions
// belong to the TCP/IP thread, and these are called from the Arduino event
// task (Ethernet GOT_IP) and the MQTT task (Wi-Fi edge, and select()). That
// also serializes the snapshot itself, so it needs no atomics. Neither caller
// is the TCP/IP thread, so the call cannot deadlock on itself.
//
// The whole ip_addr_t is kept, not an IPv4 word: these builds compile lwIP
// with IPv6 on, and reinterpreting a v6 server as v4 would install four
// meaningless bytes as a resolver. (RDNSS is disabled here, so a v6 server
// should never appear — storing the tagged value means it stays correct if
// that ever changes.)
static esp_err_t snapshotDnsOnTcpipThread(void* ctx) {
NetworkLinkBase* self = static_cast<NetworkLinkBase*>(ctx);
bool any = false;
for (int i = 0; i < kDnsServers; i++) {
const ip_addr_t* server = dns_getserver(i);
self->_dns_snapshot[i] = (server != nullptr) ? *server : *IP_ADDR_ANY;
if (!ip_addr_isany_val(self->_dns_snapshot[i])) any = true;
}
// A lease that carried no resolver at all (static configuration, or a
// server that offered none) must not be remembered as "this medium's DNS is
// nothing" — restoring that would wipe a working resolver for no gain.
if (any) self->_dns_captured = true;
return ESP_OK;
}
static esp_err_t restoreDnsOnTcpipThread(void* ctx) {
NetworkLinkBase* self = static_cast<NetworkLinkBase*>(ctx);
if (!self->_dns_captured) return ESP_OK;
// Every slot is written, empty ones included: returning to a network with
// one server must not leave the other medium's second server behind as a
// fallback that only fails slowly.
for (int i = 0; i < kDnsServers; i++) {
dns_setserver(i, &self->_dns_snapshot[i]);
}
return ESP_OK;
}
public:
void snapshotDns() { esp_netif_tcpip_exec(snapshotDnsOnTcpipThread, this); }
// A medium that has never held a lease with a resolver leaves the current one
// alone rather than blanking it.
void restoreDns() { esp_netif_tcpip_exec(restoreDnsOnTcpipThread, this); }
};
class WiFiNetworkLink final : public NetworkLinkBase {
bool _event_registered = false;
char _hostname[32] = {};
char _ssid[33] = {};
char _password[65] = {};
unsigned long _last_reconnect_attempt = 0;
uint8_t _reconnect_backoff_attempt = 0;
// One mapping for startup, reconnect and CLI (see WifiPowerSavePolicy). The
// stored default is `none`; `min` means MIN_MODEM here exactly as the CLI
// says it does. Mapping the stored value locally is what let a node set to
// `min` silently run with power save off after its first reconnect.
void applyPowerPrefs(uint8_t wifi_power_save) {
static_assert((int)WifiPowerSavePolicy::kModeNone == (int)WIFI_PS_NONE, "wifi_ps_type_t drift");
static_assert((int)WifiPowerSavePolicy::kModeMinModem == (int)WIFI_PS_MIN_MODEM, "wifi_ps_type_t drift");
static_assert((int)WifiPowerSavePolicy::kModeMaxModem == (int)WIFI_PS_MAX_MODEM, "wifi_ps_type_t drift");
esp_wifi_set_ps((wifi_ps_type_t)WifiPowerSavePolicy::modeFor(wifi_power_save));
// Read back rather than logging what we asked for: this is the only place
// the mode is observable on a running node, and the setting used to change
// meaning between the CLI and this path.
wifi_ps_type_t applied = WIFI_PS_NONE;
esp_wifi_get_ps(&applied);
NETWORK_DEBUG_PRINTLN("WiFi power save: %s (mode=%d)",
WifiPowerSavePolicy::nameFor(wifi_power_save), (int)applied);
#ifdef MQTT_WIFI_TX_POWER
WiFi.setTxPower(MQTT_WIFI_TX_POWER);
#else
WiFi.setTxPower(WIFI_POWER_11dBm);
#endif
}
public:
const char* mediumName() const override { return "wifi"; }
NetworkMedium medium() const override { return NetworkMedium::WiFi; }
const char* statusName() const override {
switch (WiFi.status()) {
case WL_CONNECTED: return "connected";
case WL_NO_SSID_AVAIL: return "no_ssid";
case WL_CONNECT_FAILED: return "connect_failed";
case WL_CONNECTION_LOST: return "connection_lost";
case WL_DISCONNECTED: return "disconnected";
case 255: return "not_started";
default: return "unknown";
}
}
int statusCode() const override { return (int)WiFi.status(); }
bool configValid(const char* wifi_ssid) const override {
return wifi_ssid && wifi_ssid[0] != '\0';
}
void setHostname(const char* hostname) override {
strncpy(_hostname, hostname ? hostname : "", sizeof(_hostname) - 1);
_hostname[sizeof(_hostname) - 1] = '\0';
}
void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) override {
strncpy(_ssid, wifi_ssid ? wifi_ssid : "", sizeof(_ssid) - 1);
_ssid[sizeof(_ssid) - 1] = '\0';
strncpy(_password, wifi_password ? wifi_password : "", sizeof(_password) - 1);
_password[sizeof(_password) - 1] = '\0';
}
bool begin(const char* wifi_ssid, const char* wifi_password) override {
if (!configValid(wifi_ssid)) return false;
updateWifiCredentials(wifi_ssid, wifi_password);
// Arduino-ESP32 applies this stored value when it creates the STA netif.
// It must be set before WiFi.mode()/begin() for the first DHCP exchange.
if (_hostname[0] != '\0') WiFi.setHostname(_hostname);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
if (!_event_registered) {
WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) {
switch (event) {
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
NETWORK_DEBUG_PRINTLN("WiFi connected: %s",
IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str());
noteConnected(millis());
_reconnect_backoff_attempt = 0;
break;
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
noteDisconnected(millis(), info.wifi_sta_disconnected.reason);
NETWORK_DEBUG_PRINTLN("WiFi disconnected: reason %d",
info.wifi_sta_disconnected.reason);
break;
default:
break;
}
});
_event_registered = true;
}
// Preserve the existing restart behavior: MQTT stop leaves the station up,
// and begin() must not force a disconnect that races the first DNS lookup.
if (!isConnected()) {
WiFi.begin(_ssid, _password);
} else {
noteConnected(millis());
}
return true;
}
NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) override {
const bool connected = isConnected();
if (connected && connectedAtMillis() == 0) noteConnected(now_ms);
if (!_status_initialized) {
_last_connected = connected;
_status_initialized = true;
setOutage(AlertFaultPolicy::applyWifiStatus(
now_ms, connected, outage(), false));
// Already associated when the link started (end()/begin() leaves STA up):
// there is no connect transition below to carry the setting, so apply it
// here or the node keeps running whatever mode was set before.
if (connected) {
applyPowerPrefs(wifi_power_save);
snapshotDns();
}
}
if ((uint32_t)(now_ms - _last_status_check) <= 10000) {
if (connected && outage().down) noteConnected(now_ms);
return NetworkTransition::None;
}
_last_status_check = now_ms;
if (connected) {
const bool transitioned = !_last_connected;
if (transitioned) {
setOutage(AlertFaultPolicy::applyWifiStatus(
now_ms, true, outage(), true));
_connected_at.store(now_ms, std::memory_order_relaxed);
_reconnect_backoff_attempt = 0;
applyPowerPrefs(wifi_power_save);
snapshotDns();
}
_last_connected = true;
return transitioned ? NetworkTransition::Up : NetworkTransition::None;
}
AlertFaultPolicy::OutageSnapshot snapshot = AlertFaultPolicy::applyWifiStatus(
now_ms, false, outage(), true);
setOutage(snapshot);
const bool transitioned = _last_connected;
if (transitioned) {
_connected_at.store(0, std::memory_order_relaxed);
} else if (snapshot.down && _ssid[0] != '\0' && MQTTConnectionPolicy::wifiReconnectDue(
now_ms, snapshot.started_ms, (uint32_t)_last_reconnect_attempt,
_reconnect_backoff_attempt)) {
_last_reconnect_attempt = now_ms;
_reconnect_backoff_attempt =
MQTTConnectionPolicy::nextWifiBackoffAttempt(_reconnect_backoff_attempt);
WiFi.disconnect();
WiFi.begin(_ssid, _password);
}
_last_connected = false;
return transitioned ? NetworkTransition::Down : NetworkTransition::None;
}
bool isConnected() const override { return WiFi.status() == WL_CONNECTED; }
IPAddress localIP() const override { return WiFi.localIP(); }
int rssi() const override { return isConnected() ? WiFi.RSSI() : INT_MIN; }
bool resolveHost(const char* hostname, IPAddress& address) const override {
return WiFi.hostByName(hostname, address);
}
void formatDiagnostics(char* reply, size_t reply_size) const override {
snprintf(reply, reply_size,
"> why:ethernet-not-enabled selected:wifi\n"
"wifi:state:%s ip:%s",
statusName(),
localIP().toString().c_str());
}
};
#if defined(NETWORK_PREFER_ETHERNET)
class EthernetNetworkLink final : public NetworkLinkBase {
public:
enum class EventState : uint8_t {
None,
Started,
LinkDown,
LinkUp,
GotIp,
Stopped,
};
private:
std::atomic<bool> _started{false};
bool _event_registered = false;
char _hostname[32] = {};
std::atomic<uint8_t> _event_state{static_cast<uint8_t>(EventState::None)};
public:
const char* mediumName() const override { return "ethernet"; }
NetworkMedium medium() const override { return NetworkMedium::Ethernet; }
const char* statusName() const override {
return isConnected() ? "connected" : "disconnected";
}
int statusCode() const override { return isConnected() ? 1 : 0; }
bool configValid(const char*) const override { return true; }
void setHostname(const char* hostname) override {
strncpy(_hostname, hostname ? hostname : "", sizeof(_hostname) - 1);
_hostname[sizeof(_hostname) - 1] = '\0';
}
bool begin(const char*, const char*) override {
if (_started.load(std::memory_order_acquire)) return true;
// DNS and WiFiClientSecure are transport-neutral sockets in this Arduino
// core, but their hostname path still uses WiFiGeneric's event group.
// Initialize that shared runtime without enabling or associating Wi-Fi.
tcpipInit();
if (!_event_registered) {
WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t) {
switch (event) {
case ARDUINO_EVENT_ETH_START:
_event_state.store(static_cast<uint8_t>(EventState::Started),
std::memory_order_relaxed);
break;
case ARDUINO_EVENT_ETH_CONNECTED:
_event_state.store(static_cast<uint8_t>(EventState::LinkUp),
std::memory_order_relaxed);
break;
case ARDUINO_EVENT_ETH_GOT_IP:
_event_state.store(static_cast<uint8_t>(EventState::GotIp),
std::memory_order_relaxed);
noteConnected(millis());
// Taken here rather than at the maintain() edge: this is the moment
// the lease installed the resolver, before a Wi-Fi fallback lease
// can overwrite it.
snapshotDns();
break;
case ARDUINO_EVENT_ETH_DISCONNECTED:
_event_state.store(static_cast<uint8_t>(EventState::LinkDown),
std::memory_order_relaxed);
// Ethernet has no 802.11 reason code; zero means unavailable.
noteDisconnected(millis(), 0);
_connected_at.store(0, std::memory_order_relaxed);
break;
case ARDUINO_EVENT_ETH_STOP:
_event_state.store(static_cast<uint8_t>(EventState::Stopped),
std::memory_order_relaxed);
break;
default:
break;
}
});
_event_registered = true;
}
_event_state.store(static_cast<uint8_t>(EventState::None),
std::memory_order_relaxed);
const bool started = beginConfiguredCH390(_hostname);
_started.store(started, std::memory_order_release);
if (started && isConnected()) noteConnected(millis());
return started;
}
bool restart() {
CH390.end();
_started.store(false, std::memory_order_release);
// Preserve status/outage history across attempts. maintain() observes the
// resulting edge in this same task iteration, so a retry cannot reset a
// prolonged-down alert timer or hide a previously connected transition.
return begin(nullptr, nullptr);
}
NetworkTransition maintain(uint32_t now_ms, uint8_t) override {
const bool connected = isConnected();
if (!_status_initialized) {
_last_connected = connected;
_status_initialized = true;
setOutage(AlertFaultPolicy::applyWifiStatus(
now_ms, connected, outage(), false));
if (connected) {
noteConnected(now_ms);
snapshotDns();
}
return NetworkTransition::None;
}
if (connected == _last_connected) {
if (connected && outage().down) noteConnected(now_ms);
return NetworkTransition::None;
}
_last_connected = connected;
if (connected) {
_connected_at.store(now_ms, std::memory_order_relaxed);
setOutage(AlertFaultPolicy::applyWifiStatus(
now_ms, true, outage(), true));
snapshotDns();
return NetworkTransition::Up;
}
const bool outage_was_down = outage().down;
_connected_at.store(0, std::memory_order_relaxed);
AlertFaultPolicy::OutageSnapshot snapshot = AlertFaultPolicy::applyWifiStatus(
now_ms, false, outage(), true);
setOutage(snapshot);
if (!outage_was_down) {
_last_disconnect_time.store(now_ms, std::memory_order_relaxed);
}
return NetworkTransition::Down;
}
bool isConnected() const override {
return _started.load(std::memory_order_acquire) && CH390.isConnected();
}
IPAddress localIP() const override { return CH390.localIP(); }
int rssi() const override { return INT_MIN; }
bool resolveHost(const char* hostname, IPAddress& address) const override {
// Arduino's hostByName is a thin wrapper over the process-wide lwIP resolver;
// DNS follows the selected esp_netif even though this entry point is named WiFi.
return WiFi.hostByName(hostname, address);
}
void formatDiagnostics(char* reply, size_t reply_size) const override {
snprintf(reply, reply_size, "> ethernet:%s ip=%s",
statusName(), localIP().toString().c_str());
}
EventState eventState() const {
return static_cast<EventState>(
_event_state.load(std::memory_order_relaxed));
}
bool started() const { return _started.load(std::memory_order_acquire); }
bool sampleLink(bool& known) const {
known = false;
if (!_started.load(std::memory_order_acquire)) return false;
// IEEE 802.3 BMSR link status is latch-low. Read it twice so the second
// value is the current carrier state rather than a remembered link flap.
(void)CH390.readPHY(0x01);
const uint32_t bmsr = CH390.readPHY(0x01) & 0xffffu;
if (bmsr != 0 && bmsr != 0xffffu) {
known = true;
return (bmsr & (1u << 2)) != 0;
}
// A failed/unsupported direct PHY read can still use the driver's events.
const EventState state = eventState();
known = state == EventState::LinkDown || state == EventState::LinkUp ||
state == EventState::GotIp || state == EventState::Stopped;
return state == EventState::LinkUp || state == EventState::GotIp;
}
bool linkUp() const {
bool known = false;
return sampleLink(known);
}
const char* eventName() const {
switch (eventState()) {
case EventState::None: return "none";
case EventState::Started: return "started";
case EventState::LinkDown: return "link-down";
case EventState::LinkUp: return "link-up";
case EventState::GotIp: return "got-ip";
case EventState::Stopped: return "stopped";
}
return "unknown";
}
};
class AutomaticNetworkLink final : public NetworkLink {
EthernetNetworkLink _ethernet;
WiFiNetworkLink _wifi;
std::atomic<NetworkMedium> _selected{NetworkMedium::None};
std::atomic<bool> _ethernet_started{false};
std::atomic<bool> _wifi_started{false};
char _wifi_ssid[33] = {};
char _wifi_password[65] = {};
std::atomic<uint32_t> _ethernet_stable_since{0};
std::atomic<uint32_t> _selected_down_since{0};
std::atomic<uint32_t> _last_ethernet_init_attempt{0};
std::atomic<uint8_t> _ethernet_retry_attempt{0};
std::atomic<uint32_t> _ethernet_no_ip_since{0};
std::atomic<uint8_t> _switch_locks{0};
std::atomic<bool> _switch_in_progress{false};
std::atomic<bool> _ethernet_seen{false}; // held a lease at least once this boot
// Ethernet is the primary for alerts once it has carried traffic, or when it
// is the only configured medium; a Wi-Fi-only install keeps Wi-Fi alerts.
bool ethernetIsAlertPrimary() const {
return _ethernet_seen.load(std::memory_order_acquire) || !wifiConfigured();
}
NetworkLink& selectedInterface(NetworkMedium selected) {
return selected == NetworkMedium::Ethernet
? static_cast<NetworkLink&>(_ethernet)
: static_cast<NetworkLink&>(_wifi);
}
const NetworkLink& selectedInterface(NetworkMedium selected) const {
return selected == NetworkMedium::Ethernet
? static_cast<const NetworkLink&>(_ethernet)
: static_cast<const NetworkLink&>(_wifi);
}
bool wifiConfigured() const { return _wifi_ssid[0] != '\0'; }
void rememberWifi(const char* ssid, const char* password) {
strncpy(_wifi_ssid, ssid ? ssid : "", sizeof(_wifi_ssid) - 1);
_wifi_ssid[sizeof(_wifi_ssid) - 1] = '\0';
strncpy(_wifi_password, password ? password : "", sizeof(_wifi_password) - 1);
_wifi_password[sizeof(_wifi_password) - 1] = '\0';
}
void startWifiFallback() {
if (_wifi_started.load(std::memory_order_acquire) || !wifiConfigured()) return;
_wifi_started.store(_wifi.begin(_wifi_ssid, _wifi_password),
std::memory_order_release);
}
// Lock and mutation each publish one flag then read the other; seq_cst keeps
// both sides from missing each other.
bool beginUnlockedMutation() {
bool expected = false;
if (!_switch_in_progress.compare_exchange_strong(expected, true)) {
return false;
}
if (_switch_locks.load() != 0) {
_switch_in_progress.store(false, std::memory_order_release);
return false;
}
return true;
}
void endUnlockedMutation() {
_switch_in_progress.store(false, std::memory_order_release);
}
bool selectIfUnlocked(NetworkMedium medium) {
if (!beginUnlockedMutation()) return false;
select(medium);
endUnlockedMutation();
return true;
}
void startWifiFallbackIfUnlocked() {
if (!beginUnlockedMutation()) return;
startWifiFallback();
endUnlockedMutation();
}
void select(NetworkMedium medium) {
if (medium == NetworkMedium::Ethernet) {
// ESP-IDF gives Wi-Fi a higher default-route priority than Ethernet.
// Keep only the selected STA associated so sockets cannot silently stay
// on Wi-Fi after the manager has declared Ethernet active.
WiFi.setAutoReconnect(false);
WiFi.disconnect(false, false);
_wifi_started.store(false, std::memory_order_release);
}
_selected_down_since.store(0, std::memory_order_relaxed);
_selected.store(medium, std::memory_order_release);
// Put back the resolver this medium leased. lwIP's server list is global,
// so whichever medium leased last still owns it here (see snapshotDns()).
if (medium == NetworkMedium::Ethernet) {
_ethernet.restoreDns();
} else if (medium == NetworkMedium::WiFi) {
_wifi.restoreDns();
}
}
bool startOrRetryEthernet(uint32_t now_ms, bool restart) {
const bool started = restart ? _ethernet.restart()
: _ethernet.begin(nullptr, nullptr);
_last_ethernet_init_attempt.store(now_ms, std::memory_order_relaxed);
_ethernet_started.store(started, std::memory_order_release);
if (started) {
_ethernet_retry_attempt.store(0, std::memory_order_relaxed);
} else {
uint8_t attempt = _ethernet_retry_attempt.load(std::memory_order_relaxed);
if (attempt != UINT8_MAX) ++attempt;
_ethernet_retry_attempt.store(attempt, std::memory_order_relaxed);
}
return started;
}
public:
const char* mediumName() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
if (selected == NetworkMedium::Ethernet) return "ethernet";
if (selected == NetworkMedium::WiFi) return "wifi";
return "none";
}
NetworkMedium medium() const override {
return _selected.load(std::memory_order_acquire);
}
const char* statusName() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None ? "not_selected"
: selectedInterface(selected).statusName();
}
int statusCode() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None ? 0
: selectedInterface(selected).statusCode();
}
bool configValid(const char* wifi_ssid) const override {
// Hardware availability and stored credentials are configuration. Current
// link/DHCP state is runtime state and must not permanently suppress the
// MQTT task that monitors for a late cable or lease.
(void)wifi_ssid;
return true;
}
bool isAutomatic() const override { return true; }
void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) override {
rememberWifi(wifi_ssid, wifi_password);
_wifi.updateWifiCredentials(_wifi_ssid, _wifi_password);
}
void setHostname(const char* hostname) override {
// Whichever medium wins now or during a later failover presents the same
// stable DHCP identity to the LAN.
_ethernet.setHostname(hostname);
_wifi.setHostname(hostname);
}
bool begin(const char* wifi_ssid, const char* wifi_password) override {
rememberWifi(wifi_ssid, wifi_password);
if (!_ethernet_started.load(std::memory_order_acquire)) {
const uint32_t now_ms = millis();
const uint8_t attempt =
_ethernet_retry_attempt.load(std::memory_order_relaxed);
if (NetworkPolicy::ethernetInitRetryDue(
attempt, now_ms,
_last_ethernet_init_attempt.load(std::memory_order_relaxed)) &&
beginUnlockedMutation()) {
startOrRetryEthernet(now_ms, attempt != 0);
endUnlockedMutation();
}
}
// bootstrap() owns the initial choice. MQTT begin() is intentionally
// idempotent and cannot demote a boot-selected Ethernet link because of a
// momentary status sample between tasks.
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
if (selected == NetworkMedium::None) {
if (_ethernet.isConnected()) {
select(NetworkMedium::Ethernet);
} else if (wifiConfigured()) {
startWifiFallback();
_selected.store(NetworkMedium::WiFi, std::memory_order_release);
}
} else if (selected == NetworkMedium::WiFi) {
startWifiFallback();
}
return _ethernet_started.load(std::memory_order_acquire) ||
_wifi_started.load(std::memory_order_acquire);
}
bool bootstrap(const char* wifi_ssid, const char* wifi_password,
uint32_t wait_ms) override {
rememberWifi(wifi_ssid, wifi_password);
if (!_ethernet_started.load(std::memory_order_acquire)) {
startOrRetryEthernet(millis(), false);
}
const uint32_t started_at = millis();
for (;;) {
bool link_known = false;
const bool link_up = _ethernet.sampleLink(link_known);
if (!NetworkPolicy::ethernetBootProbePending(
_ethernet_started.load(std::memory_order_acquire),
_ethernet.isConnected(), link_known, link_up,
(uint32_t)(millis() - started_at), wait_ms)) {
break;
}
delay(25);
}
if (_ethernet.isConnected()) _ethernet_seen.store(true, std::memory_order_release);
const NetworkMedium initial = NetworkPolicy::bootSelection(
_ethernet.isConnected(), wifiConfigured());
if (initial == NetworkMedium::Ethernet) {
select(initial);
} else {
startWifiFallback();
_selected.store(initial, std::memory_order_release);
}
return initial != NetworkMedium::None;
}
NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) override {
bool ethernet_started = _ethernet_started.load(std::memory_order_acquire);
const bool switching_locked =
_switch_locks.load(std::memory_order_acquire) != 0;
const bool ethernet_stopped =
ethernet_started &&
_ethernet.eventState() == EthernetNetworkLink::EventState::Stopped;
if (ethernet_stopped) {
_ethernet_started.store(false, std::memory_order_release);
ethernet_started = false;
if (_ethernet_retry_attempt.load(std::memory_order_relaxed) == 0) {
_ethernet_retry_attempt.store(1, std::memory_order_relaxed);
_last_ethernet_init_attempt.store(now_ms, std::memory_order_relaxed);
}
}
const EthernetNetworkLink::EventState ethernet_event =
_ethernet.eventState();
const bool ethernet_link_up =
ethernet_event == EthernetNetworkLink::EventState::LinkUp ||
ethernet_event == EthernetNetworkLink::EventState::GotIp;
if (ethernet_started && ethernet_link_up && !_ethernet.isConnected()) {
if (_ethernet_no_ip_since.load(std::memory_order_relaxed) == 0) {
uint32_t started_at = now_ms;
if (started_at == 0) started_at = 1;
_ethernet_no_ip_since.store(started_at, std::memory_order_relaxed);
}
} else {
_ethernet_no_ip_since.store(0, std::memory_order_relaxed);
}
// Restarts rebuild the CH390 netif, so they take the same mutation gate as
// a route switch: an OTA/WebConfig lock taken after the sample above wins.
if (!switching_locked && NetworkPolicy::ethernetNoIpRecoveryDue(
ethernet_started, ethernet_link_up, _ethernet.isConnected(),
now_ms, _ethernet_no_ip_since.load(std::memory_order_relaxed)) &&
beginUnlockedMutation()) {
ethernet_started = startOrRetryEthernet(now_ms, true);
_ethernet_no_ip_since.store(0, std::memory_order_relaxed);
endUnlockedMutation();
}
if (!ethernet_started && !switching_locked) {
const uint8_t attempt =
_ethernet_retry_attempt.load(std::memory_order_relaxed);
if (NetworkPolicy::ethernetInitRetryDue(
attempt, now_ms,
_last_ethernet_init_attempt.load(std::memory_order_relaxed)) &&
beginUnlockedMutation()) {
ethernet_started = startOrRetryEthernet(now_ms, true);
endUnlockedMutation();
}
}
const NetworkTransition ethernet_transition =
_ethernet.maintain(now_ms, wifi_power_save);
if (_ethernet.isConnected()) _ethernet_seen.store(true, std::memory_order_release);
const bool wifi_started = _wifi_started.load(std::memory_order_acquire);
const NetworkTransition wifi_transition = wifi_started
? _wifi.maintain(now_ms, wifi_power_save)
: NetworkTransition::None;
// Ethernet may recover while a Wi-Fi fallback is still associating. In
// that sequence the selected enum never changes, but Wi-Fi would win
// ESP-IDF's default-route priority once it came up. Tear the unused STA
// down even without a selection edge, and force MQTT to reconnect if it
// had already become reachable.
NetworkMedium selected = _selected.load(std::memory_order_acquire);
if (selected == NetworkMedium::Ethernet && _ethernet.isConnected() &&
wifi_started) {
const bool wifi_had_route = _wifi.isConnected();
if (selectIfUnlocked(NetworkMedium::Ethernet) && wifi_had_route) {
return NetworkTransition::Switched;
}
}
if (_ethernet.isConnected()) {
if (_ethernet_stable_since.load(std::memory_order_relaxed) == 0) {
_ethernet_stable_since.store(now_ms, std::memory_order_relaxed);
}
} else {
_ethernet_stable_since.store(0, std::memory_order_relaxed);
}
const bool selected_connected = isConnected();
if (!selected_connected) {
if (_selected_down_since.load(std::memory_order_relaxed) == 0) {
_selected_down_since.store(now_ms, std::memory_order_relaxed);
}
} else {
_selected_down_since.store(0, std::memory_order_relaxed);
}
const uint32_t selected_down_since =
_selected_down_since.load(std::memory_order_relaxed);
const uint32_t selected_down_ms = selected_down_since == 0
? 0 : (uint32_t)(now_ms - selected_down_since);
selected = _selected.load(std::memory_order_acquire);
if (selected == NetworkMedium::Ethernet && !_ethernet.isConnected() &&
selected_down_ms >= NetworkPolicy::kEthernetDownGraceMs) {
startWifiFallbackIfUnlocked();
}
const uint32_t ethernet_stable_since =
_ethernet_stable_since.load(std::memory_order_relaxed);
const uint32_t ethernet_stable_ms = ethernet_stable_since == 0
? 0 : (uint32_t)(now_ms - ethernet_stable_since);
const bool selection_locked =
_switch_locks.load(std::memory_order_acquire) != 0;
const NetworkPolicy::AutomaticSelectionInput input = {
selected, _ethernet.isConnected(),
_wifi_started.load(std::memory_order_acquire) && _wifi.isConnected(),
wifiConfigured(), selection_locked,
ethernet_stable_ms, selected_down_ms};
const NetworkMedium next = NetworkPolicy::automaticSelection(input);
if (next != selected) {
if (selectIfUnlocked(next)) {
return selected == NetworkMedium::None ? NetworkTransition::Up
: NetworkTransition::Switched;
}
}
if (selected == NetworkMedium::Ethernet) return ethernet_transition;
if (selected == NetworkMedium::WiFi) return wifi_transition;
return NetworkTransition::None;
}
void lockSwitching() override {
uint8_t value = _switch_locks.load(std::memory_order_relaxed);
while (value != UINT8_MAX && !_switch_locks.compare_exchange_weak(
value, static_cast<uint8_t>(value + 1))) {}
while (_switch_in_progress.load()) delay(1);
}
void unlockSwitching() override {
uint8_t value = _switch_locks.load(std::memory_order_relaxed);
while (value != 0 && !_switch_locks.compare_exchange_weak(
value, static_cast<uint8_t>(value - 1),
std::memory_order_relaxed, std::memory_order_relaxed)) {}
}
bool isConnected() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected != NetworkMedium::None &&
selectedInterface(selected).isConnected();
}
IPAddress localIP() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None ? IPAddress()
: selectedInterface(selected).localIP();
}
int rssi() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None ? INT_MIN
: selectedInterface(selected).rssi();
}
bool resolveHost(const char* hostname, IPAddress& address) const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected != NetworkMedium::None &&
selectedInterface(selected).resolveHost(hostname, address);
}
void formatDiagnostics(char* reply, size_t reply_size) const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
const bool ethernet_started =
_ethernet_started.load(std::memory_order_acquire);
const bool wifi_started = _wifi_started.load(std::memory_order_acquire);
const bool ethernet_connected = _ethernet.isConnected();
bool ethernet_link_known = false;
bool ethernet_link_up = _ethernet.sampleLink(ethernet_link_known);
ethernet_link_known = ethernet_link_known || ethernet_connected;
ethernet_link_up = ethernet_link_up || ethernet_connected;
const bool switching_locked =
_switch_locks.load(std::memory_order_acquire) != 0;
const uint32_t now_ms = millis();
const uint32_t ethernet_stable_since =
_ethernet_stable_since.load(std::memory_order_relaxed);
const uint32_t ethernet_stable_ms = ethernet_stable_since == 0
? 0 : (uint32_t)(now_ms - ethernet_stable_since);
const uint8_t retry_attempt =
_ethernet_retry_attempt.load(std::memory_order_relaxed);
const uint32_t retry_delay =
NetworkPolicy::ethernetInitRetryDelayMs(retry_attempt);
const uint32_t since_attempt = (uint32_t)(
now_ms - _last_ethernet_init_attempt.load(std::memory_order_relaxed));
const uint32_t retry_in = !ethernet_started && retry_delay > since_attempt
? retry_delay - since_attempt : 0;
const NetworkDiagnosticReason reason =
NetworkPolicy::automaticDiagnosticReason(
ethernet_started, ethernet_link_known, ethernet_link_up,
ethernet_connected, selected, switching_locked,
ethernet_stable_ms);
snprintf(reply, reply_size,
"> why:%s selected:%s lock:%s\n"
"eth:init:%s retry:%u/%lums evt:%s link:%s ip:%s\n"
"wifi:cfg:%s started:%s link:%s",
NetworkPolicy::diagnosticReasonName(reason),
NetworkPolicy::mediumName(selected),
switching_locked ? "yes" : "no",
ethernet_started ? "ok" : "failed", retry_attempt,
(unsigned long)retry_in, _ethernet.eventName(),
ethernet_link_known ? (ethernet_link_up ? "up" : "down")
: "unknown",
_ethernet.localIP().toString().c_str(),
wifiConfigured() ? "yes" : "no", wifi_started ? "yes" : "no",
(wifi_started && _wifi.isConnected()) ? "up" : "down");
}
unsigned long connectedAtMillis() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? 0 : selectedInterface(selected).connectedAtMillis();
}
uint8_t lastDisconnectReason() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? 0 : selectedInterface(selected).lastDisconnectReason();
}
unsigned long lastDisconnectTime() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? 0 : selectedInterface(selected).lastDisconnectTime();
}
AlertFaultPolicy::OutageSnapshot outageSnapshot() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? AlertFaultPolicy::OutageSnapshot{false, 0, 0}
: selectedInterface(selected).outageSnapshot();
}
NetworkMedium alertMedium() const override {
return ethernetIsAlertPrimary() ? NetworkMedium::Ethernet : NetworkMedium::WiFi;
}
AlertFaultPolicy::OutageSnapshot alertOutageSnapshot() const override {
return ethernetIsAlertPrimary() ? _ethernet.outageSnapshot() : _wifi.outageSnapshot();
}
};
#endif
} // namespace
NetworkLink& activeNetworkLink() {
#if defined(NETWORK_PREFER_ETHERNET)
static AutomaticNetworkLink network;
#else
static WiFiNetworkLink network;
#endif
return network;
}
#endif
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include "NetworkPolicy.h"
#if defined(ESP_PLATFORM)
#include <Arduino.h>
#include <IPAddress.h>
#include "AlertFaultPolicy.h"
#ifndef NETWORK_ETHERNET_BOOT_WAIT_MS
#define NETWORK_ETHERNET_BOOT_WAIT_MS 8000UL
#endif
/**
* Physical network selected for IP-based services.
*
* The interface owns link bring-up and medium-specific maintenance. MQTT, NTP,
* OTA, and other socket users only consume connectivity and addressing. Normal
* MQTT shutdown deliberately does not stop this interface because an OTA
* download runs after the broker clients have been released.
*/
class NetworkLink {
public:
virtual ~NetworkLink() = default;
virtual const char* mediumName() const = 0;
virtual NetworkMedium medium() const = 0;
virtual const char* statusName() const = 0;
virtual int statusCode() const = 0;
virtual bool configValid(const char* wifi_ssid) const = 0;
// Configure the DHCP hostname before begin()/bootstrap(). Implementations
// retain it for any later fallback interface start.
virtual void setHostname(const char* hostname) = 0;
virtual bool begin(const char* wifi_ssid, const char* wifi_password) = 0;
// Latest stored credentials; the next reconnect attempt uses them without a reboot.
virtual void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) {
(void)wifi_ssid;
(void)wifi_password;
}
virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0;
// Automatic selectors are boot-owned so first-run services can use the
// chosen link before MQTT starts. Compatibility Wi-Fi builds keep the
// original MQTT-task-owned begin() path.
virtual bool isAutomatic() const { return false; }
virtual bool bootstrap(const char* wifi_ssid, const char* wifi_password,
uint32_t wait_ms) {
(void)wait_ms;
return begin(wifi_ssid, wifi_password);
}
// WebConfig and OTA pin the current route for the lifetime of their session.
virtual void lockSwitching() {}
virtual void unlockSwitching() {}
virtual bool isConnected() const = 0;
virtual IPAddress localIP() const = 0;
virtual int rssi() const = 0; // INT_MIN when the selected medium has no RSSI.
virtual bool resolveHost(const char* hostname, IPAddress& address) const = 0;
virtual void formatDiagnostics(char* reply, size_t reply_size) const = 0;
virtual unsigned long connectedAtMillis() const = 0;
virtual uint8_t lastDisconnectReason() const = 0;
virtual unsigned long lastDisconnectTime() const = 0;
virtual AlertFaultPolicy::OutageSnapshot outageSnapshot() const = 0;
// Fault reporting normally follows the selected interface. Automatic
// Ethernet-preferred builds instead keep reporting the primary Ethernet
// outage while healthy Wi-Fi carries traffic, so prolonged degradation is
// not hidden by a successful fallback.
virtual NetworkMedium alertMedium() const { return medium(); }
virtual AlertFaultPolicy::OutageSnapshot alertOutageSnapshot() const {
return outageSnapshot();
}
};
/** Build-selected singleton. Wi-Fi is the compatibility default. */
NetworkLink& activeNetworkLink();
#endif
+225
View File
@@ -0,0 +1,225 @@
#pragma once
#include <stdint.h>
enum class NetworkTransition : uint8_t {
None,
Up,
Down,
Switched,
};
enum class NetworkMedium : uint8_t {
None,
Ethernet,
WiFi,
};
enum class NetworkDiagnosticReason : uint8_t {
EthernetActive,
EthernetInitFailed,
EthernetLinkUnknown,
EthernetLinkDown,
EthernetAwaitingIp,
EthernetStabilizing,
SwitchingLocked,
EthernetReady,
};
namespace NetworkPolicy {
struct MQTTTransitionActions {
bool disconnect_started_slots;
bool retry_disconnected_slots_now;
bool reset_reconnect_backoff;
};
static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition) {
return {
transition == NetworkTransition::Down || transition == NetworkTransition::Switched,
transition == NetworkTransition::Up || transition == NetworkTransition::Switched,
transition == NetworkTransition::Switched,
};
}
static constexpr const char* mediumName(NetworkMedium medium) {
return medium == NetworkMedium::Ethernet ? "ethernet"
: medium == NetworkMedium::WiFi ? "wifi"
: "none";
}
struct AutomaticSelectionInput {
NetworkMedium selected;
bool ethernet_connected;
bool wifi_connected;
bool wifi_configured;
bool switching_locked;
uint32_t ethernet_stable_ms;
uint32_t selected_down_ms;
};
// Keep short link flaps from bouncing the default route. Ethernet is allowed
// to recover before Wi-Fi is started, and must then remain usable before it can
// preempt a working Wi-Fi fallback.
static constexpr uint32_t kEthernetDownGraceMs = 3000;
static constexpr uint32_t kEthernetFailbackStableMs = 10000;
static constexpr uint32_t kEthernetNoLinkBootGraceMs = 750;
static constexpr uint32_t kEthernetNoIpRecoveryMs = 120000;
static constexpr uint32_t kEthernetInitRetryMinMs = 5000;
static constexpr uint32_t kEthernetInitRetryMaxMs = 300000;
static constexpr uint32_t kNtpRetryMs = 30000;
static constexpr uint32_t kManualOtaSessionTimeoutMs =
15UL * 60UL * 1000UL;
static inline uint32_t ethernetInitRetryDelayMs(uint8_t attempt) {
if (attempt == 0) return 0;
uint32_t delay_ms = kEthernetInitRetryMinMs;
for (uint8_t i = 1; i < attempt && delay_ms < kEthernetInitRetryMaxMs; ++i) {
delay_ms = delay_ms > kEthernetInitRetryMaxMs / 2
? kEthernetInitRetryMaxMs : delay_ms * 2;
}
return delay_ms;
}
static inline bool ethernetInitRetryDue(uint8_t attempt,
uint32_t now_ms,
uint32_t last_attempt_ms) {
return attempt == 0 ||
(uint32_t)(now_ms - last_attempt_ms) >=
ethernetInitRetryDelayMs(attempt);
}
static constexpr bool ethernetNoIpRecoveryDue(bool initialized,
bool link_up,
bool connected,
uint32_t now_ms,
uint32_t no_ip_since_ms) {
return initialized && link_up && !connected && no_ip_since_ms != 0 &&
(uint32_t)(now_ms - no_ip_since_ms) >= kEthernetNoIpRecoveryMs;
}
static inline NetworkDiagnosticReason automaticDiagnosticReason(
bool ethernet_initialized, bool ethernet_link_known,
bool ethernet_link_up, bool ethernet_connected, NetworkMedium selected,
bool switching_locked, uint32_t ethernet_stable_ms) {
if (!ethernet_initialized) {
return NetworkDiagnosticReason::EthernetInitFailed;
}
if (!ethernet_link_known) {
return NetworkDiagnosticReason::EthernetLinkUnknown;
}
if (!ethernet_link_up) {
return NetworkDiagnosticReason::EthernetLinkDown;
}
if (!ethernet_connected) {
return NetworkDiagnosticReason::EthernetAwaitingIp;
}
if (selected == NetworkMedium::Ethernet) {
return NetworkDiagnosticReason::EthernetActive;
}
if (switching_locked) {
return NetworkDiagnosticReason::SwitchingLocked;
}
if (selected == NetworkMedium::WiFi &&
ethernet_stable_ms < kEthernetFailbackStableMs) {
return NetworkDiagnosticReason::EthernetStabilizing;
}
return NetworkDiagnosticReason::EthernetReady;
}
static inline const char* diagnosticReasonName(
NetworkDiagnosticReason reason) {
switch (reason) {
case NetworkDiagnosticReason::EthernetActive:
return "ethernet-active";
case NetworkDiagnosticReason::EthernetInitFailed:
return "ethernet-init-failed";
case NetworkDiagnosticReason::EthernetLinkUnknown:
return "ethernet-link-unknown";
case NetworkDiagnosticReason::EthernetLinkDown:
return "ethernet-link-down";
case NetworkDiagnosticReason::EthernetAwaitingIp:
return "ethernet-awaiting-ip";
case NetworkDiagnosticReason::EthernetStabilizing:
return "ethernet-stabilizing";
case NetworkDiagnosticReason::SwitchingLocked:
return "switching-locked";
case NetworkDiagnosticReason::EthernetReady:
return "ethernet-ready";
}
return "unknown";
}
static constexpr bool ntpPendingAfterConnectivitySample(
bool synced, bool pending, bool was_connected, bool connected) {
return pending || (!synced && connected && !was_connected);
}
static constexpr bool ntpRetryDue(bool synced, bool connected,
uint32_t now_ms, uint32_t last_attempt_ms) {
return !synced && connected &&
(uint32_t)(now_ms - last_attempt_ms) >= kNtpRetryMs;
}
static constexpr NetworkMedium bootSelection(bool ethernet_connected,
bool wifi_configured) {
return ethernet_connected ? NetworkMedium::Ethernet
: wifi_configured ? NetworkMedium::WiFi
: NetworkMedium::None;
}
// Give the PHY a short window to report carrier. Once a definitive link-down
// sample arrives, do not stall mesh startup for the full DHCP deadline. A
// present link (or an unknown PHY state) still receives the entire probe.
static constexpr bool ethernetBootProbePending(bool ethernet_initialized,
bool ethernet_connected,
bool link_known,
bool link_up,
uint32_t elapsed_ms,
uint32_t wait_ms) {
return ethernet_initialized && !ethernet_connected && elapsed_ms < wait_ms &&
(!link_known || link_up || elapsed_ms < kEthernetNoLinkBootGraceMs);
}
static inline NetworkMedium automaticSelection(
const AutomaticSelectionInput& input) {
if (input.switching_locked) return input.selected;
if (input.selected == NetworkMedium::Ethernet) {
if (input.ethernet_connected) return NetworkMedium::Ethernet;
if (input.wifi_configured && input.wifi_connected &&
input.selected_down_ms >= kEthernetDownGraceMs) {
return NetworkMedium::WiFi;
}
return NetworkMedium::Ethernet;
}
if (input.selected == NetworkMedium::WiFi) {
if (input.ethernet_connected &&
(!input.wifi_connected ||
input.ethernet_stable_ms >= kEthernetFailbackStableMs)) {
return NetworkMedium::Ethernet;
}
return NetworkMedium::WiFi;
}
if (input.ethernet_connected) return NetworkMedium::Ethernet;
if (input.wifi_configured && input.wifi_connected) return NetworkMedium::WiFi;
return NetworkMedium::None;
}
// `start ota` uses the selected LAN only when reachable and not explicitly
// forced to SoftAP. Manifest OTA has no fallback and checks connectivity itself.
static constexpr bool startOtaUsesSelectedNetwork(bool force_ap,
bool network_connected) {
return !force_ap && network_connected;
}
static constexpr bool manualOtaTimeoutDue(uint32_t now_ms,
uint32_t started_ms,
bool upload_in_progress) {
return !upload_in_progress &&
(uint32_t)(now_ms - started_ms) >= kManualOtaSessionTimeoutMs;
}
} // namespace NetworkPolicy
+141
View File
@@ -0,0 +1,141 @@
#pragma once
#include <stdint.h>
#include <string.h>
// Client-side NTPv4 request building and response validation.
//
// Pure logic: no Arduino, WiFi or UDP headers, so every rejection case is
// host-testable. The caller owns the socket and supplies the datagram plus
// whether it arrived from the server it asked (see MQTTBridge::probeNtpServer).
//
// This exists because the NTPClient library accepted *any* non-empty datagram
// on its fixed local port as time: it ignored the read length, the mode, the
// stratum, the leap indicator and the request/response correlation, so a
// one-byte packet from anywhere set the clock — which then feeds JWT issuance,
// certificate validity and the RTC. Everything below is the validation that
// path never had.
namespace NtpValidation {
static const size_t kPacketSize = 48;
// Seconds between the NTP epoch (1900-01-01) and the Unix epoch (1970-01-01).
static const uint32_t kUnixEpochOffset = 2208988800UL;
// Wrap distance for NTP era 1, which starts 2036-02-07: era-1 seconds count
// from there, so a small NTP seconds field is a *future* time, not a past one.
static const uint32_t kEra1UnixOffset = 2085978496UL;
enum Reject : uint8_t {
kAccepted = 0,
kShortPacket, // fewer than 48 bytes: not an NTP response at all
kWrongSource, // not from the address/port we queried
kBadVersion, // NTP version outside 3..4
kBadMode, // not mode 4 (server)
kLeapAlarm, // LI=3: the server itself is unsynchronised
kBadStratum, // 0 (kiss-of-death) or >= 16 (unsynchronised)
kOriginMismatch, // did not echo the transmit timestamp we sent
kZeroTransmit, // no transmit timestamp
kEpochTooEarly, // before the caller's floor
kEpochTooLate, // implausibly far in the future
};
// The transmit timestamp we send and the server must echo back in its originate
// field. Random, so an off-path sender cannot answer a query it never saw.
struct Nonce {
uint32_t seconds;
uint32_t fraction;
};
struct Result {
Reject reject;
uint32_t epoch; // Unix seconds; only meaningful when reject == kAccepted
};
static inline uint32_t readU32(const uint8_t* p) {
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
((uint32_t)p[2] << 8) | (uint32_t)p[3];
}
static inline void writeU32(uint8_t* p, uint32_t v) {
p[0] = (uint8_t)(v >> 24);
p[1] = (uint8_t)(v >> 16);
p[2] = (uint8_t)(v >> 8);
p[3] = (uint8_t)v;
}
// LI=0 (no warning), VN=4, Mode=3 (client). The NTPClient request claimed LI=3,
// advertising an alarm condition it had no business asserting.
static inline void buildRequest(uint8_t out[kPacketSize], Nonce nonce) {
memset(out, 0, kPacketSize);
out[0] = 0x23;
out[1] = 0; // stratum: unspecified
out[2] = 6; // poll interval
out[3] = 0xEC; // precision
writeU32(out + 40, nonce.seconds); // transmit timestamp = our nonce
writeU32(out + 44, nonce.fraction);
}
// Unix time for an NTP seconds field, handling the 2036 era rollover instead of
// wrapping into 1900 the way an unchecked subtraction does.
static inline uint32_t unixFromNtpSeconds(uint32_t ntp_seconds) {
return ntp_seconds >= kUnixEpochOffset ? (ntp_seconds - kUnixEpochOffset)
: (ntp_seconds + kEra1UnixOffset);
}
// `source_matches` is the caller's check that the datagram came from the
// address and port it queried; it is a parameter rather than a lookup so this
// stays free of socket types.
static inline Result validate(const uint8_t* data, size_t len, bool source_matches,
Nonce nonce, uint32_t min_epoch, uint32_t max_epoch) {
Result r;
r.epoch = 0;
if (data == nullptr || len < kPacketSize) { r.reject = kShortPacket; return r; }
if (!source_matches) { r.reject = kWrongSource; return r; }
const uint8_t li = (uint8_t)((data[0] >> 6) & 0x03);
const uint8_t version = (uint8_t)((data[0] >> 3) & 0x07);
const uint8_t mode = (uint8_t)(data[0] & 0x07);
const uint8_t stratum = data[1];
if (version < 3 || version > 4) { r.reject = kBadVersion; return r; }
if (mode != 4) { r.reject = kBadMode; return r; }
if (li == 3) { r.reject = kLeapAlarm; return r; }
// Stratum 0 carries a kiss-of-death code (RATE/DENY/RSTR) rather than time;
// 16 and above means the server has no time to give.
if (stratum == 0 || stratum >= 16) { r.reject = kBadStratum; return r; }
if (readU32(data + 24) != nonce.seconds ||
readU32(data + 28) != nonce.fraction) { r.reject = kOriginMismatch; return r; }
const uint32_t transmit_seconds = readU32(data + 40);
if (transmit_seconds == 0) { r.reject = kZeroTransmit; return r; }
const uint32_t epoch = unixFromNtpSeconds(transmit_seconds);
if (epoch < min_epoch) { r.reject = kEpochTooEarly; return r; }
if (epoch > max_epoch) { r.reject = kEpochTooLate; return r; }
r.reject = kAccepted;
r.epoch = epoch;
return r;
}
// Short enough for a LoRa-bounded diagnostic reply.
static inline const char* rejectReason(Reject reject) {
switch (reject) {
case kAccepted: return "ok";
case kShortPacket: return "short packet";
case kWrongSource: return "wrong source";
case kBadVersion: return "bad version";
case kBadMode: return "not a server reply";
case kLeapAlarm: return "server unsynced";
case kBadStratum: return "bad stratum";
case kOriginMismatch: return "unsolicited reply";
case kZeroTransmit: return "no timestamp";
case kEpochTooEarly: return "time too old";
case kEpochTooLate: return "time too far ahead";
default: return "invalid";
}
}
} // namespace NtpValidation
+116
View File
@@ -0,0 +1,116 @@
#pragma once
#include <stdint.h>
// Decides when the observer radio watchdog should kick a radio that is parked in
// RX but hearing nothing (see Dispatcher::loop()).
//
// Pure logic: no Arduino, radio or mesh headers. The caller feeds the raw 32-bit
// activity timestamps it can see (last receive, last radio interrupt, last
// successful transmit) plus its millis() counter.
//
// Why this is not a max() of those three timestamps: millis() wraps every ~49.7
// days, and a pre-wrap timestamp is numerically larger than every fresh
// post-wrap one. Picking the largest therefore latches onto an old transmit and
// reports the radio silent while it is receiving continuously, resetting a
// healthy radio once per watchdog interval until that timestamp stops winning.
//
// Instead the caller's timestamps are only ever compared with their own previous
// values: any change is fresh activity, stamped against a monotonic 64-bit clock
// extended from millis(). Nothing downstream has a rollover case, and evidence
// older than one 32-bit cycle stays old instead of aliasing back to "recent".
//
// How far a timestamp may run behind the previous one and still count as an
// out-of-order reading rather than a very long forward gap (same rule as
// RadioActivityWindow).
#define RADIO_WATCHDOG_BACKSTEP_TOLERANCE_MS 5000UL
struct RadioWatchdogDecision {
bool recover; // trip now: idle + restart receive
bool measurable; // false until some activity has been observed
uint64_t silent_ms; // age of the most recent activity (0 when !measurable)
};
class RadioWatchdog {
public:
RadioWatchdog() { reset(0); }
void reset(uint32_t now_ms) {
_now_ms = now_ms;
_last_input_ms = now_ms;
_seeded = false;
_has_activity = false;
_last_recv = _last_irq = _last_tx = 0;
_last_activity_ms = now_ms;
_recovered = false;
_last_recovery_ms = 0;
}
// Call once per loop with the current activity timestamps. `in_recv_mode` and
// `watchdog_ms` (0 disables) gate the decision; the silence measurement is
// updated either way so a disabled or transmitting radio does not accumulate
// phantom silence.
RadioWatchdogDecision update(uint32_t now_ms, bool in_recv_mode, uint32_t watchdog_ms,
uint32_t last_recv, uint32_t last_irq, uint32_t last_tx) {
tick(now_ms);
if (!_seeded) {
// First observation: the timestamps carry activity from before this
// tracker existed, so date it to now rather than trusting a raw value we
// cannot place on our own clock. Worst case that delays the first
// possible trip by one watchdog interval.
_seeded = true;
_has_activity = (last_recv != 0 || last_irq != 0 || last_tx != 0);
_last_activity_ms = _now_ms;
} else if (last_recv != _last_recv || last_irq != _last_irq || last_tx != _last_tx) {
_has_activity = true;
_last_activity_ms = _now_ms;
}
_last_recv = last_recv;
_last_irq = last_irq;
_last_tx = last_tx;
RadioWatchdogDecision d;
d.recover = false;
d.measurable = _has_activity;
d.silent_ms = _has_activity ? (_now_ms - _last_activity_ms) : 0;
if (!_has_activity || !in_recv_mode || watchdog_ms == 0) return d;
if (d.silent_ms <= watchdog_ms) return d;
// One recovery per watchdog interval: a radio that stays silent through a
// reset must not be reset every loop.
if (_recovered && (_now_ms - _last_recovery_ms) <= watchdog_ms) return d;
d.recover = true;
return d;
}
// Call after acting on a decision with recover set.
void noteRecovery() {
_recovered = true;
_last_recovery_ms = _now_ms;
}
uint64_t nowMs() const { return _now_ms; }
private:
// Extends the caller's 32-bit millis() to a monotonic 64-bit clock.
void tick(uint32_t now_ms) {
uint32_t delta = now_ms - _last_input_ms;
if (delta > 0x80000000u &&
(uint32_t)(_last_input_ms - now_ms) <= RADIO_WATCHDOG_BACKSTEP_TOLERANCE_MS) {
return; // out-of-order reading: no time has passed
}
_now_ms += delta;
_last_input_ms = now_ms;
}
uint64_t _now_ms;
uint32_t _last_input_ms;
bool _seeded;
bool _has_activity;
uint32_t _last_recv, _last_irq, _last_tx;
uint64_t _last_activity_ms;
bool _recovered;
uint64_t _last_recovery_ms;
};
+1 -1
View File
@@ -20,7 +20,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = {
// NodePrefs (radio / node)
"name", "bluetooth.name", "bluetooth.mac", "bluetooth.stealth",
"lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"cad", "radio.rxgain", "radio.fem.rxgain", "radio.rxps", "powersaving",
"cad", "radio.rxgain", "radio.fem.rxgain", "radio.fem.txgain", "radio.rxps", "powersaving",
"repeat",
"display.mode", "display.timeout", "display.usb.mode", "display.usb.timeout",
"display.inbox",
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include <stdint.h>
#include <string.h>
// The single mapping between the stored `wifi.powersave` preference, its CLI
// name, and the IDF power-save mode.
//
// Pure lookup so host tests can hold startup, reconnect, CLI and web config to
// one table: they used to map the same stored value differently, so a node set
// to `min` silently ran with power save off after its first reconnect while
// `get wifi.powersave` still said min.
//
// Stored values are fleet state — never renumber them. 0 was the shipped
// default from 2026-01-02 to 2026-03-28, and every association path ran it with
// power save off, so it keeps meaning `none`; an explicit `min` is stored as 3.
// Older firmware repairs 3 to `none` when it loads /mqtt.json.
namespace WifiPowerSavePolicy {
enum StoredValue : uint8_t {
kLegacyDefault = 0, // WIFI_PS_NONE: the old default, never an operator choice
kNone = 1, // WIFI_PS_NONE (default)
kMax = 2, // WIFI_PS_MAX_MODEM
kMin = 3, // WIFI_PS_MIN_MODEM
kMaxStoredValue = kMin,
};
// Mirrors wifi_ps_type_t. MQTTBridge.cpp static_asserts these against the SDK.
enum Mode : uint8_t {
kModeNone = 0,
kModeMinModem = 1,
kModeMaxModem = 2,
};
// Anything outside the known range reads as the default rather than as the
// lowest-numbered mode, so a corrupt byte cannot silently enable modem sleep.
static inline Mode modeFor(uint8_t stored) {
switch (stored) {
case kMin: return kModeMinModem;
case kMax: return kModeMaxModem;
case kNone: return kModeNone;
default: return kModeNone;
}
}
static inline const char* nameFor(uint8_t stored) {
switch (stored) {
case kMin: return "min";
case kMax: return "max";
case kNone: return "none";
default: return "none";
}
}
// Parses a CLI argument, which may be followed by trailing text (the observer
// setters take the rest of the command line). Returns false and leaves *out
// untouched for anything else.
static inline bool parseName(const char* value, uint8_t* out) {
if (value == nullptr || out == nullptr) return false;
static const struct { const char* name; uint8_t stored; } kNames[] = {
{ "min", kMin },
{ "none", kNone },
{ "max", kMax },
};
for (unsigned i = 0; i < sizeof(kNames) / sizeof(kNames[0]); i++) {
const size_t len = strlen(kNames[i].name);
if (strncmp(value, kNames[i].name, len) == 0 &&
(value[len] == '\0' || value[len] == ' ')) {
*out = kNames[i].stored;
return true;
}
}
return false;
}
} // namespace WifiPowerSavePolicy
+125
View File
@@ -0,0 +1,125 @@
#pragma once
#include <stdint.h>
// Operator-facing labels for the SDK error codes the observer reports through
// `get wifi.status`, `get mqttN.diag` and the web config panel.
//
// Pure lookup: no Arduino or ESP headers, so the table is host-testable. The
// numeric values below are the installed SDK's; MQTTBridge.cpp static_asserts
// every one of them against the real enum/#define, so a framework bump either
// keeps these labels honest or fails the build. Codes with no entry return
// nullptr and the caller prints the raw number — an unknown code must never be
// given a neighbouring code's name.
namespace MQTTErrorLabels {
// esp_wifi_types.h wifi_err_reason_t. Values below 200 are 802.11 reason codes
// forwarded from the AP; 200+ are Espressif's own local failures.
enum WifiReason : uint16_t {
kWifiAuthExpire = 2,
kWifiAssocExpire = 4,
kWifiAssocLeave = 8,
kWifi4WayHandshakeTimeout = 15,
kWifiGroupCipherInvalid = 18,
kWifiCipherSuiteRejected = 24,
kWifiMissingAcks = 34,
kWifiTimeout = 39,
kWifiInvalidPmkid = 49,
kWifiBssTransitionDisassoc = 12,
kWifiBeaconTimeout = 200,
kWifiNoApFound = 201,
kWifiAuthFail = 202,
kWifiAssocFail = 203,
kWifiHandshakeTimeout = 204,
kWifiConnectionFail = 205,
kWifiApTsfReset = 206,
kWifiRoaming = 207,
kWifiSaQueryTimeout = 209,
};
// esp_tls_errors.h, ESP_ERR_ESP_TLS_BASE (0x8000) + offset.
enum TlsError : int32_t {
kTlsCannotResolveHostname = 0x8001,
kTlsCannotCreateSocket = 0x8002,
kTlsUnsupportedProtoFamily= 0x8003,
kTlsFailedConnectToHost = 0x8004,
kTlsSocketSetoptFailed = 0x8005,
kTlsConnectionTimeout = 0x8006,
kTlsTcpClosedFin = 0x8008,
kTlsMbedtlsCertPartlyOk = 0x8010,
kTlsMbedtlsSetHostname = 0x8012,
kTlsMbedtlsX509ParseFailed= 0x8015,
kTlsMbedtlsSslSetupFailed = 0x8017,
kTlsMbedtlsSslWriteFailed = 0x8018,
kTlsMbedtlsHandshakeFailed= 0x801A,
};
// MQTT 3.1.1 CONNACK return codes (esp_mqtt_error_codes.connect_return_code).
enum ConnackCode : uint8_t {
kConnackAccepted = 0,
kConnackBadProtocol = 1,
kConnackIdRejected = 2,
kConnackServerUnavail = 3,
kConnackBadCredentials = 4,
kConnackNotAuthorized = 5,
};
// Short enough to sit inside a LoRa-bounded diagnostic reply next to the raw code.
static inline const char* wifiReason(uint8_t reason) {
switch (reason) {
case kWifiAuthExpire: return "auth expired";
case kWifiAssocExpire: return "assoc expired";
case kWifiAssocLeave: return "AP disconnected";
case kWifiBssTransitionDisassoc: return "AP steered us away";
case kWifi4WayHandshakeTimeout: return "4-way handshake timeout";
case kWifiGroupCipherInvalid: return "group cipher mismatch";
case kWifiCipherSuiteRejected: return "cipher suite rejected";
case kWifiMissingAcks: return "AP saw no acks";
case kWifiTimeout: return "802.11 timeout";
case kWifiInvalidPmkid: return "invalid PMKID";
case kWifiBeaconTimeout: return "beacon lost";
case kWifiNoApFound: return "SSID not found";
case kWifiAuthFail: return "auth failed (check password)";
case kWifiAssocFail: return "association failed";
case kWifiHandshakeTimeout: return "handshake timeout";
case kWifiConnectionFail: return "connect failed";
case kWifiApTsfReset: return "AP restarted";
case kWifiRoaming: return "roaming";
case kWifiSaQueryTimeout: return "SA query timeout (PMF)";
default: return nullptr;
}
}
static inline const char* tlsError(int32_t err) {
switch (err) {
case kTlsCannotResolveHostname: return "DNS failed";
case kTlsCannotCreateSocket: return "socket error";
case kTlsUnsupportedProtoFamily: return "unsupported protocol family";
case kTlsFailedConnectToHost: return "connect failed";
case kTlsSocketSetoptFailed: return "socket setopt failed";
case kTlsConnectionTimeout: return "connection timeout";
case kTlsTcpClosedFin: return "server closed connection";
case kTlsMbedtlsCertPartlyOk: return "cert chain partly parsed";
case kTlsMbedtlsSetHostname: return "SNI hostname rejected";
case kTlsMbedtlsX509ParseFailed: return "cert parse failed";
case kTlsMbedtlsSslSetupFailed: return "TLS setup failed";
case kTlsMbedtlsSslWriteFailed: return "TLS write failed";
case kTlsMbedtlsHandshakeFailed: return "TLS handshake failed";
default: return nullptr;
}
}
// The broker answered and refused: this is what an operator needs instead of a
// transport error, which is why the bridge keeps the CONNACK code separately.
static inline const char* connackReason(uint8_t code) {
switch (code) {
case kConnackBadProtocol: return "protocol rejected";
case kConnackIdRejected: return "client id rejected";
case kConnackServerUnavail: return "server unavailable";
case kConnackBadCredentials: return "bad user/password";
case kConnackNotAuthorized: return "not authorized";
default: return nullptr;
}
}
} // namespace MQTTErrorLabels
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <atomic>
#include <stdint.h>
// Ownership guard for the two AsyncWebServer users. It prevents `start ota`
// from claiming port 80 while WebConfig is listening (and vice versa), even
// when the requests arrive from different tasks.
namespace HttpPort80Lease {
enum class Owner : uint8_t { None = 0, WebConfig, Ota };
inline std::atomic<Owner>& current() {
static std::atomic<Owner> owner{Owner::None};
return owner;
}
inline bool acquire(Owner requested) {
if (requested == Owner::None) return false;
Owner expected = Owner::None;
return current().compare_exchange_strong(
expected, requested, std::memory_order_acq_rel,
std::memory_order_relaxed);
}
inline void release(Owner expected) {
current().compare_exchange_strong(
expected, Owner::None, std::memory_order_acq_rel,
std::memory_order_relaxed);
}
inline const char* ownerName() {
switch (current().load(std::memory_order_acquire)) {
case Owner::WebConfig: return "webconfig";
case Owner::Ota: return "ota";
default: return "none";
}
}
} // namespace HttpPort80Lease
File diff suppressed because it is too large. Load diff
+44 -1
View File
@@ -238,6 +238,12 @@ static inline bool wcIsDeferredReboot(const char* cmd) {
return strncmp(cmd, "reboot", 6) == 0;
}
static const char* const WC_BOARD_CMDS[] = {
"radio.fem.rxgain", "radio.fem.txgain", "fan",
};
static const size_t WC_BOARD_CMD_COUNT = sizeof(WC_BOARD_CMDS) / sizeof(WC_BOARD_CMDS[0]);
static_assert(WC_BOARD_CMD_COUNT <= 8, "board command mask must fit uint8_t");
// Commands the CLI reaches but the portal cannot honestly serve. Rejected at
// POST so nothing in the sequence runs, rather than failing halfway with a
// reply that does not explain itself. Returns the reason, or NULL if fine.
@@ -1007,9 +1013,25 @@ void WebConfigServer::finalizeTeardown() {
_admin_pwd_set = false;
_session_token[0] = 0;
_stats_json[0] = 0;
_board_cmds = 0;
_board_cmds_probed = false;
if (_cb) _cb->onWebConfigStopped();
}
void WebConfigServer::probeBoardCommands() {
char command[48], reply[160];
uint8_t mask = 0;
for (size_t i = 0; i < WC_BOARD_CMD_COUNT; ++i) {
snprintf(command, sizeof(command), "get %s", WC_BOARD_CMDS[i]);
reply[0] = 0;
_cb->execCommand(command, reply);
if (reply[0] == '>') mask |= static_cast<uint8_t>(1U << i);
}
WCLock lock(_mux);
_board_cmds = mask;
_board_cmds_probed = true;
}
void WebConfigServer::tick(uint32_t now) {
if (_stopping) {
uint32_t refs = handlerRefCount();
@@ -1030,6 +1052,8 @@ void WebConfigServer::tick(uint32_t now) {
}
if (_mode == MODE_OFF) return;
if (!_board_cmds_probed) probeBoardCommands();
serviceTerminal(now);
// A primary ESP-NOW radio and its WiFi station cannot remain on different
@@ -1662,7 +1686,7 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
}
const bool has_mqtt = _mqtt_prefs != NULL;
DynamicJsonDocument doc(768);
DynamicJsonDocument doc(896);
doc["mode"] = (_mode == MODE_SETUP) ? "setup" : "lan";
doc["auth"] = authed;
doc["needs_setup"] = (_wifi_ssid[0] == 0);
@@ -1678,6 +1702,25 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
doc["build_date"] = _build_date;
doc["role"] = _role;
doc["board"] = _board_name;
uint8_t board_mask = 0;
bool board_probed = false;
{
WCLock lock(_mux);
board_mask = _board_cmds;
board_probed = _board_cmds_probed;
}
if (board_probed) {
char board_cmds[80] = {0};
size_t used = 0;
for (size_t i = 0; i < WC_BOARD_CMD_COUNT; ++i) {
if ((board_mask & (1U << i)) == 0) continue;
const int written = snprintf(board_cmds + used, sizeof(board_cmds) - used,
"%s%s", used ? "," : "", WC_BOARD_CMDS[i]);
if (written < 0 || static_cast<size_t>(written) >= sizeof(board_cmds) - used) break;
used += static_cast<size_t>(written);
}
doc["board_cmds"] = board_cmds;
}
doc["uptime_s"] = millis() / 1000;
#ifdef WITH_MQTT_BRIDGE
doc["runtime_slots"] = has_mqtt ? RUNTIME_MQTT_SLOTS : 0;
+3
View File
@@ -333,6 +333,8 @@ private:
volatile uint32_t _stats_wanted_until = 0;
uint32_t _stats_built_at = 0;
char _stats_json[1024] = {0};
uint8_t _board_cmds = 0;
bool _board_cmds_probed = false;
void createServer();
void registerRoutes();
@@ -342,6 +344,7 @@ private:
void detachRoutes();
uint32_t handlerRefCount() const;
void drainBatch(uint32_t now);
void probeBoardCommands();
void serviceTerminal(uint32_t now);
void closeTerminal();
void serviceSetupWiFiHandoff(uint32_t now);
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <ESP32_CH390.h>
/**
* Bring up the repository's CH390 lwIP interface from the board build flags.
* Shared by the companion transport wrapper and the observer network adapter so
* pin and static-IP behavior cannot drift between the two paths.
*/
static inline bool beginConfiguredCH390(const char* hostname = nullptr) {
ch390_config_t config = CH390_DEFAULT_CONFIG();
config.spi_miso_gpio = ETH_MISO_PIN;
config.spi_mosi_gpio = ETH_MOSI_PIN;
config.spi_sck_gpio = ETH_SCLK_PIN;
config.spi_cs_gpio = ETH_CS_PIN;
config.int_gpio = ETH_INT_PIN;
if (hostname && hostname[0] != '\0') {
if (!CH390.setHostname(hostname)) {
Serial.printf("Network: invalid Ethernet hostname %s\n", hostname);
}
}
if (!CH390.begin(config)) return false;
#if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET)
IPAddress ip(ETHERNET_STATIC_IP);
IPAddress gateway(ETHERNET_STATIC_GATEWAY);
IPAddress subnet(ETHERNET_STATIC_SUBNET);
#if defined(ETHERNET_STATIC_DNS)
IPAddress dns(ETHERNET_STATIC_DNS);
CH390.config(ip, gateway, subnet, dns);
#else
CH390.config(ip, gateway, subnet);
#endif
#endif
return true;
}
@@ -1,4 +1,5 @@
#include "CH390EthernetInterface.h"
#include "CH390Config.h"
void onWiFiEvent(WiFiEvent_t event) {
switch(event){
@@ -29,26 +30,12 @@ bool CH390EthernetInterface::begin() {
// listen to ethernet events
WiFi.onEvent(onWiFiEvent);
// Init CH390
ch390_config_t config = CH390_DEFAULT_CONFIG();
config.spi_miso_gpio = ETH_MISO_PIN;
config.spi_mosi_gpio = ETH_MOSI_PIN;
config.spi_sck_gpio = ETH_SCLK_PIN;
config.spi_cs_gpio = ETH_CS_PIN;
config.int_gpio = ETH_INT_PIN;
if (!CH390.begin(config)) {
// Init CH390 using the same board configuration as the observer uplink.
if (!beginConfiguredCH390()) {
ETHERNET_DEBUG_PRINTLN("Failed to initialize CH390 hardware.");
return false;
}
// Setup Static IP if build flags are present
#if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET)
IPAddress ip(ETHERNET_STATIC_IP);
IPAddress gw(ETHERNET_STATIC_GATEWAY);
IPAddress sn(ETHERNET_STATIC_SUBNET);
CH390.config(ip, gw, sn);
#endif
// Start Server
server.begin(ETHERNET_TCP_PORT);
ETHERNET_DEBUG_PRINTLN("listening on TCP port: %d", ETHERNET_TCP_PORT);
+2 -2
View File
@@ -24,11 +24,11 @@ RotaryInputEvent RotaryInputGPIO::poll() {
if (!b_prec && b) { // rising edge of A
if (a) {
ev = RotaryInputEvent::Next;
} else {
} else {
ev = RotaryInputEvent::Prev;
}
}
b_prec = b;
return ev;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
#define BUTTON_EVENT_DOWN 6
class RotaryInputGPIO: public RotaryInput {
int8_t _pin_a;
int8_t _pin_a;
bool _pull_a;
int8_t _pin_b;
bool _pull_b;
+5
View File
@@ -3,8 +3,13 @@
#include <cstdint>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include "Stream.h"
using std::atof;
using std::atoi;
using std::atol;
inline uint32_t g_mock_millis = 0;
constexpr uint8_t LOW = 0;
+32
View File
@@ -0,0 +1,32 @@
#include "helpers/CommonRadioPrefs.h"
#include <stdio.h>
#include <stdlib.h>
// Native serializer tests instantiate NodePrefs, but the full radio CLI source
// depends on target hardware. These two methods are the only out-of-line vtable
// entries the host-side NodePrefs tests require.
bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) {
if (strcmp(key, "fem_rxgain") == 0) {
snprintf(value, max_len, "%d", (uint32_t)getFEMRxGain());
return true;
}
if (strcmp(key, "fem_txgain") == 0) {
snprintf(value, max_len, "%d", (uint32_t)getFEMTxGain());
return true;
}
return false;
}
bool CommonRadioPrefs::setByKey(const char* key, const char* value) {
if (strcmp(key, "fem_rxgain") == 0) {
setFEMRxGain(atoi(value));
markDirty();
return true;
}
if (strcmp(key, "fem_txgain") == 0) {
setFEMTxGain(atoi(value));
markDirty();
return true;
}
return false;
}
@@ -0,0 +1,118 @@
#include "helpers/MQTTClientState.h"
#include <gtest/gtest.h>
#include <string.h>
#include <vector>
namespace {
const MqttClientState kAllStates[] = {
MqttClientState::Absent, MqttClientState::Configured,
MqttClientState::Starting, MqttClientState::Connected,
MqttClientState::Disconnected, MqttClientState::Stopped,
MqttClientState::Quarantined,
};
bool ack(std::vector<MqttClientState> states) {
return mqttStopMayBeAcknowledged(states.data(), (int)states.size());
}
} // namespace
TEST(MqttClientState, LiveMeansStartedAndNotProvenStopped) {
EXPECT_TRUE(mqttClientStateIsLive(MqttClientState::Starting));
EXPECT_TRUE(mqttClientStateIsLive(MqttClientState::Connected));
EXPECT_TRUE(mqttClientStateIsLive(MqttClientState::Disconnected));
EXPECT_FALSE(mqttClientStateIsLive(MqttClientState::Absent));
EXPECT_FALSE(mqttClientStateIsLive(MqttClientState::Configured));
EXPECT_FALSE(mqttClientStateIsLive(MqttClientState::Stopped));
// Quarantined is not "live": it is worse. It may own a task and we can never
// find out, which is why it is not simply lumped in with the live states.
EXPECT_FALSE(mqttClientStateIsLive(MqttClientState::Quarantined));
}
TEST(MqttClientState, OnlyStartingHasAnAttemptInFlight) {
for (MqttClientState s : kAllStates) {
EXPECT_EQ(s == MqttClientState::Starting, mqttClientStateHasAttemptInFlight(s))
<< mqttClientStateName(s);
}
}
TEST(MqttClientState, ProvenStoppedIsTheComplementOfLiveAndQuarantined) {
for (MqttClientState s : kAllStates) {
const bool proven = mqttClientStateIsProvenStopped(s);
EXPECT_EQ(!mqttClientStateIsLive(s) && s != MqttClientState::Quarantined, proven)
<< mqttClientStateName(s);
}
}
// The invariant the shutdown contract rests on: the acknowledgement means the
// task destroyed its clients, so it may not be published while any client's
// stop is unproven.
TEST(MqttClientState, StopIsAcknowledgedOnlyWhenEveryClientIsProvenStopped) {
EXPECT_TRUE(ack({})); // no slots configured at all
EXPECT_TRUE(ack({MqttClientState::Absent, MqttClientState::Stopped,
MqttClientState::Configured}));
// One quarantined client withholds the acknowledgement, whatever the others did.
EXPECT_FALSE(ack({MqttClientState::Quarantined}));
EXPECT_FALSE(ack({MqttClientState::Stopped, MqttClientState::Stopped,
MqttClientState::Quarantined, MqttClientState::Absent}));
EXPECT_FALSE(ack({MqttClientState::Quarantined, MqttClientState::Absent,
MqttClientState::Absent, MqttClientState::Absent,
MqttClientState::Absent, MqttClientState::Absent}));
// So does a client that is merely still live: teardown did not finish it.
EXPECT_FALSE(ack({MqttClientState::Stopped, MqttClientState::Connected}));
EXPECT_FALSE(ack({MqttClientState::Starting}));
EXPECT_FALSE(ack({MqttClientState::Disconnected}));
// An empty set is trivially proven; a null array with a nonzero count is a
// caller bug and must never read as proof.
EXPECT_TRUE(mqttStopMayBeAcknowledged(nullptr, 0));
EXPECT_FALSE(mqttStopMayBeAcknowledged(nullptr, 3));
}
// A link transition closes each slot's transport because the route under it
// changed. These two predicates are what decide which clients it may touch.
//
// The distinction from a reconfigure matters: a reconfigure must cancel an
// in-flight attempt, because that attempt would report the OLD endpoint under
// the new configuration. A transition changes no configuration, so the attempt
// is left to resolve — cancelling it would mean an unbounded
// esp_mqtt_client_stop() on the sole MQTT worker during a routine link flap.
TEST(MqttClientState, LinkTransitionOnlyDisconnectsClientsWithNoAttemptInFlight) {
for (MqttClientState s : kAllStates) {
const bool live = mqttClientStateIsLive(s);
const bool disconnected_here = live && !mqttClientStateHasAttemptInFlight(s);
// Quarantined is never touched: its SDK task was not joined, so it stays
// out of service for the rest of the boot.
if (s == MqttClientState::Quarantined) EXPECT_FALSE(live) << mqttClientStateName(s);
// Nothing that is already proven stopped is worth a disconnect.
if (mqttClientStateIsProvenStopped(s)) EXPECT_FALSE(live) << mqttClientStateName(s);
EXPECT_EQ(s == MqttClientState::Connected || s == MqttClientState::Disconnected,
disconnected_here) << mqttClientStateName(s);
// Starting is live, and still must not be disconnected here.
if (s == MqttClientState::Starting) {
EXPECT_TRUE(live);
EXPECT_FALSE(disconnected_here);
}
}
}
TEST(MqttClientState, EveryStateHasAName) {
for (MqttClientState s : kAllStates) {
ASSERT_NE(nullptr, mqttClientStateName(s));
EXPECT_GT(strlen(mqttClientStateName(s)), 0u);
}
EXPECT_STREQ("quarantined", mqttClientStateName(MqttClientState::Quarantined));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,203 @@
#include "helpers/MQTTEffectiveConfig.h"
#include <gtest/gtest.h>
#include <string.h>
namespace {
const char kPemA[] = "-----BEGIN CERTIFICATE-----A";
const char kPemB[] = "-----BEGIN CERTIFICATE-----B";
const char kJwtUser[] = "v1_CC5D3CFD9C4C7B84";
const char kToken[] = "eyJhbGciOi...";
MqttEffectiveConfig build(const char* uri,
MqttAuth auth = MqttAuth::None,
const char* user = nullptr,
const char* pass = nullptr,
MqttTrust trust = MqttTrust::Plaintext,
const char* pem = nullptr,
uint16_t buffer = 896) {
MqttEffectiveConfig c;
mqttBuildEffectiveConfig(uri, auth, user, pass, trust, pem, buffer, 75, &c);
return c;
}
MqttRecreateDecision decide(const MqttEffectiveConfig& applied,
const MqttEffectiveConfig& desired,
uint16_t allocated = 896) {
return mqttConfigRecreateDecision(applied, desired, allocated);
}
} // namespace
TEST(MqttEffectiveConfig, TransportComesFromTheUriScheme) {
EXPECT_EQ(MqttTransport::Tcp, mqttTransportFromUri("mqtt://broker:1883"));
EXPECT_EQ(MqttTransport::Tls, mqttTransportFromUri("mqtts://broker:8883"));
EXPECT_EQ(MqttTransport::Ws, mqttTransportFromUri("ws://broker/mqtt"));
EXPECT_EQ(MqttTransport::Wss, mqttTransportFromUri("wss://broker:443/mqtt"));
EXPECT_EQ(MqttTransport::Unknown, mqttTransportFromUri("broker:1883"));
EXPECT_EQ(MqttTransport::Unknown, mqttTransportFromUri(""));
EXPECT_EQ(MqttTransport::Unknown, mqttTransportFromUri(nullptr));
EXPECT_TRUE(mqttTransportIsEncrypted(MqttTransport::Tls));
EXPECT_TRUE(mqttTransportIsEncrypted(MqttTransport::Wss));
EXPECT_FALSE(mqttTransportIsEncrypted(MqttTransport::Tcp));
EXPECT_FALSE(mqttTransportIsEncrypted(MqttTransport::Ws));
}
TEST(MqttEffectiveConfig, BuildRejectsUnusableUris) {
MqttEffectiveConfig c;
EXPECT_FALSE(mqttBuildEffectiveConfig("", MqttAuth::None, nullptr, nullptr,
MqttTrust::Plaintext, nullptr, 896, 75, &c));
EXPECT_FALSE(c.valid);
EXPECT_FALSE(mqttBuildEffectiveConfig(nullptr, MqttAuth::None, nullptr, nullptr,
MqttTrust::Plaintext, nullptr, 896, 75, &c));
EXPECT_FALSE(mqttBuildEffectiveConfig("broker:1883", MqttAuth::None, nullptr, nullptr,
MqttTrust::Plaintext, nullptr, 896, 75, &c));
char too_long[MQTT_EFFECTIVE_URI_MAX + 16];
memset(too_long, 'x', sizeof(too_long));
memcpy(too_long, "mqtt://", 7);
too_long[sizeof(too_long) - 1] = '\0';
EXPECT_FALSE(mqttBuildEffectiveConfig(too_long, MqttAuth::None, nullptr, nullptr,
MqttTrust::Plaintext, nullptr, 896, 75, &c));
}
TEST(MqttEffectiveConfig, BuildOwnsItsUriCopy) {
char uri[64];
strcpy(uri, "mqtt://192.168.50.231:1883");
MqttEffectiveConfig c = build(uri);
ASSERT_TRUE(c.valid);
strcpy(uri, "mqtt://other:1883"); // slot.broker_uri is rewritten in place
EXPECT_STREQ("mqtt://192.168.50.231:1883", c.uri);
}
// An unencrypted transport verifies nothing, whatever certificate material was
// requested. Recording anything else would make a plaintext endpoint look like
// it kept a verified policy.
TEST(MqttEffectiveConfig, TrustIsNormalisedAgainstTheTransport) {
MqttEffectiveConfig plain = build("mqtt://broker:1883", MqttAuth::None, nullptr, nullptr,
MqttTrust::Bundle, kPemA);
EXPECT_EQ(MqttTrust::Plaintext, plain.trust);
EXPECT_EQ(nullptr, plain.pem);
MqttEffectiveConfig bundle = build("wss://broker:443/mqtt", MqttAuth::Jwt, kJwtUser, kToken,
MqttTrust::Bundle, nullptr);
EXPECT_EQ(MqttTrust::Bundle, bundle.trust);
MqttEffectiveConfig pem = build("mqtts://broker:8883", MqttAuth::UserPass, "u", "p",
MqttTrust::PemCert, kPemA);
EXPECT_EQ(MqttTrust::PemCert, pem.trust);
EXPECT_EQ(kPemA, pem.pem);
// Encrypted transport with no usable material is not a verified policy.
MqttEffectiveConfig none = build("mqtts://broker:8883", MqttAuth::None, nullptr, nullptr,
MqttTrust::PemCert, nullptr);
EXPECT_EQ(MqttTrust::Plaintext, none.trust);
EXPECT_EQ(nullptr, none.pem);
}
// The six F02 transitions from the review, plus the one reproduced on hardware.
// None of them needs a new client: credentials are rewritten unconditionally.
TEST(MqttEffectiveConfig, CredentialAndAuthChangesReuseTheClient) {
const MqttEffectiveConfig jwt = build("wss://broker:443/mqtt", MqttAuth::Jwt, kJwtUser, kToken,
MqttTrust::Bundle, nullptr);
const MqttEffectiveConfig anon = build("wss://broker:443/mqtt", MqttAuth::None, nullptr, nullptr,
MqttTrust::Bundle, nullptr);
const MqttEffectiveConfig userpass = build("wss://broker:443/mqtt", MqttAuth::UserPass, "u", "p",
MqttTrust::Bundle, nullptr);
const MqttEffectiveConfig renewed = build("wss://broker:443/mqtt", MqttAuth::Jwt, kJwtUser,
"eyJhbGciOi...new", MqttTrust::Bundle, nullptr);
EXPECT_FALSE(decide(jwt, anon).recreate); // JWT -> anonymous
EXPECT_FALSE(decide(userpass, anon).recreate); // user/pass -> anonymous
EXPECT_FALSE(decide(anon, jwt).recreate); // none -> configured
EXPECT_FALSE(decide(userpass, jwt).recreate); // user/pass -> JWT
EXPECT_FALSE(decide(jwt, renewed).recreate); // token renewal
EXPECT_FALSE(decide(jwt, jwt).recreate); // ordinary reconnect
EXPECT_STREQ("reuse", decide(jwt, jwt).reason);
}
// Host/port/path within one scheme is an endpoint move, not a structural one.
TEST(MqttEffectiveConfig, EndpointMoveWithinOneSchemeReusesTheClient) {
const MqttEffectiveConfig a = build("mqtt://192.168.50.231:1883");
const MqttEffectiveConfig b = build("mqtt://192.168.50.9:1884");
EXPECT_FALSE(decide(a, b).recreate);
const MqttEffectiveConfig wss_a = build("wss://a.example:443/mqtt", MqttAuth::Jwt, kJwtUser,
kToken, MqttTrust::Bundle, nullptr);
const MqttEffectiveConfig wss_b = build("wss://b.example:443/other", MqttAuth::Jwt, kJwtUser,
kToken, MqttTrust::Bundle, nullptr);
EXPECT_FALSE(decide(wss_a, wss_b).recreate);
}
TEST(MqttEffectiveConfig, TransportChangeForcesRecreate) {
const MqttEffectiveConfig wss = build("wss://broker:443/mqtt", MqttAuth::Jwt, kJwtUser, kToken,
MqttTrust::Bundle, nullptr);
const MqttEffectiveConfig tcp = build("mqtt://192.168.50.231:1883");
const MqttEffectiveConfig tls = build("mqtts://broker:8883", MqttAuth::UserPass, "u", "p",
MqttTrust::Bundle, nullptr);
const MqttEffectiveConfig ws = build("ws://broker/mqtt");
EXPECT_TRUE(decide(wss, tcp).recreate);
EXPECT_STREQ("transport-change", decide(wss, tcp).reason);
EXPECT_TRUE(decide(tcp, wss).recreate);
EXPECT_TRUE(decide(wss, tls).recreate); // both encrypted, still a scheme change
EXPECT_TRUE(decide(tcp, ws).recreate); // both plaintext, still a scheme change
}
TEST(MqttEffectiveConfig, TrustChangeForcesRecreate) {
const MqttEffectiveConfig bundle = build("mqtts://broker:8883", MqttAuth::None, nullptr, nullptr,
MqttTrust::Bundle, nullptr);
const MqttEffectiveConfig pem_a = build("mqtts://broker:8883", MqttAuth::None, nullptr, nullptr,
MqttTrust::PemCert, kPemA);
const MqttEffectiveConfig pem_b = build("mqtts://broker:8883", MqttAuth::None, nullptr, nullptr,
MqttTrust::PemCert, kPemB);
EXPECT_TRUE(decide(bundle, pem_a).recreate);
EXPECT_STREQ("trust-change", decide(bundle, pem_a).reason);
EXPECT_TRUE(decide(pem_a, bundle).recreate);
EXPECT_TRUE(decide(pem_a, pem_b).recreate);
EXPECT_STREQ("ca-cert-change", decide(pem_a, pem_b).reason);
EXPECT_FALSE(decide(pem_a, pem_a).recreate);
}
// IDF 4.4 allocates the MQTT buffers at client init and does not resize them,
// and the wrapper's reassembly buffer is allocated once for the client's life.
TEST(MqttEffectiveConfig, BufferGrowthForcesRecreateButShrinkDoesNot) {
const MqttEffectiveConfig small = build("mqtt://broker:1883", MqttAuth::None, nullptr, nullptr,
MqttTrust::Plaintext, nullptr, 512);
const MqttEffectiveConfig large = build("mqtt://broker:1883", MqttAuth::Jwt, kJwtUser, kToken,
MqttTrust::Plaintext, nullptr, 896);
EXPECT_TRUE(decide(small, large, /*allocated=*/512).recreate);
EXPECT_STREQ("buffer-growth", decide(small, large, 512).reason);
EXPECT_FALSE(decide(small, large, /*allocated=*/896).recreate)
<< "a client that already owns the capacity does not need recreating";
EXPECT_FALSE(decide(large, small, /*allocated=*/896).recreate);
}
TEST(MqttEffectiveConfig, FirstApplyAndInvalidDesiredNeverRecreate) {
const MqttEffectiveConfig unset;
const MqttEffectiveConfig wss = build("wss://broker:443/mqtt", MqttAuth::Jwt, kJwtUser, kToken,
MqttTrust::Bundle, nullptr);
EXPECT_FALSE(unset.valid);
EXPECT_FALSE(decide(unset, wss).recreate);
EXPECT_STREQ("first-apply", decide(unset, wss).reason);
EXPECT_FALSE(decide(wss, unset).recreate);
EXPECT_STREQ("invalid-desired", decide(wss, unset).reason);
}
// Rule 1: absent is a value that gets written, never an omission.
TEST(MqttEffectiveConfig, AbsentFieldsAreWrittenAsEmptyStrings) {
EXPECT_STREQ("", mqttFieldOrEmpty(nullptr));
EXPECT_STREQ("alice", mqttFieldOrEmpty("alice"));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,78 @@
#include "helpers/bridges/MQTTErrorLabels.h"
#include <gtest/gtest.h>
#include <string.h>
using namespace MQTTErrorLabels;
namespace {
bool labelIs(const char* actual, const char* expected) {
return actual != nullptr && strcmp(actual, expected) == 0;
}
} // namespace
// F14: these four were the mislabels found in review. 201 is "no AP found"
// (usually a wrong/hidden SSID), not a security mismatch; 202 is an auth
// failure (usually a wrong password), not a rejected auth mode.
TEST(MQTTErrorLabels, WifiReasonsMatchTheSdkMeaning) {
EXPECT_TRUE(labelIs(wifiReason(201), "SSID not found"));
EXPECT_TRUE(labelIs(wifiReason(202), "auth failed (check password)"));
EXPECT_TRUE(labelIs(wifiReason(39), "802.11 timeout")); // was "SSID not found"
EXPECT_TRUE(labelIs(wifiReason(34), "AP saw no acks")); // was "AP state mismatch"
}
TEST(MQTTErrorLabels, TlsErrorsMatchTheSdkMeaning) {
EXPECT_TRUE(labelIs(tlsError(0x8008), "server closed connection")); // was "connection timeout"
EXPECT_TRUE(labelIs(tlsError(0x8010), "cert chain partly parsed")); // was "mbedTLS error"
EXPECT_TRUE(labelIs(tlsError(0x8006), "connection timeout"));
EXPECT_TRUE(labelIs(tlsError(0x8001), "DNS failed"));
EXPECT_TRUE(labelIs(tlsError(0x801A), "TLS handshake failed"));
}
// An unknown code must fall through to the caller's raw-number formatting
// rather than borrow a neighbouring code's label. 0x800B is the one the review
// caught: no such esp-tls error exists, but it was labelled "cert verify failed".
TEST(MQTTErrorLabels, UnknownCodesHaveNoLabel) {
EXPECT_EQ(nullptr, tlsError(0x800B));
EXPECT_EQ(nullptr, tlsError(0));
EXPECT_EQ(nullptr, tlsError(-1));
EXPECT_EQ(nullptr, tlsError(0x8099));
EXPECT_EQ(nullptr, wifiReason(0));
EXPECT_EQ(nullptr, wifiReason(61)); // not defined by the installed SDK
EXPECT_EQ(nullptr, wifiReason(88));
EXPECT_EQ(nullptr, wifiReason(168));
EXPECT_EQ(nullptr, wifiReason(255));
}
TEST(MQTTErrorLabels, ConnackReasonsCoverTheRefusalCodes) {
EXPECT_TRUE(labelIs(connackReason(1), "protocol rejected"));
EXPECT_TRUE(labelIs(connackReason(2), "client id rejected"));
EXPECT_TRUE(labelIs(connackReason(3), "server unavailable"));
EXPECT_TRUE(labelIs(connackReason(4), "bad user/password"));
EXPECT_TRUE(labelIs(connackReason(5), "not authorized"));
EXPECT_EQ(nullptr, connackReason(0)); // accepted: not an error to report
EXPECT_EQ(nullptr, connackReason(6));
}
// Labels share a bounded LoRa reply with the rest of the diagnostic line.
TEST(MQTTErrorLabels, LabelsStayShortEnoughForABoundedReply) {
for (int i = 0; i <= 255; i++) {
const char* w = wifiReason((uint8_t)i);
if (w) EXPECT_LE(strlen(w), 30u) << "wifi reason " << i;
const char* c = connackReason((uint8_t)i);
if (c) EXPECT_LE(strlen(c), 30u) << "connack code " << i;
}
for (int32_t e = 0x8000; e <= 0x8040; e++) {
const char* t = tlsError(e);
if (t) EXPECT_LE(strlen(t), 30u) << "tls error " << e;
}
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,139 @@
#include <gtest/gtest.h>
#include <cstring>
#include "helpers/NetworkHostname.h"
namespace {
const uint8_t kStableId[] = {0xab, 0xcd, 0x01};
void expectDhcpSafe(const char* hostname) {
ASSERT_NE(nullptr, hostname);
const size_t length = strlen(hostname);
EXPECT_GT(length, 0u);
EXPECT_LE(length, NetworkHostname::kMaxLength);
EXPECT_NE('-', hostname[0]);
EXPECT_NE('-', hostname[length - 1]);
for (size_t i = 0; i < length; ++i) {
const char ch = hostname[i];
EXPECT_TRUE((ch >= 'a' && ch <= 'z') ||
(ch >= '0' && ch <= '9') || ch == '-')
<< "invalid byte at " << i;
}
}
} // namespace
TEST(NetworkHostname, PrefixesAndLowercasesReadableNodeName) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
"Hill Repeater 2", kStableId,
sizeof(kStableId)));
EXPECT_STREQ("meshcore-hill-repeater-2", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, CollapsesInvalidRunsAndTrimsEdges) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
" Adam's___Hill / Repeater! ",
kStableId, sizeof(kStableId)));
EXPECT_STREQ("meshcore-adam-s-hill-repeater", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, UsesDocumentedFallbackForEmptySanitizedName) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), "___ !!!",
kStableId, sizeof(kStableId)));
EXPECT_STREQ("meshcore-node-abcd01", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, RemovesUtf8AndRetainsStableIdentity) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
"M\xC3\xBCnchen", kStableId,
sizeof(kStableId)));
EXPECT_STREQ("meshcore-m-nchen-abcd01", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, MarksCombiningUnicodeAsLossy) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
"Cafe\xCC\x81", kStableId,
sizeof(kStableId)));
EXPECT_STREQ("meshcore-cafe-abcd01", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, UnicodeOnlyNamesUseDistinctIdentityFallbacks) {
const uint8_t other_id[] = {0x12, 0x34, 0x56};
const char unicode_only[] = "\xE7\xBD\x91\xE6\xA0\xBC\xF0\x9F\x8C\x90";
char first[NetworkHostname::kBufferSize];
char second[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(first, sizeof(first), unicode_only,
kStableId, sizeof(kStableId)));
ASSERT_TRUE(NetworkHostname::build(second, sizeof(second), unicode_only,
other_id, sizeof(other_id)));
EXPECT_STREQ("meshcore-node-abcd01", first);
EXPECT_STREQ("meshcore-node-123456", second);
EXPECT_STRNE(first, second);
expectDhcpSafe(first);
expectDhcpSafe(second);
}
TEST(NetworkHostname, MalformedUtf8CannotReachDhcpHostname) {
char hostname[NetworkHostname::kBufferSize];
const char malformed[] = {'b', 'a', 'd', static_cast<char>(0xff),
'n', 'a', 'm', 'e', '\0'};
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), malformed,
kStableId, sizeof(kStableId)));
EXPECT_STREQ("meshcore-bad-name-abcd01", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, KeepsExactBoundaryWithoutIdentitySuffix) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
"abcdefghijklmnopqrstuv", kStableId,
sizeof(kStableId)));
EXPECT_STREQ("meshcore-abcdefghijklmnopqrstuv", hostname);
EXPECT_EQ(NetworkHostname::kMaxLength, strlen(hostname));
}
TEST(NetworkHostname, TruncatesReadablePartAndAddsStableIdentitySuffix) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
"ABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
kStableId, sizeof(kStableId)));
EXPECT_STREQ("meshcore-abcdefghijklmno-abcd01", hostname);
EXPECT_EQ(NetworkHostname::kMaxLength, strlen(hostname));
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, IdentitySuffixSeparatesOtherwiseCollidingNames) {
const uint8_t other_id[] = {0x12, 0x34, 0x56};
char first[NetworkHostname::kBufferSize];
char second[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(first, sizeof(first),
"ABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
kStableId, sizeof(kStableId)));
ASSERT_TRUE(NetworkHostname::build(second, sizeof(second),
"ABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
other_id, sizeof(other_id)));
EXPECT_STRNE(first, second);
EXPECT_STREQ("meshcore-abcdefghijklmno-123456", second);
}
TEST(NetworkHostname, RejectsMissingOutputBuffer) {
EXPECT_FALSE(NetworkHostname::build(nullptr, 0, "node", kStableId,
sizeof(kStableId)));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,202 @@
#include <gtest/gtest.h>
#include "helpers/NetworkPolicy.h"
TEST(NetworkPolicy, MqttDownDisconnectsSlotsWithoutRequestingImmediateRetry) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Down);
EXPECT_TRUE(actions.disconnect_started_slots);
EXPECT_FALSE(actions.retry_disconnected_slots_now);
EXPECT_FALSE(actions.reset_reconnect_backoff);
}
TEST(NetworkPolicy, MqttUpRetriesWithoutClearingBrokerCircuitBreaker) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Up);
EXPECT_FALSE(actions.disconnect_started_slots);
EXPECT_TRUE(actions.retry_disconnected_slots_now);
EXPECT_FALSE(actions.reset_reconnect_backoff);
}
TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::None);
EXPECT_FALSE(actions.disconnect_started_slots);
EXPECT_FALSE(actions.retry_disconnected_slots_now);
EXPECT_FALSE(actions.reset_reconnect_backoff);
}
TEST(NetworkPolicy, LinkSwitchReconnectsMqttSlotsImmediately) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Switched);
EXPECT_TRUE(actions.disconnect_started_slots);
EXPECT_TRUE(actions.retry_disconnected_slots_now);
EXPECT_TRUE(actions.reset_reconnect_backoff);
}
TEST(NetworkPolicy, MediumNamesAreStableForTransitionLogs) {
EXPECT_STREQ("none", NetworkPolicy::mediumName(NetworkMedium::None));
EXPECT_STREQ("ethernet", NetworkPolicy::mediumName(NetworkMedium::Ethernet));
EXPECT_STREQ("wifi", NetworkPolicy::mediumName(NetworkMedium::WiFi));
}
TEST(NetworkPolicy, BootPrefersEthernetAndOtherwiseUsesConfiguredWifi) {
EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::bootSelection(true, true));
EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::bootSelection(false, true));
EXPECT_EQ(NetworkMedium::None, NetworkPolicy::bootSelection(false, false));
}
TEST(NetworkPolicy, EthernetBootProbeUsesFullDeadlineForLinkOrUnknownState) {
EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(
true, false, false, false, 1500, 8000));
EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(
true, false, true, true, 7999, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(
true, false, true, true, 8000, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(
true, true, true, true, 100, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(
false, false, false, false, 100, 8000));
}
TEST(NetworkPolicy, EthernetBootProbeStopsEarlyOnKnownCableDown) {
EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(
true, false, true, false,
NetworkPolicy::kEthernetNoLinkBootGraceMs - 1, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(
true, false, true, false,
NetworkPolicy::kEthernetNoLinkBootGraceMs, 8000));
}
TEST(NetworkPolicy, EthernetInitRetryUsesBoundedExponentialBackoff) {
EXPECT_EQ(0u, NetworkPolicy::ethernetInitRetryDelayMs(0));
EXPECT_EQ(5000u, NetworkPolicy::ethernetInitRetryDelayMs(1));
EXPECT_EQ(10000u, NetworkPolicy::ethernetInitRetryDelayMs(2));
EXPECT_EQ(300000u, NetworkPolicy::ethernetInitRetryDelayMs(8));
EXPECT_EQ(300000u, NetworkPolicy::ethernetInitRetryDelayMs(255));
EXPECT_FALSE(NetworkPolicy::ethernetInitRetryDue(1, 4999, 0));
EXPECT_TRUE(NetworkPolicy::ethernetInitRetryDue(1, 5000, 0));
EXPECT_TRUE(NetworkPolicy::ethernetInitRetryDue(1, 10, 10u - 5000u));
}
TEST(NetworkPolicy, EthernetNoIpRecoveryRequiresSustainedCarrier) {
const uint32_t timeout = NetworkPolicy::kEthernetNoIpRecoveryMs;
EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, true, false, timeout, 0));
EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, true, false, timeout, 1));
EXPECT_TRUE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, true, false, timeout + 1, 1));
EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, false, false, timeout + 1, 1));
EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, true, true, timeout + 1, 1));
EXPECT_TRUE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, true, false, 100, 100u - timeout));
}
TEST(NetworkPolicy, EthernetFailureWaitsForGraceAndConnectedWifi) {
NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::Ethernet, false, true, true, false, 0,
NetworkPolicy::kEthernetDownGraceMs - 1};
EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input));
input.selected_down_ms = NetworkPolicy::kEthernetDownGraceMs;
EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input));
}
TEST(NetworkPolicy, FailbackRequiresStableEthernet) {
NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::WiFi, true, true, true, false,
NetworkPolicy::kEthernetFailbackStableMs - 1, 0};
EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input));
input.ethernet_stable_ms = NetworkPolicy::kEthernetFailbackStableMs;
EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input));
}
TEST(NetworkPolicy, DeadWifiFailsBackToReadyEthernetImmediately) {
const NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::WiFi, true, false, true, false, 0, 0};
EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input));
}
TEST(NetworkPolicy, SwitchingLockPinsTheCurrentMedium) {
const NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::WiFi, true, true, true, true,
NetworkPolicy::kEthernetFailbackStableMs, 0};
EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input));
}
TEST(NetworkPolicy, DiagnosticsIdentifyEthernetFallbackCause) {
using Reason = NetworkDiagnosticReason;
EXPECT_EQ(Reason::EthernetInitFailed,
NetworkPolicy::automaticDiagnosticReason(
false, false, false, false, NetworkMedium::WiFi, false, 0));
EXPECT_EQ(Reason::EthernetLinkUnknown,
NetworkPolicy::automaticDiagnosticReason(
true, false, false, false, NetworkMedium::WiFi, false, 0));
EXPECT_EQ(Reason::EthernetLinkDown,
NetworkPolicy::automaticDiagnosticReason(
true, true, false, false, NetworkMedium::WiFi, false, 0));
EXPECT_EQ(Reason::EthernetAwaitingIp,
NetworkPolicy::automaticDiagnosticReason(
true, true, true, false, NetworkMedium::WiFi, false, 0));
}
TEST(NetworkPolicy, DiagnosticsExplainWhyReadyEthernetHasNotBeenSelected) {
using Reason = NetworkDiagnosticReason;
EXPECT_EQ(Reason::SwitchingLocked,
NetworkPolicy::automaticDiagnosticReason(
true, true, true, true, NetworkMedium::WiFi, true,
NetworkPolicy::kEthernetFailbackStableMs));
EXPECT_EQ(Reason::EthernetStabilizing,
NetworkPolicy::automaticDiagnosticReason(
true, true, true, true, NetworkMedium::WiFi, false,
NetworkPolicy::kEthernetFailbackStableMs - 1));
EXPECT_EQ(Reason::EthernetReady,
NetworkPolicy::automaticDiagnosticReason(
true, true, true, true, NetworkMedium::WiFi, false,
NetworkPolicy::kEthernetFailbackStableMs));
EXPECT_EQ(Reason::EthernetActive,
NetworkPolicy::automaticDiagnosticReason(
true, true, true, true, NetworkMedium::Ethernet, false, 0));
}
TEST(NetworkPolicy, NtpIsScheduledOncePerConnectivityEdge) {
EXPECT_TRUE(NetworkPolicy::ntpPendingAfterConnectivitySample(
false, false, false, true));
EXPECT_FALSE(NetworkPolicy::ntpPendingAfterConnectivitySample(
false, false, true, true));
EXPECT_TRUE(NetworkPolicy::ntpPendingAfterConnectivitySample(
false, true, true, true));
EXPECT_FALSE(NetworkPolicy::ntpPendingAfterConnectivitySample(
true, false, false, true));
}
TEST(NetworkPolicy, FailedNtpSyncWaitsForThirtySecondRetryBoundary) {
EXPECT_FALSE(NetworkPolicy::ntpRetryDue(false, true, 29999, 0));
EXPECT_TRUE(NetworkPolicy::ntpRetryDue(false, true, 30000, 0));
EXPECT_FALSE(NetworkPolicy::ntpRetryDue(true, true, 60000, 0));
EXPECT_FALSE(NetworkPolicy::ntpRetryDue(false, false, 60000, 0));
EXPECT_TRUE(NetworkPolicy::ntpRetryDue(false, true, 10, 0xffff8000u));
}
TEST(NetworkPolicy, StartOtaUsesReachableSelectedNetworkByDefault) {
EXPECT_TRUE(NetworkPolicy::startOtaUsesSelectedNetwork(false, true));
EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(false, false));
}
TEST(NetworkPolicy, StartOtaForceApOverridesAReachableSelectedNetwork) {
EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(true, true));
EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(true, false));
}
TEST(NetworkPolicy, ManualOtaTimeoutIsUploadSafeAndWrapSafe) {
const uint32_t timeout = NetworkPolicy::kManualOtaSessionTimeoutMs;
EXPECT_FALSE(NetworkPolicy::manualOtaTimeoutDue(timeout - 1, 0, false));
EXPECT_TRUE(NetworkPolicy::manualOtaTimeoutDue(timeout, 0, false));
EXPECT_FALSE(NetworkPolicy::manualOtaTimeoutDue(timeout, 0, true));
EXPECT_TRUE(NetworkPolicy::manualOtaTimeoutDue(
100, 100 - timeout, false));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,184 @@
#include "helpers/NtpValidation.h"
#include <gtest/gtest.h>
#include <string.h>
using namespace NtpValidation;
namespace {
const uint32_t MIN_EPOCH = 1767225600UL; // 2026-01-01, the bridge's floor
const uint32_t MAX_EPOCH = 4102444800UL; // 2100-01-01
const Nonce NONCE = { 0xA1B2C3D4UL, 0x0F1E2D3CUL };
// A well-formed reply to buildRequest(): stratum 2, mode 4, our nonce echoed.
void makeReply(uint8_t out[48], uint32_t unix_epoch = 1789000000UL) {
memset(out, 0, 48);
out[0] = (0 << 6) | (4 << 3) | 4; // LI=0, VN=4, mode=4 (server)
out[1] = 2; // stratum
writeU32(out + 24, NONCE.seconds); // originate = our transmit timestamp
writeU32(out + 28, NONCE.fraction);
writeU32(out + 32, unix_epoch + kUnixEpochOffset); // receive
writeU32(out + 40, unix_epoch + kUnixEpochOffset); // transmit
}
Result check(const uint8_t* data, size_t len, bool source_matches = true) {
return validate(data, len, source_matches, NONCE, MIN_EPOCH, MAX_EPOCH);
}
} // namespace
TEST(NtpValidation, RequestIsAClientPacketCarryingTheNonce) {
uint8_t req[48];
memset(req, 0xFF, sizeof(req));
buildRequest(req, NONCE);
EXPECT_EQ(0u, (req[0] >> 6) & 0x03); // LI = 0, not the library's bogus alarm
EXPECT_EQ(4u, (req[0] >> 3) & 0x07); // version 4
EXPECT_EQ(3u, req[0] & 0x07); // mode 3 = client
EXPECT_EQ(NONCE.seconds, readU32(req + 40));
EXPECT_EQ(NONCE.fraction, readU32(req + 44));
}
TEST(NtpValidation, ValidReplyIsAccepted) {
uint8_t pkt[48];
makeReply(pkt, 1789000000UL);
Result r = check(pkt, sizeof(pkt));
EXPECT_EQ(kAccepted, r.reject);
EXPECT_EQ(1789000000UL, r.epoch);
}
// F07's reproduction: the installed NTPClient accepted a one-byte non-NTP
// datagram and derived epoch 2085978496 from the uninitialised buffer.
TEST(NtpValidation, OneByteNonNtpDatagramIsRejected) {
const uint8_t junk[1] = { 0x00 };
Result r = check(junk, sizeof(junk));
EXPECT_EQ(kShortPacket, r.reject);
EXPECT_EQ(0u, r.epoch);
}
TEST(NtpValidation, ShortAndEmptyDatagramsAreRejected) {
uint8_t pkt[48];
makeReply(pkt);
for (size_t len = 0; len < 48; len++) {
EXPECT_EQ(kShortPacket, check(pkt, len).reject) << "len " << len;
}
EXPECT_EQ(kShortPacket, check(nullptr, 48).reject);
}
TEST(NtpValidation, ReplyFromAnotherSourceIsRejected) {
uint8_t pkt[48];
makeReply(pkt);
EXPECT_EQ(kWrongSource, check(pkt, sizeof(pkt), /*source_matches=*/false).reject);
}
// An off-path sender that never saw the query cannot echo the nonce.
TEST(NtpValidation, UnsolicitedReplyIsRejected) {
uint8_t pkt[48];
makeReply(pkt);
writeU32(pkt + 24, 0);
writeU32(pkt + 28, 0);
EXPECT_EQ(kOriginMismatch, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
writeU32(pkt + 28, NONCE.fraction ^ 1u); // one bit off is still not ours
EXPECT_EQ(kOriginMismatch, check(pkt, sizeof(pkt)).reject);
}
TEST(NtpValidation, ClientModeAndWrongVersionAreRejected) {
uint8_t pkt[48];
makeReply(pkt);
pkt[0] = (0 << 6) | (4 << 3) | 3; // our own request echoed back
EXPECT_EQ(kBadMode, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
pkt[0] = (0 << 6) | (5 << 3) | 4; // version 5 does not exist
EXPECT_EQ(kBadVersion, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
pkt[0] = (0 << 6) | (3 << 3) | 4; // NTPv3 servers are fine
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
}
TEST(NtpValidation, UnsynchronisedServersAreRejected) {
uint8_t pkt[48];
makeReply(pkt);
pkt[0] |= (3 << 6); // LI = 3: alarm, clock not set
EXPECT_EQ(kLeapAlarm, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
pkt[1] = 0; // stratum 0: kiss-of-death
EXPECT_EQ(kBadStratum, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
pkt[1] = 16; // unsynchronised
EXPECT_EQ(kBadStratum, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
pkt[1] = 15; // last usable stratum
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
}
TEST(NtpValidation, MissingTransmitTimestampIsRejected) {
uint8_t pkt[48];
makeReply(pkt);
writeU32(pkt + 40, 0);
EXPECT_EQ(kZeroTransmit, check(pkt, sizeof(pkt)).reject);
}
// The bridge's floor is the only defence against a stale or rolled-back clock,
// and nothing previously stopped an absurd forward jump.
TEST(NtpValidation, ImplausibleTimesAreRejected) {
uint8_t pkt[48];
makeReply(pkt, MIN_EPOCH - 1);
EXPECT_EQ(kEpochTooEarly, check(pkt, sizeof(pkt)).reject);
makeReply(pkt, MIN_EPOCH);
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
makeReply(pkt, MAX_EPOCH + 1);
EXPECT_EQ(kEpochTooLate, check(pkt, sizeof(pkt)).reject);
makeReply(pkt, MAX_EPOCH);
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
}
// Era 1 begins 2036-02-07; a naive subtraction turns those seconds into 1900.
TEST(NtpValidation, Era1TimestampsConvertForward) {
EXPECT_EQ(0u, unixFromNtpSeconds(kUnixEpochOffset));
EXPECT_EQ(2085978496UL, unixFromNtpSeconds(0)); // 2036-02-07
EXPECT_GT(unixFromNtpSeconds(1000), unixFromNtpSeconds(0xFFFFFFFFu));
uint8_t pkt[48];
makeReply(pkt);
writeU32(pkt + 40, 1000); // 1000 s into era 1
Result r = check(pkt, sizeof(pkt));
EXPECT_EQ(kAccepted, r.reject);
EXPECT_EQ(2085979496UL, r.epoch);
}
TEST(NtpValidation, EveryRejectionHasAShortReason) {
for (int i = 0; i <= (int)kEpochTooLate; i++) {
const char* reason = rejectReason((Reject)i);
ASSERT_NE(nullptr, reason);
EXPECT_GT(strlen(reason), 0u);
EXPECT_LE(strlen(reason), 20u) << "reject " << i;
}
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,204 @@
#include "helpers/RadioWatchdog.h"
#include <gtest/gtest.h>
namespace {
const uint32_t WATCHDOG_MS = 300000UL; // RADIO_WATCHDOG_MS default: 5 minutes
// Mirrors Dispatcher::loop(): the radio's three timestamps are sampled every
// pass, and a recovery is acknowledged as soon as it is decided.
struct Radio {
uint32_t last_recv = 0;
uint32_t last_irq = 0;
uint32_t last_tx = 0;
};
RadioWatchdogDecision run(RadioWatchdog& w, const Radio& r, uint32_t now,
bool in_recv_mode = true, uint32_t watchdog_ms = WATCHDOG_MS) {
RadioWatchdogDecision d = w.update(now, in_recv_mode, watchdog_ms,
r.last_recv, r.last_irq, r.last_tx);
if (d.recover) w.noteRecovery();
return d;
}
} // namespace
TEST(RadioWatchdog, NoActivitySinceBootNeverTrips) {
RadioWatchdog w;
w.reset(0);
Radio r; // radio has never received, interrupted or transmitted
for (uint32_t t = 0; t <= 4 * WATCHDOG_MS; t += 10000) {
RadioWatchdogDecision d = run(w, r, t);
EXPECT_FALSE(d.recover) << "tripped at t=" << t;
EXPECT_FALSE(d.measurable);
}
}
TEST(RadioWatchdog, ContinuousReceiveNeverTrips) {
RadioWatchdog w;
w.reset(0);
Radio r;
for (uint32_t t = 1000; t <= 3 * WATCHDOG_MS; t += 1000) {
r.last_recv = t;
r.last_irq = t;
RadioWatchdogDecision d = run(w, r, t);
EXPECT_FALSE(d.recover) << "tripped at t=" << t;
EXPECT_LE(d.silent_ms, 1000u);
}
}
TEST(RadioWatchdog, SilentRadioTripsOncePerInterval) {
RadioWatchdog w;
w.reset(0);
Radio r;
r.last_recv = 1000;
run(w, r, 1000);
int trips = 0;
uint32_t first_trip = 0;
for (uint32_t t = 2000; t <= 1000000; t += 1000) {
if (run(w, r, t).recover) {
if (trips == 0) first_trip = t;
trips++;
}
}
EXPECT_EQ(302000u, first_trip); // strictly past the interval, from the last receive
EXPECT_EQ(3, trips); // ~1 M ms / 300 s, never per-loop
}
// F13: a transmit timestamp from just before the millis() wrap is numerically
// larger than every fresh post-wrap receive. Selecting the largest timestamp
// reported ~24 days of silence and reset a radio that was receiving normally.
TEST(RadioWatchdog, PreWrapTransmitDoesNotSilenceFreshPostWrapReceives) {
const uint32_t WRAP = 0xFFFFFFFFu;
const uint32_t TX_AT = 0x80000000u; // upper half: beats any post-wrap value
RadioWatchdog w;
w.reset(TX_AT - 60000);
Radio r;
r.last_recv = TX_AT - 60000;
r.last_irq = TX_AT - 60000;
run(w, r, TX_AT - 60000);
r.last_tx = TX_AT;
run(w, r, TX_AT);
// Receive steadily right up to the wrap...
for (uint32_t t = TX_AT + 1000; t < WRAP - 1000; t += 1000) {
r.last_recv = t;
r.last_irq = t;
ASSERT_FALSE(run(w, r, t).recover) << "tripped before wrap at t=" << t;
}
// ...and straight through it. The stale transmit is still the numerically
// largest of the three timestamps for the next ~24 days.
for (uint32_t t = 1000; t < 2 * WATCHDOG_MS; t += 1000) {
r.last_recv = t;
r.last_irq = t;
RadioWatchdogDecision d = run(w, r, t);
ASSERT_FALSE(d.recover) << "tripped after wrap at t=" << t;
ASSERT_LE(d.silent_ms, 1000u);
}
}
// The wrap must not hide a genuinely silent radio either.
TEST(RadioWatchdog, SilenceAcrossTheWrapIsStillDetected) {
const uint32_t LAST_RX = 0xFFFF0000u; // ~65 s before the wrap
RadioWatchdog w;
w.reset(LAST_RX - 1000);
Radio r;
r.last_recv = LAST_RX;
r.last_irq = LAST_RX;
run(w, r, LAST_RX);
bool tripped = false;
uint64_t silent_at_trip = 0;
for (uint32_t step = 1000; step <= WATCHDOG_MS + 5000; step += 1000) {
uint32_t t = LAST_RX + step; // wraps on its own
RadioWatchdogDecision d = run(w, r, t);
if (d.recover) {
tripped = true;
silent_at_trip = d.silent_ms;
break;
}
}
EXPECT_TRUE(tripped);
EXPECT_GT(silent_at_trip, (uint64_t)WATCHDOG_MS);
}
// Uptime past 2^32 ms must not alias a long silence back to "recent".
TEST(RadioWatchdog, SilenceBeyondOneMillisCycleKeepsGrowing) {
RadioWatchdog w;
w.reset(0);
Radio r;
r.last_recv = 1000;
run(w, r, 1000);
// Walk a full cycle in 1-minute steps; the raw timestamp aliases back to its
// starting value, the tracked age does not.
uint64_t last_silent = 0;
for (uint64_t step = 60000; step <= 0x100000000ull + 600000ull; step += 60000) {
uint32_t t = (uint32_t)((1000ull + step) & 0xFFFFFFFFull);
RadioWatchdogDecision d = w.update(t, true, WATCHDOG_MS, r.last_recv, r.last_irq, r.last_tx);
if (d.recover) w.noteRecovery();
ASSERT_GT(d.silent_ms, last_silent);
last_silent = d.silent_ms;
}
EXPECT_GT(last_silent, 0x100000000ull);
}
TEST(RadioWatchdog, DisabledWatchdogAndNonRecvModeNeverTrip) {
RadioWatchdog w;
w.reset(0);
Radio r;
r.last_recv = 1000;
run(w, r, 1000);
for (uint32_t t = 2000; t <= 1000000; t += 10000) {
EXPECT_FALSE(run(w, r, t, /*in_recv_mode=*/true, /*watchdog_ms=*/0).recover);
EXPECT_FALSE(run(w, r, t, /*in_recv_mode=*/false).recover);
}
}
// Transmit-only traffic still counts as a live radio.
TEST(RadioWatchdog, TransmitActivityRefreshesSilence) {
RadioWatchdog w;
w.reset(0);
Radio r;
r.last_recv = 1000;
run(w, r, 1000);
for (uint32_t t = 100000; t <= 900000; t += 100000) {
r.last_tx = t;
EXPECT_FALSE(run(w, r, t).recover) << "tripped at t=" << t;
}
}
// A blocked mesh loop (e.g. a 30 s NTP probe) collapses several events into one
// observation. That may only shorten measured silence, never lengthen it.
TEST(RadioWatchdog, LoopStallDoesNotManufactureSilence) {
RadioWatchdog w;
w.reset(0);
Radio r;
r.last_recv = 1000;
run(w, r, 1000);
r.last_recv = 20000; // arrived during the stall
r.last_irq = 20000;
RadioWatchdogDecision d = run(w, r, 31000);
EXPECT_FALSE(d.recover);
EXPECT_EQ(0u, d.silent_ms);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -295,6 +295,7 @@ TEST(WebConfigBatch, CliFailureRepliesAreRecognisedInEveryShapeCommonCLIEmits) {
"Err - bad params", // MyMesh setperm
"ERR: bad pubkey", // neighbor.remove
"Error: IATA code must be exactly 3 letters",// observer setters
"Error: setting not persisted; change rolled back", // observer storage failure
"(ERR: clock cannot go backwards)", // clock sync, parenthesised
"Unknown command", // top-level fallthrough
"unknown config: mqtt.nope", // set fallthrough
@@ -15,6 +15,8 @@ TEST(WebConfigKeys, AllowsKnownScalarKeys) {
EXPECT_TRUE(wcIsAllowedSetKey("radio.rxps"));
EXPECT_TRUE(wcIsAllowedSetKey("powersaving"));
EXPECT_TRUE(wcIsAllowedSetKey("repeat"));
EXPECT_TRUE(wcIsAllowedSetKey("radio.fem.rxgain")); // Board::handleCommand(), not CommonCLI
EXPECT_TRUE(wcIsAllowedSetKey("radio.fem.txgain"));
EXPECT_TRUE(wcIsAllowedSetKey("wifi.ssid"));
EXPECT_TRUE(wcIsAllowedSetKey("espnow.channel"));
EXPECT_TRUE(wcIsAllowedSetKey("mqtt.iata"));
@@ -0,0 +1,68 @@
#include "helpers/WifiPowerSavePolicy.h"
#include <gtest/gtest.h>
using namespace WifiPowerSavePolicy;
// F11: the CLI applied stored 0 as MIN_MODEM while the reconnect path applied it
// as NONE, so a node configured for `min` changed behaviour after every
// reconnect and `get wifi.powersave` still reported min. One table, one answer.
TEST(WifiPowerSavePolicy, StoredValuesMapToOneModeEach) {
EXPECT_EQ(kModeMinModem, modeFor(kMin));
EXPECT_EQ(kModeNone, modeFor(kNone));
EXPECT_EQ(kModeMaxModem, modeFor(kMax));
}
// Stored 0 was the default for nodes set up 2026-01-02..03-28, and they have
// always run with power save off; reading it as `min` would put them to sleep.
TEST(WifiPowerSavePolicy, LegacyDefaultKeepsPowerSaveOff) {
EXPECT_EQ(kModeNone, modeFor(kLegacyDefault));
EXPECT_STREQ("none", nameFor(kLegacyDefault));
uint8_t parsed = 0xFF;
ASSERT_TRUE(parseName("min", &parsed));
EXPECT_NE(kLegacyDefault, parsed);
}
TEST(WifiPowerSavePolicy, NamesRoundTripWithStoredValues) {
for (uint8_t stored = kNone; stored <= kMaxStoredValue; stored++) {
uint8_t parsed = 0xFF;
ASSERT_TRUE(parseName(nameFor(stored), &parsed)) << "stored " << (int)stored;
EXPECT_EQ(stored, parsed);
EXPECT_EQ(modeFor(stored), modeFor(parsed));
}
}
// A byte outside the stored range must read as the product default, not as
// whatever mode happens to sit at that index.
TEST(WifiPowerSavePolicy, OutOfRangeStoredValueReadsAsDefault) {
for (int stored = kMaxStoredValue + 1; stored <= 255; stored++) {
EXPECT_EQ(kModeNone, modeFor((uint8_t)stored)) << "stored " << stored;
EXPECT_STREQ("none", nameFor((uint8_t)stored));
}
}
// The setters take the remainder of the command line, so a trailing argument
// must still parse — and a longer word starting with a valid name must not.
TEST(WifiPowerSavePolicy, ParsesCliArgumentsExactly) {
uint8_t stored = 0xFF;
EXPECT_TRUE(parseName("min", &stored));
EXPECT_EQ(kMin, stored);
EXPECT_TRUE(parseName("none extra", &stored));
EXPECT_EQ(kNone, stored);
EXPECT_TRUE(parseName("max ", &stored));
EXPECT_EQ(kMax, stored);
stored = 0xFF;
EXPECT_FALSE(parseName("minimum", &stored));
EXPECT_FALSE(parseName("", &stored));
EXPECT_FALSE(parseName("off", &stored));
EXPECT_FALSE(parseName("MIN", &stored));
EXPECT_FALSE(parseName(nullptr, &stored));
EXPECT_EQ(0xFF, stored); // rejected input leaves the caller's value alone
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+3 -3
View File
@@ -17,9 +17,9 @@ build_flags =
-D P_LORA_SCLK=9
-D P_LORA_MISO=11
-D P_LORA_MOSI=10
-D P_LORA_PA_POWER=7
-D P_LORA_KCT8103L_PA_CSD=2
-D P_LORA_KCT8103L_PA_CTX=5
-D P_LORA_PA_POWER=7 ; VFEM_Ctrl - LDO power enable
-D P_LORA_KCT8103L_PA_CSD=2 ; FEM_EN / CSD (HIGH=on)
-D P_LORA_KCT8103L_PA_CTX=5 ; PA_CTX (LOW=RX LNA, HIGH=RX bypass)
-D P_LORA_TX_LED=46
-D PIN_USER_BTN=0
-D PIN_VEXT_EN=40
+167 -10
View File
@@ -1,4 +1,7 @@
[Station_G3_ESP32]
; The motherboard has two RF daughterboard slots on separate GPIOs, mirrored by its
; "LNA P" / "LNA S" jumpers. Slot 1 is [Station_G3_ESP32], slot 2 is [Station_G3_ESP32_r2];
; the slot pins sit in their own sections so neither set of -D flags shadows the other.
[Station_G3_ESP32_common]
extends = esp32_base
board = station-g3-esp32
board_build.partitions = default_16MB.csv
@@ -11,17 +14,8 @@ build_flags =
-D USE_SX1262
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D P_LORA_DIO_1=48
-D P_LORA_NSS=11
-D P_LORA_RESET=21
-D P_LORA_BUSY=47
-D P_LORA_SCLK=12
-D P_LORA_MISO=14
-D P_LORA_MOSI=13
-D P_PA1_EN=9 ; PA PL1 Mode: LOW/open selects low level, HIGH/short selects high level.
-D P_PA1_EN_ACTIVE=HIGH
-D P_PRIMARY_LNA_EN=10 ; Primary Slot LNA Mode: LOW/open is LNA on, HIGH/short is LNA off.
-D P_PRIMARY_LNA_EN_ACTIVE=LOW
-D LORA_TX_POWER=7 ; SX1262 input power to the Station G3 PA; final output depends on PA PL1/PL2 level.
-D MAX_LORA_TX_POWER=22
; -D P_LORA_TX_LED=35
@@ -48,6 +42,48 @@ lib_deps =
adafruit/Adafruit SH110X @ ~2.1.13
adafruit/Adafruit GFX Library @ ^1.12.1
; PA PL1 is one board-level jumper shared by both slots, so P_PA1_EN stays in the base above.
[station_g3_esp32_r1_pins]
build_flags =
-D P_LORA_DIO_1=48
-D P_LORA_NSS=11
-D P_LORA_RESET=21
-D P_LORA_BUSY=47
-D P_LORA_SCLK=12
-D P_LORA_MISO=14
-D P_LORA_MOSI=13
-D P_PRIMARY_LNA_EN=10 ; Primary Slot LNA Mode: LOW/open is LNA on, HIGH/short is LNA off.
-D P_PRIMARY_LNA_EN_ACTIVE=LOW
; Slot 2 ("LNA S"). P_PRIMARY_LNA_EN keeps its name because LoRaFEMControl knows only one LNA pin.
[station_g3_esp32_r2_pins]
build_flags =
-D P_LORA_DIO_1=2
-D P_LORA_NSS=43
-D P_LORA_RESET=44
-D P_LORA_BUSY=1
-D P_LORA_SCLK=39
-D P_LORA_MISO=41
-D P_LORA_MOSI=40
-D P_PRIMARY_LNA_EN=42 ; Secondary Slot LNA Mode, same polarity as the primary slot.
-D P_PRIMARY_LNA_EN_ACTIVE=LOW
[Station_G3_ESP32]
extends = Station_G3_ESP32_common
build_flags =
${Station_G3_ESP32_common.build_flags}
${station_g3_esp32_r1_pins.build_flags}
build_src_filter = ${Station_G3_ESP32_common.build_src_filter}
lib_deps = ${Station_G3_ESP32_common.lib_deps}
[Station_G3_ESP32_r2]
extends = Station_G3_ESP32_common
build_flags =
${Station_G3_ESP32_common.build_flags}
${station_g3_esp32_r2_pins.build_flags}
build_src_filter = ${Station_G3_ESP32_common.build_src_filter}
lib_deps = ${Station_G3_ESP32_common.lib_deps}
[env:Station_G3_ESP32_repeater]
extends = Station_G3_ESP32
build_flags =
@@ -268,3 +304,124 @@ lib_deps =
JChristensen/Timezone
paulstoffregen/Time@1.6.1
0neblock/SNMP_Agent
[env:Station_G3_ESP32_r2_repeater]
extends = Station_G3_ESP32_r2
build_flags =
${Station_G3_ESP32_r2.build_flags}
-D ADVERT_NAME='"Station G3 ESP32 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${Station_G3_ESP32_r2.build_src_filter}
+<../examples/simple_repeater>
lib_deps =
${Station_G3_ESP32_r2.lib_deps}
${esp32_ota.lib_deps}
[env:Station_G3_ESP32_r2_room_server]
extends = Station_G3_ESP32_r2
build_src_filter = ${Station_G3_ESP32_r2.build_src_filter}
+<../examples/simple_room_server>
build_flags =
${Station_G3_ESP32_r2.build_flags}
-D ADVERT_NAME='"Station G3 ESP32 Room"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
lib_deps =
${Station_G3_ESP32_r2.lib_deps}
${esp32_ota.lib_deps}
[env:Station_G3_ESP32_r2_repeater_observer_mqtt]
extends = Station_G3_ESP32_r2
board_build.partitions = default_16MB.csv ; standard 16MB partition table
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 =
${Station_G3_ESP32_r2.build_flags}
-D ADVERT_NAME='"MQTT Observer"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
# -D MESH_PACKET_LOGGING=1
# -D MESH_DEBUG=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
-D WITH_SNMP=1
# -D WIFI_SSID='"ssid"'
# -D WIFI_PWD='"password"'
# -D MQTT_SERVER='"your-mqtt-broker.com"'
# -D MQTT_PORT=1883
# -D MQTT_USERNAME='"your-username"'
# -D MQTT_PASSWORD='"your-password"'
build_src_filter = ${Station_G3_ESP32_r2.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/SNMPAgent.cpp>
+<helpers/ui/SH1106Display.cpp>
+<../examples/simple_repeater>
lib_deps =
${Station_G3_ESP32_r2.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
0neblock/SNMP_Agent
[env:Station_G3_ESP32_r2_room_server_observer_mqtt]
extends = Station_G3_ESP32_r2
board_build.partitions = default_16MB.csv ; standard 16MB partition table
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 =
${Station_G3_ESP32_r2.build_flags}
-D ADVERT_NAME='"Station G3 ESP32 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 MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
-D WITH_SNMP=1
build_src_filter = ${Station_G3_ESP32_r2.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<helpers/ui/SH1106Display.cpp>
+<helpers/SNMPAgent.cpp>
+<../examples/simple_room_server>
lib_deps =
${Station_G3_ESP32_r2.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
0neblock/SNMP_Agent
+53 -3
View File
@@ -760,6 +760,11 @@ function boot(){
st.nslots=st.hasMqtt?Math.min(s.active_slots||s.runtime_slots||0,s.runtime_slots||s.active_slots||0):0;
if(s.max_cmds>0)CLI_MAX=s.max_cmds;
st.fw=s.fw||"";st.build=s.build_date||"";st.role=s.role||"";st.board=s.board||"";
// Board-specific CLI commands this node answered for at startup. Controls
// for anything absent stay hidden: on this board they are not commands.
// The field is ABSENT, not empty, until the node has finished probing, and
// this page reads status once — so latch it only when it is really there.
setBoardCmds(s.board_cmds);
$("#h-name").textContent=s.name||"MeshCore";
$("#h-sub").textContent=s.role+" * "+shortVer()+" * "+s.board;
var b=$("#h-badge");b.classList.remove("hide");
@@ -1905,7 +1910,6 @@ var CLI_KEYS=[
["dutycycle","Duty cycle percent (writes airtime factor)",0],
["cad","Listen before transmit",0,"on|off"],
["radio.rxgain","SX126x RX boosted gain",0,"on|off"],
["radio.fem.rxgain","Front-end module RX gain",0],
["radio.rxps","RX duty-cycle power saving",0,"off|level 1-10|level 1-10 preamble 16|32|rx_us sleep_us"],
["radio.watchdog","Restart the radio if silent this long {0-120 min}",0],
["int.thresh","Interference threshold",0],
@@ -1929,6 +1933,8 @@ var CLI_KEYS=[
["wifi.ssid","WiFi network name",0],
["wifi.pwd","WiFi password",0],
["wifi.powersave","WiFi power-save mode",0,"none|min|max"],
["link.status","Selected network, IP, signal and uptime",1],
["link.diag","Ethernet selection and fallback diagnostics",1],
["wifi.status","WiFi connection, IP, RSSI and uptime",1],
["wifi.cli","Browser console",0,"on|off"],
["webui","WiFi settings website",0,"on|off"],
@@ -1974,6 +1980,46 @@ var CLI_KEYS=[
["bridge.format","ESP-NOW bridge wire format",0,"wrapped|raw"],
["bridge.secret","Bridge shared secret",0]
];
/* Keys that come from Board::handleCommand(), not CommonCLI, so they exist only
on some boards. The node probes its own board at startup and names the ones it
answers in /api/status (board_cmds); the rest are never offered, because on
this board they are not commands at all. `gate` is the probed name — fan.lo
and fan.hi have no getter of their own and ride on `fan`.
[gate, key, description, mode, values] */
var CLI_BOARD_KEYS=[
["radio.fem.rxgain","radio.fem.rxgain","Front-end module RX gain",0,"on|off"],
["radio.fem.txgain","radio.fem.txgain","Front-end module TX gain",0,"on|off"],
["fan","fan","Fan mode, temperature and cooldown",0,"on|off|auto"],
["fan","fan.lo","Fan-off temperature in C {0-100, below fan.hi}",2],
["fan","fan.hi","Fan-on temperature in C {above fan.lo, max 120}",2]
];
function boardHas(gate){return st.boardCmds.indexOf(gate)>=0}
// An absent board_cmds means the node has not finished probing its board, which
// it does on the first tick after the routes go live — so in practice the very
// first retry finds it. Retry rather than read absent as "no board commands",
// which would hide working controls for the whole session. Backs off to ~15s
// total: generous against a one-tick gap, and bounded, since a node that never
// answers is one where hiding the controls is the right outcome anyway.
function setBoardCmds(v,tries){
if(v===undefined||v===null){
tries=tries||0;
if(tries<10)setTimeout(function(){
api("/api/status").then(function(s){setBoardCmds(s.board_cmds,tries+1)})
.catch(function(){});
},Math.min(300*(tries+1),2000));
return;
}
st.boardCmds=v.split(",").filter(Boolean);
cli.built=0; // the command table is keyed on this; force a rebuild
applyBoardCmds();
}
// Board-specific rows in the settings form. Hidden unless the node answered for
// the command at startup, so the form never shows a control that cannot apply.
function applyBoardCmds(){
$("#sw-fem-rx").classList.toggle("hide",!boardHas("radio.fem.rxgain"));
$("#sw-fem-tx").classList.toggle("hide",!boardHas("radio.fem.txgain"));
}
// Per-slot keys, expanded across the slots this board actually runs.
// [field, description, values]
var CLI_SLOT=[
@@ -2076,8 +2122,8 @@ function cliKeys(){
var cli={built:-1,tbl:[],hist:[],hix:-1,draft:"",sug:[],sel:-1,busy:false,shown:false,pwd:false};
// Rebuilt when the slot count changes: `active_slots` decides how many
// mqttN.* keys actually exist on this board.
// Rebuilt when the board's surface changes: `active_slots` decides how many
// mqttN.* keys exist, and board_cmds which of the board-specific keys do.
function cliTable(){
var signature=[st.role,st.nslots,st.hasMqtt,st.caps].join(":");
if(cli.built===signature)return cli.tbl;
@@ -2116,6 +2162,10 @@ function cliEnum(key){
for(var i=0;i<keys.length;i++){
if(keys[i][0]===key)return keys[i][3]?keys[i][3].split("|"):null;
}
for(var j=0;j<CLI_BOARD_KEYS.length;j++){
var b=CLI_BOARD_KEYS[j];
if(b[1]===key&&boardHas(b[0]))return b[4]?b[4].split("|"):null;
}
return null;
}